Compare commits
7 Commits
87bddcfb95
...
37c685d73f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37c685d73f | ||
|
|
295874e11f | ||
|
|
aa301eb447 | ||
|
|
0e6383693c | ||
|
|
1da5570e1c | ||
|
|
b344650608 | ||
|
|
9950d96fd6 |
@ -12,6 +12,7 @@ import {
|
|||||||
UsePipes,
|
UsePipes,
|
||||||
ValidationPipe,
|
ValidationPipe,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
|
BadRequestException,
|
||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
ParseIntPipe,
|
ParseIntPipe,
|
||||||
DefaultValuePipe,
|
DefaultValuePipe,
|
||||||
@ -41,10 +42,16 @@ import {
|
|||||||
ORGANIZATION_REPOSITORY,
|
ORGANIZATION_REPOSITORY,
|
||||||
} from '@domain/ports/out/organization.repository';
|
} from '@domain/ports/out/organization.repository';
|
||||||
import { Organization, OrganizationType } from '@domain/entities/organization.entity';
|
import { Organization, OrganizationType } from '@domain/entities/organization.entity';
|
||||||
|
import {
|
||||||
|
NotificationType,
|
||||||
|
NotificationPriority,
|
||||||
|
} from '@domain/entities/notification.entity';
|
||||||
|
import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository';
|
||||||
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||||
import { RolesGuard } from '../guards/roles.guard';
|
import { RolesGuard } from '../guards/roles.guard';
|
||||||
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
|
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
|
||||||
import { Roles } from '../decorators/roles.decorator';
|
import { Roles } from '../decorators/roles.decorator';
|
||||||
|
import { NotificationService } from '../services/notification.service';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -64,7 +71,9 @@ export class OrganizationsController {
|
|||||||
private readonly logger = new Logger(OrganizationsController.name);
|
private readonly logger = new Logger(OrganizationsController.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(ORGANIZATION_REPOSITORY) private readonly organizationRepository: OrganizationRepository
|
@Inject(ORGANIZATION_REPOSITORY) private readonly organizationRepository: OrganizationRepository,
|
||||||
|
@Inject(USER_REPOSITORY) private readonly userRepository: UserRepository,
|
||||||
|
private readonly notificationService: NotificationService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -123,6 +132,11 @@ export class OrganizationsController {
|
|||||||
name: dto.name,
|
name: dto.name,
|
||||||
type: dto.type,
|
type: dto.type,
|
||||||
scac: dto.scac,
|
scac: dto.scac,
|
||||||
|
siren: dto.siren,
|
||||||
|
siret: dto.siret,
|
||||||
|
eori: dto.eori,
|
||||||
|
contact_phone: dto.contact_phone,
|
||||||
|
contact_email: dto.contact_email,
|
||||||
address: OrganizationMapper.mapDtoToAddress(dto.address),
|
address: OrganizationMapper.mapDtoToAddress(dto.address),
|
||||||
logoUrl: dto.logoUrl,
|
logoUrl: dto.logoUrl,
|
||||||
documents: [],
|
documents: [],
|
||||||
@ -252,6 +266,10 @@ export class OrganizationsController {
|
|||||||
organization.updateSiren(dto.siren);
|
organization.updateSiren(dto.siren);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (dto.siret) {
|
||||||
|
organization.updateSiret(dto.siret);
|
||||||
|
}
|
||||||
|
|
||||||
if (dto.eori) {
|
if (dto.eori) {
|
||||||
organization.updateEori(dto.eori);
|
organization.updateEori(dto.eori);
|
||||||
}
|
}
|
||||||
@ -288,6 +306,72 @@ export class OrganizationsController {
|
|||||||
return OrganizationMapper.toDto(updatedOrg);
|
return OrganizationMapper.toDto(updatedOrg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request SIRET/SIREN approval from admins
|
||||||
|
*
|
||||||
|
* Any authenticated user can call this to notify all admins
|
||||||
|
* that their organization's SIRET/SIREN needs approval.
|
||||||
|
*/
|
||||||
|
@Post('request-siret-approval')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Request SIRET/SIREN approval',
|
||||||
|
description: 'Sends a notification to all admins requesting manual SIRET/SIREN verification.',
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, description: 'Approval request sent to admins' })
|
||||||
|
@ApiResponse({ status: 400, description: 'No SIRET/SIREN registered or already verified' })
|
||||||
|
async requestSiretApproval(
|
||||||
|
@CurrentUser() user: UserPayload
|
||||||
|
): Promise<{ message: string }> {
|
||||||
|
const organization = await this.organizationRepository.findById(user.organizationId);
|
||||||
|
if (!organization) {
|
||||||
|
throw new NotFoundException('Organization not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!organization.siren && !organization.siret) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Aucun SIRET ou SIREN renseigné sur votre organisation. Veuillez les ajouter avant de demander la validation.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (organization.siretVerified) {
|
||||||
|
throw new BadRequestException('Votre SIRET/SIREN est déjà vérifié.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const admins = await this.userRepository.findByRole('ADMIN');
|
||||||
|
|
||||||
|
const identifier = organization.siret
|
||||||
|
? `SIRET ${organization.siret}`
|
||||||
|
: `SIREN ${organization.siren}`;
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
admins.map(admin =>
|
||||||
|
this.notificationService.createNotification({
|
||||||
|
userId: admin.id,
|
||||||
|
organizationId: admin.organizationId,
|
||||||
|
type: NotificationType.ORGANIZATION_UPDATE,
|
||||||
|
priority: NotificationPriority.HIGH,
|
||||||
|
title: 'Demande de validation SIRET/SIREN',
|
||||||
|
message: `L'organisation "${organization.name}" demande la validation de son ${identifier}.`,
|
||||||
|
metadata: {
|
||||||
|
organizationId: organization.id,
|
||||||
|
organizationName: organization.name,
|
||||||
|
siret: organization.siret,
|
||||||
|
siren: organization.siren,
|
||||||
|
requestedBy: user.email,
|
||||||
|
},
|
||||||
|
actionUrl: `/dashboard/admin/organizations`,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`[${user.email}] SIRET/SIREN approval requested for org ${organization.name} (${organization.id})`
|
||||||
|
);
|
||||||
|
|
||||||
|
return { message: 'Votre demande a été envoyée aux administrateurs.' };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List organizations
|
* List organizations
|
||||||
*
|
*
|
||||||
|
|||||||
@ -104,16 +104,21 @@ export class CreateOrganizationDto {
|
|||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
example: '123456789',
|
example: '123456789',
|
||||||
description: 'French SIREN number (9 digits)',
|
description: 'French SIREN number (9 digits)',
|
||||||
minLength: 9,
|
|
||||||
maxLength: 9,
|
|
||||||
})
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@MinLength(9)
|
|
||||||
@MaxLength(9)
|
|
||||||
@Matches(/^[0-9]{9}$/, { message: 'SIREN must be 9 digits' })
|
@Matches(/^[0-9]{9}$/, { message: 'SIREN must be 9 digits' })
|
||||||
siren?: string;
|
siren?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: '12345678901234',
|
||||||
|
description: 'French SIRET number (14 digits)',
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@Matches(/^[0-9]{14}$/, { message: 'SIRET must be 14 digits' })
|
||||||
|
siret?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
example: 'FR123456789',
|
example: 'FR123456789',
|
||||||
description: 'EU EORI number',
|
description: 'EU EORI number',
|
||||||
@ -174,26 +179,18 @@ export class UpdateOrganizationDto {
|
|||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
example: '123456789',
|
example: '123456789',
|
||||||
description: 'French SIREN number (9 digits)',
|
description: 'French SIREN number (9 digits)',
|
||||||
minLength: 9,
|
|
||||||
maxLength: 9,
|
|
||||||
})
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@MinLength(9)
|
|
||||||
@MaxLength(9)
|
|
||||||
@Matches(/^[0-9]{9}$/, { message: 'SIREN must be 9 digits' })
|
@Matches(/^[0-9]{9}$/, { message: 'SIREN must be 9 digits' })
|
||||||
siren?: string;
|
siren?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
example: '12345678901234',
|
example: '12345678901234',
|
||||||
description: 'French SIRET number (14 digits)',
|
description: 'French SIRET number (14 digits)',
|
||||||
minLength: 14,
|
|
||||||
maxLength: 14,
|
|
||||||
})
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@MinLength(14)
|
|
||||||
@MaxLength(14)
|
|
||||||
@Matches(/^[0-9]{14}$/, { message: 'SIRET must be 14 digits' })
|
@Matches(/^[0-9]{14}$/, { message: 'SIRET must be 14 digits' })
|
||||||
siret?: string;
|
siret?: string;
|
||||||
|
|
||||||
|
|||||||
@ -1,15 +1,18 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { OrganizationsController } from '../controllers/organizations.controller';
|
import { OrganizationsController } from '../controllers/organizations.controller';
|
||||||
|
import { UsersModule } from '../users/users.module';
|
||||||
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
|
|
||||||
// Import domain ports
|
|
||||||
import { ORGANIZATION_REPOSITORY } from '@domain/ports/out/organization.repository';
|
import { ORGANIZATION_REPOSITORY } from '@domain/ports/out/organization.repository';
|
||||||
import { TypeOrmOrganizationRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-organization.repository';
|
import { TypeOrmOrganizationRepository } from '../../infrastructure/persistence/typeorm/repositories/typeorm-organization.repository';
|
||||||
import { OrganizationOrmEntity } from '../../infrastructure/persistence/typeorm/entities/organization.orm-entity';
|
import { OrganizationOrmEntity } from '../../infrastructure/persistence/typeorm/entities/organization.orm-entity';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([OrganizationOrmEntity]), // 👈 This line registers the repository provider
|
TypeOrmModule.forFeature([OrganizationOrmEntity]),
|
||||||
|
UsersModule,
|
||||||
|
NotificationsModule,
|
||||||
],
|
],
|
||||||
controllers: [OrganizationsController],
|
controllers: [OrganizationsController],
|
||||||
providers: [
|
providers: [
|
||||||
@ -18,8 +21,6 @@ import { OrganizationOrmEntity } from '../../infrastructure/persistence/typeorm/
|
|||||||
useClass: TypeOrmOrganizationRepository,
|
useClass: TypeOrmOrganizationRepository,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [ORGANIZATION_REPOSITORY],
|
||||||
ORGANIZATION_REPOSITORY, // optional, if other modules need it
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
export class OrganizationsModule {}
|
export class OrganizationsModule {}
|
||||||
|
|||||||
@ -989,7 +989,9 @@ export class CsvBookingService {
|
|||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const documentId = uuidv4();
|
const documentId = uuidv4();
|
||||||
const fileKey = `csv-bookings/${bookingId}/${documentId}-${file.originalname}`;
|
// Multer decodes originalname as latin1; re-encode to utf8 to preserve accented characters
|
||||||
|
const fileName = Buffer.from(file.originalname, 'latin1').toString('utf8');
|
||||||
|
const fileKey = `csv-bookings/${bookingId}/${documentId}-${fileName}`;
|
||||||
|
|
||||||
// Upload to S3
|
// Upload to S3
|
||||||
const uploadResult = await this.storageAdapter.upload({
|
const uploadResult = await this.storageAdapter.upload({
|
||||||
@ -1000,12 +1002,12 @@ export class CsvBookingService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Determine document type from filename or default to OTHER
|
// Determine document type from filename or default to OTHER
|
||||||
const documentType = this.inferDocumentType(file.originalname);
|
const documentType = this.inferDocumentType(fileName);
|
||||||
|
|
||||||
const document = new CsvBookingDocumentImpl(
|
const document = new CsvBookingDocumentImpl(
|
||||||
documentId,
|
documentId,
|
||||||
documentType,
|
documentType,
|
||||||
file.originalname,
|
fileName,
|
||||||
uploadResult.url,
|
uploadResult.url,
|
||||||
file.mimetype,
|
file.mimetype,
|
||||||
file.size,
|
file.size,
|
||||||
@ -1064,9 +1066,10 @@ export class CsvBookingService {
|
|||||||
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
|
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow adding documents to PENDING_PAYMENT, PENDING, or ACCEPTED bookings
|
// Allow adding documents to PENDING_PAYMENT, PENDING_BANK_TRANSFER, PENDING, or ACCEPTED bookings
|
||||||
if (
|
if (
|
||||||
booking.status !== CsvBookingStatus.PENDING_PAYMENT &&
|
booking.status !== CsvBookingStatus.PENDING_PAYMENT &&
|
||||||
|
booking.status !== CsvBookingStatus.PENDING_BANK_TRANSFER &&
|
||||||
booking.status !== CsvBookingStatus.PENDING &&
|
booking.status !== CsvBookingStatus.PENDING &&
|
||||||
booking.status !== CsvBookingStatus.ACCEPTED
|
booking.status !== CsvBookingStatus.ACCEPTED
|
||||||
) {
|
) {
|
||||||
|
|||||||
@ -30,6 +30,49 @@ interface Organization {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface FormData {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
scac: string;
|
||||||
|
siren: string;
|
||||||
|
siret: string;
|
||||||
|
eori: string;
|
||||||
|
contact_phone: string;
|
||||||
|
contact_email: string;
|
||||||
|
address: {
|
||||||
|
street: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
postalCode: string;
|
||||||
|
country: string;
|
||||||
|
};
|
||||||
|
logoUrl: string;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_FORM: FormData = {
|
||||||
|
name: '',
|
||||||
|
type: 'FREIGHT_FORWARDER',
|
||||||
|
scac: '',
|
||||||
|
siren: '',
|
||||||
|
siret: '',
|
||||||
|
eori: '',
|
||||||
|
contact_phone: '',
|
||||||
|
contact_email: '',
|
||||||
|
address: { street: '', city: '', state: '', postalCode: '', country: 'FR' },
|
||||||
|
logoUrl: '',
|
||||||
|
isActive: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
function FieldHint({ children }: { children: React.ReactNode }) {
|
||||||
|
return <p className="mt-1 text-xs text-gray-400">{children}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldError({ message }: { message?: string }) {
|
||||||
|
if (!message) return null;
|
||||||
|
return <p className="mt-1 text-xs text-red-600">{message}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
export default function AdminOrganizationsPage() {
|
export default function AdminOrganizationsPage() {
|
||||||
const t = useTranslations('dashboard.admin.organizations');
|
const t = useTranslations('dashboard.admin.organizations');
|
||||||
|
|
||||||
@ -40,43 +83,10 @@ export default function AdminOrganizationsPage() {
|
|||||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
const [showEditModal, setShowEditModal] = useState(false);
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
const [verifyingId, setVerifyingId] = useState<string | null>(null);
|
const [verifyingId, setVerifyingId] = useState<string | null>(null);
|
||||||
|
const [formData, setFormData] = useState<FormData>(EMPTY_FORM);
|
||||||
// Form state
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
const [formData, setFormData] = useState<{
|
const [fieldErrors, setFieldErrors] = useState<Partial<Record<string, string>>>({});
|
||||||
name: string;
|
const [submitting, setSubmitting] = useState(false);
|
||||||
type: string;
|
|
||||||
scac: string;
|
|
||||||
siren: string;
|
|
||||||
siret: string;
|
|
||||||
eori: string;
|
|
||||||
contact_phone: string;
|
|
||||||
contact_email: string;
|
|
||||||
address: {
|
|
||||||
street: string;
|
|
||||||
city: string;
|
|
||||||
state?: string;
|
|
||||||
postalCode: string;
|
|
||||||
country: string;
|
|
||||||
};
|
|
||||||
logoUrl: string;
|
|
||||||
}>({
|
|
||||||
name: '',
|
|
||||||
type: 'FREIGHT_FORWARDER',
|
|
||||||
scac: '',
|
|
||||||
siren: '',
|
|
||||||
siret: '',
|
|
||||||
eori: '',
|
|
||||||
contact_phone: '',
|
|
||||||
contact_email: '',
|
|
||||||
address: {
|
|
||||||
street: '',
|
|
||||||
city: '',
|
|
||||||
state: '',
|
|
||||||
postalCode: '',
|
|
||||||
country: 'FR',
|
|
||||||
},
|
|
||||||
logoUrl: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchOrganizations();
|
fetchOrganizations();
|
||||||
@ -95,63 +105,120 @@ export default function AdminOrganizationsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const validateForm = (isCreate: boolean): boolean => {
|
||||||
|
const errors: Partial<Record<string, string>> = {};
|
||||||
|
|
||||||
|
if (!formData.name.trim() || formData.name.trim().length < 2) {
|
||||||
|
errors.name = t('validation.nameTooShort');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCreate && formData.type === 'CARRIER') {
|
||||||
|
if (!formData.scac) {
|
||||||
|
errors.scac = t('validation.scacRequired');
|
||||||
|
} else if (!/^[A-Z]{4}$/.test(formData.scac)) {
|
||||||
|
errors.scac = t('validation.scacFormat');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.siren && !/^[0-9]{9}$/.test(formData.siren)) {
|
||||||
|
errors.siren = t('validation.sirenFormat');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.siret && !/^[0-9]{14}$/.test(formData.siret)) {
|
||||||
|
errors.siret = t('validation.siretFormat');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.address.street.trim()) errors.street = t('validation.required');
|
||||||
|
if (!formData.address.city.trim()) errors.city = t('validation.required');
|
||||||
|
if (!formData.address.postalCode.trim()) errors.postalCode = t('validation.required');
|
||||||
|
if (!/^[A-Z]{2}$/.test(formData.address.country)) {
|
||||||
|
errors.country = t('validation.countryFormat');
|
||||||
|
}
|
||||||
|
|
||||||
|
setFieldErrors(errors);
|
||||||
|
return Object.keys(errors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildApiAddress = () => ({
|
||||||
|
street: formData.address.street,
|
||||||
|
city: formData.address.city,
|
||||||
|
postalCode: formData.address.postalCode,
|
||||||
|
country: formData.address.country,
|
||||||
|
...(formData.address.state ? { state: formData.address.state } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
const handleCreate = async (e: React.FormEvent) => {
|
const handleCreate = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
setFormError(null);
|
||||||
|
if (!validateForm(true)) return;
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const apiData = {
|
await createOrganization({
|
||||||
name: formData.name,
|
name: formData.name.trim(),
|
||||||
type: formData.type as any,
|
type: formData.type as any,
|
||||||
address_street: formData.address.street,
|
address: buildApiAddress(),
|
||||||
address_city: formData.address.city,
|
|
||||||
address_postal_code: formData.address.postalCode,
|
|
||||||
address_country: formData.address.country,
|
|
||||||
contact_email: formData.contact_email || undefined,
|
contact_email: formData.contact_email || undefined,
|
||||||
contact_phone: formData.contact_phone || undefined,
|
contact_phone: formData.contact_phone || undefined,
|
||||||
logo_url: formData.logoUrl || undefined,
|
logoUrl: formData.logoUrl || undefined,
|
||||||
};
|
...(formData.siren ? { siren: formData.siren } : {}),
|
||||||
await createOrganization(apiData);
|
...(formData.siret ? { siret: formData.siret } : {}),
|
||||||
|
...(formData.eori ? { eori: formData.eori } : {}),
|
||||||
|
...(formData.scac ? { scac: formData.scac } : {}),
|
||||||
|
});
|
||||||
await fetchOrganizations();
|
await fetchOrganizations();
|
||||||
setShowCreateModal(false);
|
setShowCreateModal(false);
|
||||||
resetForm();
|
resetForm();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
alert(err.message || t('createError'));
|
setFormError(err.message || t('createError'));
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdate = async (e: React.FormEvent) => {
|
const handleUpdate = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!selectedOrg) return;
|
if (!selectedOrg) return;
|
||||||
|
setFormError(null);
|
||||||
|
if (!validateForm(false)) return;
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await updateOrganization(selectedOrg.id, formData);
|
await updateOrganization(selectedOrg.id, {
|
||||||
|
name: formData.name.trim(),
|
||||||
|
address: buildApiAddress(),
|
||||||
|
contact_email: formData.contact_email || undefined,
|
||||||
|
contact_phone: formData.contact_phone || undefined,
|
||||||
|
logoUrl: formData.logoUrl || undefined,
|
||||||
|
isActive: formData.isActive,
|
||||||
|
...(formData.siren ? { siren: formData.siren } : {}),
|
||||||
|
...(formData.siret ? { siret: formData.siret } : {}),
|
||||||
|
...(formData.eori ? { eori: formData.eori } : {}),
|
||||||
|
});
|
||||||
await fetchOrganizations();
|
await fetchOrganizations();
|
||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
setSelectedOrg(null);
|
setSelectedOrg(null);
|
||||||
resetForm();
|
resetForm();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
alert(err.message || t('updateError'));
|
setFormError(err.message || t('updateError'));
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setFormData({
|
setFormData(EMPTY_FORM);
|
||||||
name: '',
|
setFormError(null);
|
||||||
type: 'FREIGHT_FORWARDER',
|
setFieldErrors({});
|
||||||
scac: '',
|
};
|
||||||
siren: '',
|
|
||||||
siret: '',
|
const handleTypeChange = (newType: string) => {
|
||||||
eori: '',
|
setFormData(prev => ({
|
||||||
contact_phone: '',
|
...prev,
|
||||||
contact_email: '',
|
type: newType,
|
||||||
address: {
|
scac: newType !== 'CARRIER' ? '' : prev.scac,
|
||||||
street: '',
|
}));
|
||||||
city: '',
|
setFieldErrors(prev => ({ ...prev, scac: undefined }));
|
||||||
state: '',
|
|
||||||
postalCode: '',
|
|
||||||
country: 'FR',
|
|
||||||
},
|
|
||||||
logoUrl: '',
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleVerifySiret = async (orgId: string) => {
|
const handleVerifySiret = async (orgId: string) => {
|
||||||
@ -215,20 +282,41 @@ export default function AdminOrganizationsPage() {
|
|||||||
eori: org.eori || '',
|
eori: org.eori || '',
|
||||||
contact_phone: org.contact_phone || '',
|
contact_phone: org.contact_phone || '',
|
||||||
contact_email: org.contact_email || '',
|
contact_email: org.contact_email || '',
|
||||||
address: org.address,
|
address: {
|
||||||
|
street: org.address.street,
|
||||||
|
city: org.address.city,
|
||||||
|
state: org.address.state || '',
|
||||||
|
postalCode: org.address.postalCode,
|
||||||
|
country: org.address.country,
|
||||||
|
},
|
||||||
logoUrl: org.logoUrl || '',
|
logoUrl: org.logoUrl || '',
|
||||||
|
isActive: org.isActive,
|
||||||
});
|
});
|
||||||
|
setFormError(null);
|
||||||
|
setFieldErrors({});
|
||||||
setShowEditModal(true);
|
setShowEditModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
setShowCreateModal(false);
|
||||||
|
setShowEditModal(false);
|
||||||
|
setSelectedOrg(null);
|
||||||
|
resetForm();
|
||||||
|
};
|
||||||
|
|
||||||
const getTypeLabel = (type: string) => {
|
const getTypeLabel = (type: string) => {
|
||||||
const allowed = ['FREIGHT_FORWARDER', 'CARRIER', 'SHIPPER'];
|
const allowed = ['FREIGHT_FORWARDER', 'CARRIER', 'SHIPPER'];
|
||||||
if (allowed.includes(type)) {
|
if (allowed.includes(type)) return t(`types.${type}` as any);
|
||||||
return t(`types.${type}` as any);
|
|
||||||
}
|
|
||||||
return type.replace('_', ' ');
|
return type.replace('_', ' ');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const inputClass = (hasError?: string) =>
|
||||||
|
`mt-1 block w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none text-sm ${
|
||||||
|
hasError
|
||||||
|
? 'border-red-400 focus:border-red-500 focus:ring-red-500'
|
||||||
|
: 'border-gray-300 focus:border-blue-500 focus:ring-blue-500'
|
||||||
|
}`;
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-96">
|
<div className="flex items-center justify-center h-96">
|
||||||
@ -255,7 +343,6 @@ export default function AdminOrganizationsPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Error Message */}
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
|
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
|
||||||
{error}
|
{error}
|
||||||
@ -375,247 +462,365 @@ export default function AdminOrganizationsPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Create/Edit Modal */}
|
{/* Create / Edit Modal */}
|
||||||
{(showCreateModal || showEditModal) && (
|
{(showCreateModal || showEditModal) && (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 overflow-y-auto">
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 overflow-y-auto py-4">
|
||||||
<div className="bg-white rounded-lg p-6 max-w-2xl w-full m-4 max-h-[90vh] overflow-y-auto">
|
<div className="bg-white rounded-lg p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||||
<h2 className="text-xl font-bold mb-4">
|
<h2 className="text-xl font-bold mb-6">
|
||||||
{showCreateModal ? t('modal.createTitle') : t('modal.editTitle')}
|
{showCreateModal ? t('modal.createTitle') : t('modal.editTitle')}
|
||||||
</h2>
|
</h2>
|
||||||
<form onSubmit={showCreateModal ? handleCreate : handleUpdate} className="space-y-4">
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="col-span-2">
|
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
|
||||||
{t('modal.name')}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
value={formData.name}
|
|
||||||
onChange={e => setFormData({ ...formData, name: e.target.value })}
|
|
||||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<form
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
onSubmit={showCreateModal ? handleCreate : handleUpdate}
|
||||||
{t('modal.type')}
|
noValidate
|
||||||
</label>
|
className="space-y-5"
|
||||||
<select
|
>
|
||||||
value={formData.type}
|
{/* ── Informations générales ── */}
|
||||||
onChange={e => setFormData({ ...formData, type: e.target.value })}
|
<section>
|
||||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
<h3 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||||
>
|
{t('modal.sectionGeneral')}
|
||||||
<option value="FREIGHT_FORWARDER">{t('types.FREIGHT_FORWARDER')}</option>
|
</h3>
|
||||||
<option value="CARRIER">{t('types.CARRIER')}</option>
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<option value="SHIPPER">{t('types.SHIPPER')}</option>
|
<div className="col-span-2">
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{formData.type === 'CARRIER' && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
{t('modal.scacLabel')}
|
{t('modal.name')} <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required={formData.type === 'CARRIER'}
|
value={formData.name}
|
||||||
maxLength={4}
|
onChange={e => {
|
||||||
value={formData.scac}
|
setFormData({ ...formData, name: e.target.value });
|
||||||
onChange={e =>
|
setFieldErrors(prev => ({ ...prev, name: undefined }));
|
||||||
setFormData({ ...formData, scac: e.target.value.toUpperCase() })
|
}}
|
||||||
}
|
minLength={2}
|
||||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
maxLength={200}
|
||||||
|
className={inputClass(fieldErrors.name)}
|
||||||
|
placeholder="Acme Freight Forwarding"
|
||||||
|
/>
|
||||||
|
<FieldError message={fieldErrors.name} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
{t('modal.type')} <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
{showCreateModal ? (
|
||||||
|
<select
|
||||||
|
value={formData.type}
|
||||||
|
onChange={e => handleTypeChange(e.target.value)}
|
||||||
|
className={inputClass()}
|
||||||
|
>
|
||||||
|
<option value="FREIGHT_FORWARDER">{t('types.FREIGHT_FORWARDER')}</option>
|
||||||
|
<option value="CARRIER">{t('types.CARRIER')}</option>
|
||||||
|
<option value="SHIPPER">{t('types.SHIPPER')}</option>
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<div className="mt-1 flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 text-xs font-semibold rounded-full ${
|
||||||
|
formData.type === 'FREIGHT_FORWARDER'
|
||||||
|
? 'bg-blue-100 text-blue-800'
|
||||||
|
: formData.type === 'CARRIER'
|
||||||
|
? 'bg-green-100 text-green-800'
|
||||||
|
: 'bg-purple-100 text-purple-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{getTypeLabel(formData.type)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-400">{t('modal.typeReadOnly')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SCAC — création uniquement, visible en lecture seule en édition si CARRIER */}
|
||||||
|
{showCreateModal && formData.type === 'CARRIER' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
{t('modal.scacLabel')} <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.scac}
|
||||||
|
onChange={e => {
|
||||||
|
const v = e.target.value.toUpperCase().replace(/[^A-Z]/g, '');
|
||||||
|
setFormData({ ...formData, scac: v });
|
||||||
|
setFieldErrors(prev => ({ ...prev, scac: undefined }));
|
||||||
|
}}
|
||||||
|
maxLength={4}
|
||||||
|
minLength={4}
|
||||||
|
className={inputClass(fieldErrors.scac)}
|
||||||
|
placeholder="MAEU"
|
||||||
|
/>
|
||||||
|
<FieldHint>{t('modal.scacHint')}</FieldHint>
|
||||||
|
<FieldError message={fieldErrors.scac} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{showEditModal && formData.type === 'CARRIER' && formData.scac && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
{t('modal.scacLabel')}
|
||||||
|
</label>
|
||||||
|
<div className="mt-1 px-3 py-2 bg-gray-50 border border-gray-200 rounded-md text-sm text-gray-700 font-mono">
|
||||||
|
{formData.scac}
|
||||||
|
</div>
|
||||||
|
<FieldHint>{t('modal.typeReadOnly')}</FieldHint>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Identifiants légaux ── */}
|
||||||
|
<section>
|
||||||
|
<h3 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||||
|
{t('modal.sectionLegal')}
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
{t('modal.sirenLabel')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.siren}
|
||||||
|
onChange={e => {
|
||||||
|
const v = e.target.value.replace(/\D/g, '').slice(0, 9);
|
||||||
|
setFormData({ ...formData, siren: v });
|
||||||
|
setFieldErrors(prev => ({ ...prev, siren: undefined }));
|
||||||
|
}}
|
||||||
|
maxLength={9}
|
||||||
|
className={inputClass(fieldErrors.siren)}
|
||||||
|
placeholder="123456789"
|
||||||
|
/>
|
||||||
|
<FieldHint>{t('modal.sirenHint')}</FieldHint>
|
||||||
|
<FieldError message={fieldErrors.siren} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
{t('modal.siretLabel')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.siret}
|
||||||
|
onChange={e => {
|
||||||
|
const v = e.target.value.replace(/\D/g, '').slice(0, 14);
|
||||||
|
setFormData({ ...formData, siret: v });
|
||||||
|
setFieldErrors(prev => ({ ...prev, siret: undefined }));
|
||||||
|
}}
|
||||||
|
maxLength={14}
|
||||||
|
className={inputClass(fieldErrors.siret)}
|
||||||
|
placeholder="12345678901234"
|
||||||
|
/>
|
||||||
|
<FieldHint>{t('modal.siretHint')}</FieldHint>
|
||||||
|
<FieldError message={fieldErrors.siret} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
{t('modal.eoriLabel')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.eori}
|
||||||
|
onChange={e => setFormData({ ...formData, eori: e.target.value })}
|
||||||
|
className={inputClass()}
|
||||||
|
placeholder="FR123456789"
|
||||||
|
/>
|
||||||
|
<FieldHint>{t('modal.eoriHint')}</FieldHint>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Contact ── */}
|
||||||
|
<section>
|
||||||
|
<h3 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||||
|
{t('modal.sectionContact')}
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
{t('modal.contactEmail')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={formData.contact_email}
|
||||||
|
onChange={e => setFormData({ ...formData, contact_email: e.target.value })}
|
||||||
|
className={inputClass()}
|
||||||
|
placeholder="contact@exemple.com"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
{t('modal.sirenLabel')}
|
{t('modal.contactPhone')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="tel"
|
||||||
maxLength={9}
|
value={formData.contact_phone}
|
||||||
value={formData.siren}
|
onChange={e => setFormData({ ...formData, contact_phone: e.target.value })}
|
||||||
onChange={e => setFormData({ ...formData, siren: e.target.value })}
|
className={inputClass()}
|
||||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
placeholder="+33 6 00 00 00 00"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div>
|
{/* ── Adresse ── */}
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
<section>
|
||||||
{t('modal.siretLabel')}
|
<h3 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||||
</label>
|
{t('modal.sectionAddress')}
|
||||||
<input
|
</h3>
|
||||||
type="text"
|
<div className="grid grid-cols-2 gap-4">
|
||||||
maxLength={14}
|
<div className="col-span-2">
|
||||||
value={formData.siret}
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
onChange={e =>
|
{t('modal.street')} <span className="text-red-500">*</span>
|
||||||
setFormData({ ...formData, siret: e.target.value.replace(/\D/g, '') })
|
</label>
|
||||||
}
|
<input
|
||||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
type="text"
|
||||||
placeholder={t('modal.siretPlaceholder')}
|
value={formData.address.street}
|
||||||
/>
|
onChange={e => {
|
||||||
|
setFormData({ ...formData, address: { ...formData.address, street: e.target.value } });
|
||||||
|
setFieldErrors(prev => ({ ...prev, street: undefined }));
|
||||||
|
}}
|
||||||
|
className={inputClass(fieldErrors.street)}
|
||||||
|
placeholder="123 Rue de la République"
|
||||||
|
/>
|
||||||
|
<FieldError message={fieldErrors.street} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
{t('modal.city')} <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.address.city}
|
||||||
|
onChange={e => {
|
||||||
|
setFormData({ ...formData, address: { ...formData.address, city: e.target.value } });
|
||||||
|
setFieldErrors(prev => ({ ...prev, city: undefined }));
|
||||||
|
}}
|
||||||
|
className={inputClass(fieldErrors.city)}
|
||||||
|
placeholder="Paris"
|
||||||
|
/>
|
||||||
|
<FieldError message={fieldErrors.city} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
{t('modal.postalCode')} <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.address.postalCode}
|
||||||
|
onChange={e => {
|
||||||
|
setFormData({ ...formData, address: { ...formData.address, postalCode: e.target.value } });
|
||||||
|
setFieldErrors(prev => ({ ...prev, postalCode: undefined }));
|
||||||
|
}}
|
||||||
|
className={inputClass(fieldErrors.postalCode)}
|
||||||
|
placeholder="75001"
|
||||||
|
/>
|
||||||
|
<FieldError message={fieldErrors.postalCode} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
{t('modal.state')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.address.state}
|
||||||
|
onChange={e =>
|
||||||
|
setFormData({ ...formData, address: { ...formData.address, state: e.target.value } })
|
||||||
|
}
|
||||||
|
className={inputClass()}
|
||||||
|
placeholder="Île-de-France"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
{t('modal.country')} <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.address.country}
|
||||||
|
onChange={e => {
|
||||||
|
const v = e.target.value.toUpperCase().replace(/[^A-Z]/g, '').slice(0, 2);
|
||||||
|
setFormData({ ...formData, address: { ...formData.address, country: v } });
|
||||||
|
setFieldErrors(prev => ({ ...prev, country: undefined }));
|
||||||
|
}}
|
||||||
|
maxLength={2}
|
||||||
|
minLength={2}
|
||||||
|
className={inputClass(fieldErrors.country)}
|
||||||
|
placeholder="FR"
|
||||||
|
/>
|
||||||
|
<FieldHint>{t('modal.countryHint')}</FieldHint>
|
||||||
|
<FieldError message={fieldErrors.country} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div>
|
{/* ── Autres ── */}
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
<section>
|
||||||
{t('modal.eoriLabel')}
|
<h3 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||||
</label>
|
{t('modal.sectionOther')}
|
||||||
<input
|
</h3>
|
||||||
type="text"
|
<div className="space-y-4">
|
||||||
value={formData.eori}
|
<div>
|
||||||
onChange={e => setFormData({ ...formData, eori: e.target.value })}
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
{t('modal.logoUrl')}
|
||||||
/>
|
</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={formData.logoUrl}
|
||||||
|
onChange={e => setFormData({ ...formData, logoUrl: e.target.value })}
|
||||||
|
className={inputClass()}
|
||||||
|
placeholder="https://exemple.com/logo.png"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showEditModal && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
id="isActive"
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.isActive}
|
||||||
|
onChange={e => setFormData({ ...formData, isActive: e.target.checked })}
|
||||||
|
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<label htmlFor="isActive" className="text-sm font-medium text-gray-700">
|
||||||
|
{t('modal.isActive')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div>
|
{/* Erreur globale */}
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
{formError && (
|
||||||
{t('modal.contactPhone')}
|
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md text-sm">
|
||||||
</label>
|
{formError}
|
||||||
<input
|
|
||||||
type="tel"
|
|
||||||
value={formData.contact_phone}
|
|
||||||
onChange={e => setFormData({ ...formData, contact_phone: e.target.value })}
|
|
||||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
<div className="flex justify-end space-x-3 pt-2 border-t border-gray-100">
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
|
||||||
{t('modal.contactEmail')}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={formData.contact_email}
|
|
||||||
onChange={e => setFormData({ ...formData, contact_email: e.target.value })}
|
|
||||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="col-span-2">
|
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
|
||||||
{t('modal.street')}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
value={formData.address.street}
|
|
||||||
onChange={e =>
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
address: { ...formData.address, street: e.target.value },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
|
||||||
{t('modal.city')}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
value={formData.address.city}
|
|
||||||
onChange={e =>
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
address: { ...formData.address, city: e.target.value },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
|
||||||
{t('modal.postalCode')}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
value={formData.address.postalCode}
|
|
||||||
onChange={e =>
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
address: { ...formData.address, postalCode: e.target.value },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
|
||||||
{t('modal.state')}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.address.state}
|
|
||||||
onChange={e =>
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
address: { ...formData.address, state: e.target.value },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
|
||||||
{t('modal.country')}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
maxLength={2}
|
|
||||||
value={formData.address.country}
|
|
||||||
onChange={e =>
|
|
||||||
setFormData({
|
|
||||||
...formData,
|
|
||||||
address: { ...formData.address, country: e.target.value.toUpperCase() },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="col-span-2">
|
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
|
||||||
{t('modal.logoUrl')}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
value={formData.logoUrl}
|
|
||||||
onChange={e => setFormData({ ...formData, logoUrl: e.target.value })}
|
|
||||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end space-x-2 pt-4">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={closeModal}
|
||||||
setShowCreateModal(false);
|
className="px-4 py-2 border border-gray-300 rounded-md text-sm text-gray-700 hover:bg-gray-50 transition-colors"
|
||||||
setShowEditModal(false);
|
|
||||||
setSelectedOrg(null);
|
|
||||||
resetForm();
|
|
||||||
}}
|
|
||||||
className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
|
|
||||||
>
|
>
|
||||||
{t('modal.cancel')}
|
{t('modal.cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
disabled={submitting}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
{showCreateModal ? t('modal.create') : t('modal.update')}
|
{submitting
|
||||||
|
? t('modal.saving')
|
||||||
|
: showCreateModal
|
||||||
|
? t('modal.create')
|
||||||
|
: t('modal.update')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -19,8 +19,12 @@ import {
|
|||||||
CheckCircle,
|
CheckCircle,
|
||||||
Copy,
|
Copy,
|
||||||
Clock,
|
Clock,
|
||||||
|
ShieldCheck,
|
||||||
|
Send,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { getCsvBooking, payBookingCommission, declareBankTransfer } from '@/lib/api/bookings';
|
import { getCsvBooking, payBookingCommission, declareBankTransfer } from '@/lib/api/bookings';
|
||||||
|
import { getOrganization, requestSiretApproval } from '@/lib/api/organizations';
|
||||||
|
import { useAuth } from '@/lib/context/auth-context';
|
||||||
|
|
||||||
interface BookingData {
|
interface BookingData {
|
||||||
id: string;
|
id: string;
|
||||||
@ -42,6 +46,13 @@ interface BookingData {
|
|||||||
commissionAmountEur?: number;
|
commissionAmountEur?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface OrgData {
|
||||||
|
siren?: string | null;
|
||||||
|
siret?: string | null;
|
||||||
|
siretVerified?: boolean;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
type PaymentMethod = 'card' | 'transfer' | null;
|
type PaymentMethod = 'card' | 'transfer' | null;
|
||||||
|
|
||||||
const BANK_DETAILS = {
|
const BANK_DETAILS = {
|
||||||
@ -54,21 +65,32 @@ export default function PayCommissionPage() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const bookingId = params.id as string;
|
const bookingId = params.id as string;
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
const [booking, setBooking] = useState<BookingData | null>(null);
|
const [booking, setBooking] = useState<BookingData | null>(null);
|
||||||
|
const [org, setOrg] = useState<OrgData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [paying, setPaying] = useState(false);
|
const [paying, setPaying] = useState(false);
|
||||||
const [declaring, setDeclaring] = useState(false);
|
const [declaring, setDeclaring] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [selectedMethod, setSelectedMethod] = useState<PaymentMethod>(null);
|
const [selectedMethod, setSelectedMethod] = useState<PaymentMethod>(null);
|
||||||
const [copied, setCopied] = useState<string | null>(null);
|
const [copied, setCopied] = useState<string | null>(null);
|
||||||
|
const [requestingApproval, setRequestingApproval] = useState(false);
|
||||||
|
const [approvalSent, setApprovalSent] = useState(false);
|
||||||
|
const [approvalError, setApprovalError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchBooking() {
|
async function fetchData() {
|
||||||
try {
|
try {
|
||||||
const data = await getCsvBooking(bookingId);
|
const [bookingData, orgData] = await Promise.all([
|
||||||
setBooking(data as any);
|
getCsvBooking(bookingId),
|
||||||
if (data.status !== 'PENDING_PAYMENT') {
|
user?.organizationId ? getOrganization(user.organizationId) : Promise.resolve(null),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setBooking(bookingData as any);
|
||||||
|
if (orgData) setOrg(orgData);
|
||||||
|
|
||||||
|
if ((bookingData as any).status !== 'PENDING_PAYMENT') {
|
||||||
router.replace('/dashboard/bookings');
|
router.replace('/dashboard/bookings');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -77,8 +99,8 @@ export default function PayCommissionPage() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (bookingId) fetchBooking();
|
if (bookingId) fetchData();
|
||||||
}, [bookingId, router]);
|
}, [bookingId, router, user?.organizationId]);
|
||||||
|
|
||||||
const handlePayByCard = async () => {
|
const handlePayByCard = async () => {
|
||||||
setPaying(true);
|
setPaying(true);
|
||||||
@ -104,6 +126,21 @@ export default function PayCommissionPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRequestApproval = async () => {
|
||||||
|
setRequestingApproval(true);
|
||||||
|
setApprovalError(null);
|
||||||
|
try {
|
||||||
|
await requestSiretApproval();
|
||||||
|
setApprovalSent(true);
|
||||||
|
} catch (err) {
|
||||||
|
setApprovalError(
|
||||||
|
err instanceof Error ? err.message : 'Erreur lors de l\'envoi de la demande'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setRequestingApproval(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const copyToClipboard = (value: string, key: string) => {
|
const copyToClipboard = (value: string, key: string) => {
|
||||||
navigator.clipboard.writeText(value);
|
navigator.clipboard.writeText(value);
|
||||||
setCopied(key);
|
setCopied(key);
|
||||||
@ -113,6 +150,14 @@ export default function PayCommissionPage() {
|
|||||||
const formatPrice = (price: number, currency: string) =>
|
const formatPrice = (price: number, currency: string) =>
|
||||||
new Intl.NumberFormat('fr-FR', { style: 'currency', currency }).format(price);
|
new Intl.NumberFormat('fr-FR', { style: 'currency', currency }).format(price);
|
||||||
|
|
||||||
|
const hasSiretOrSiren = org && (org.siret || org.siren);
|
||||||
|
const siretNotVerified = hasSiretOrSiren && !org.siretVerified;
|
||||||
|
const identifier = org?.siret
|
||||||
|
? `SIRET ${org.siret}`
|
||||||
|
: org?.siren
|
||||||
|
? `SIREN ${org.siren}`
|
||||||
|
: null;
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-blue-50">
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-blue-50">
|
||||||
@ -164,6 +209,60 @@ export default function PayCommissionPage() {
|
|||||||
Finalisez votre booking en réglant la commission de service
|
Finalisez votre booking en réglant la commission de service
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* SIRET/SIREN non vérifié — bannière de demande */}
|
||||||
|
{siretNotVerified && (
|
||||||
|
<div className="mb-6 bg-amber-50 border border-amber-200 rounded-xl p-5">
|
||||||
|
<div className="flex items-start space-x-4">
|
||||||
|
<div className="flex-shrink-0 w-10 h-10 bg-amber-100 rounded-full flex items-center justify-center">
|
||||||
|
<ShieldCheck className="h-5 w-5 text-amber-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-semibold text-amber-900 mb-1">
|
||||||
|
Votre {identifier} n'est pas encore validé
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-amber-700 mb-4">
|
||||||
|
Un administrateur doit vérifier votre numéro d'identification avant de
|
||||||
|
finaliser certaines opérations. Vous pouvez lui envoyer une demande de validation
|
||||||
|
directement depuis cette page.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{approvalSent ? (
|
||||||
|
<div className="flex items-center space-x-2 text-green-700 bg-green-50 border border-green-200 rounded-lg px-4 py-3 text-sm">
|
||||||
|
<CheckCircle className="h-4 w-4 flex-shrink-0" />
|
||||||
|
<span>
|
||||||
|
Demande envoyée aux administrateurs. Vous serez notifié dès que votre{' '}
|
||||||
|
{identifier} sera validé.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{approvalError && (
|
||||||
|
<p className="text-sm text-red-600">{approvalError}</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleRequestApproval}
|
||||||
|
disabled={requestingApproval}
|
||||||
|
className="inline-flex items-center space-x-2 px-4 py-2.5 bg-amber-600 hover:bg-amber-700 disabled:bg-amber-400 text-white text-sm font-medium rounded-lg transition-colors disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{requestingApproval ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
<span>Envoi en cours...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
<span>Demander la validation à un administrateur</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4 flex items-start space-x-3">
|
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4 flex items-start space-x-3">
|
||||||
<AlertTriangle className="h-5 w-5 text-red-500 flex-shrink-0 mt-0.5" />
|
<AlertTriangle className="h-5 w-5 text-red-500 flex-shrink-0 mt-0.5" />
|
||||||
@ -420,6 +519,29 @@ export default function PayCommissionPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Organisation info */}
|
||||||
|
{org && (
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-4 space-y-2 text-sm">
|
||||||
|
<p className="font-medium text-gray-700">{org.name}</p>
|
||||||
|
{identifier && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-gray-500">{identifier}</span>
|
||||||
|
{org.siretVerified ? (
|
||||||
|
<span className="flex items-center space-x-1 text-green-600 text-xs font-medium">
|
||||||
|
<CheckCircle className="h-3.5 w-3.5" />
|
||||||
|
<span>Vérifié</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center space-x-1 text-amber-600 text-xs font-medium">
|
||||||
|
<AlertTriangle className="h-3.5 w-3.5" />
|
||||||
|
<span>Non vérifié</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="bg-white rounded-xl border border-gray-200 p-4 flex items-start space-x-3">
|
<div className="bg-white rounded-xl border border-gray-200 p-4 flex items-start space-x-3">
|
||||||
<CheckCircle className="h-4 w-4 text-green-500 mt-0.5 flex-shrink-0" />
|
<CheckCircle className="h-4 w-4 text-green-500 mt-0.5 flex-shrink-0" />
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, Suspense } from 'react';
|
import { useState, useEffect, useRef, Suspense } from 'react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { ClipboardList, Mail, AlertTriangle } from 'lucide-react';
|
import { ClipboardList, Mail, AlertTriangle } from 'lucide-react';
|
||||||
import type { CsvRateSearchResult } from '@/types/rates';
|
import type { CsvRateSearchResult } from '@/types/rates';
|
||||||
@ -55,6 +55,8 @@ function NewBookingPageContent() {
|
|||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [termsAccepted, setTermsAccepted] = useState(false);
|
const [termsAccepted, setTermsAccepted] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const [formData, setFormData] = useState<BookingForm>({
|
const [formData, setFormData] = useState<BookingForm>({
|
||||||
carrierName: '',
|
carrierName: '',
|
||||||
@ -419,8 +421,30 @@ function NewBookingPageContent() {
|
|||||||
|
|
||||||
{/* File Upload */}
|
{/* File Upload */}
|
||||||
<div className="space-y-4 mb-8">
|
<div className="space-y-4 mb-8">
|
||||||
<div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-blue-400 transition-colors">
|
<div
|
||||||
|
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer ${
|
||||||
|
isDragging
|
||||||
|
? 'border-blue-500 bg-blue-50'
|
||||||
|
: 'border-gray-300 hover:border-blue-400 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
onDragOver={e => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(true);
|
||||||
|
}}
|
||||||
|
onDragLeave={e => {
|
||||||
|
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||||
|
setIsDragging(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDrop={e => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(false);
|
||||||
|
handleFileChange(e.dataTransfer.files);
|
||||||
|
}}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
id="file-upload"
|
id="file-upload"
|
||||||
multiple
|
multiple
|
||||||
@ -428,12 +452,9 @@ function NewBookingPageContent() {
|
|||||||
onChange={e => handleFileChange(e.target.files)}
|
onChange={e => handleFileChange(e.target.files)}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
/>
|
/>
|
||||||
<label
|
<div className="flex flex-col items-center">
|
||||||
htmlFor="file-upload"
|
|
||||||
className="cursor-pointer flex flex-col items-center"
|
|
||||||
>
|
|
||||||
<svg
|
<svg
|
||||||
className="w-16 h-16 text-gray-400 mb-4"
|
className={`w-16 h-16 mb-4 transition-colors ${isDragging ? 'text-blue-500' : 'text-gray-400'}`}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@ -446,10 +467,10 @@ function NewBookingPageContent() {
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<p className="text-lg font-semibold text-gray-700 mb-2">
|
<p className="text-lg font-semibold text-gray-700 mb-2">
|
||||||
Cliquez pour sélectionner des fichiers
|
{isDragging ? 'Déposez vos fichiers ici' : 'Cliquez pour sélectionner des fichiers'}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-500">ou glissez-déposez vos documents ici</p>
|
<p className="text-sm text-gray-500">ou glissez-déposez vos documents ici</p>
|
||||||
</label>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Document Type Suggestions */}
|
{/* Document Type Suggestions */}
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { listCsvBookings } from '@/lib/api';
|
import { listCsvBookings } from '@/lib/api';
|
||||||
import { Link } from '@/i18n/navigation';
|
import { Link } from '@/i18n/navigation';
|
||||||
import { Plus, Clock } from 'lucide-react';
|
import { Plus, Clock, Eye, X, CreditCard } from 'lucide-react';
|
||||||
import ExportButton from '@/components/ExportButton';
|
import ExportButton from '@/components/ExportButton';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { PageHeader } from '@/components/ui/PageHeader';
|
import { PageHeader } from '@/components/ui/PageHeader';
|
||||||
@ -22,6 +22,7 @@ export default function BookingsListPage() {
|
|||||||
const [statusFilter, setStatusFilter] = useState('');
|
const [statusFilter, setStatusFilter] = useState('');
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [showTransferBanner, setShowTransferBanner] = useState(false);
|
const [showTransferBanner, setShowTransferBanner] = useState(false);
|
||||||
|
const [selectedBooking, setSelectedBooking] = useState<any | null>(null);
|
||||||
const ITEMS_PER_PAGE = 20;
|
const ITEMS_PER_PAGE = 20;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -341,12 +342,32 @@ export default function BookingsListPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-400">
|
<div className="flex items-center justify-between">
|
||||||
{booking.type === 'csv'
|
<div className="text-xs text-gray-400">
|
||||||
? t('mobile.ref', {
|
{booking.type === 'csv'
|
||||||
id: booking.bookingId || booking.id.slice(0, 8).toUpperCase(),
|
? t('mobile.ref', {
|
||||||
})
|
id: booking.bookingId || booking.id.slice(0, 8).toUpperCase(),
|
||||||
: t('mobile.booking', { number: booking.bookingNumber || '-' })}
|
})
|
||||||
|
: t('mobile.booking', { number: booking.bookingNumber || '-' })}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{booking.status === 'PENDING_PAYMENT' && (
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/booking/${booking.id}/pay`}
|
||||||
|
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-white bg-orange-500 hover:bg-orange-600 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<CreditCard className="h-3.5 w-3.5" />
|
||||||
|
{t('actions.pay')}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedBooking(booking)}
|
||||||
|
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-blue-700 bg-blue-50 hover:bg-blue-100 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Eye className="h-3.5 w-3.5" />
|
||||||
|
{t('actions.view')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -377,6 +398,9 @@ export default function BookingsListPage() {
|
|||||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
{t('columns.bookingNumber')}
|
{t('columns.bookingNumber')}
|
||||||
</th>
|
</th>
|
||||||
|
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
{t('columns.actions')}
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
@ -451,6 +475,26 @@ export default function BookingsListPage() {
|
|||||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium text-brand-navy">
|
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium text-brand-navy">
|
||||||
{booking.bookingNumber || '-'}
|
{booking.bookingNumber || '-'}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-center">
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
{booking.status === 'PENDING_PAYMENT' && (
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/booking/${booking.id}/pay`}
|
||||||
|
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-white bg-orange-500 hover:bg-orange-600 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<CreditCard className="h-3.5 w-3.5" />
|
||||||
|
{t('actions.pay')}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedBooking(booking)}
|
||||||
|
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-blue-700 bg-blue-50 hover:bg-blue-100 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Eye className="h-3.5 w-3.5" />
|
||||||
|
{t('actions.view')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@ -601,6 +645,128 @@ export default function BookingsListPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Booking Detail Modal */}
|
||||||
|
{selectedBooking && (
|
||||||
|
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||||
|
<div className="flex items-center justify-center min-h-screen px-4 py-8">
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
|
||||||
|
onClick={() => setSelectedBooking(null)}
|
||||||
|
/>
|
||||||
|
<div className="relative bg-white rounded-xl shadow-xl w-full max-w-2xl">
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">
|
||||||
|
{t('detail.title')}
|
||||||
|
{selectedBooking.bookingId
|
||||||
|
? ` #${selectedBooking.bookingId}`
|
||||||
|
: ` #${selectedBooking.id?.slice(0, 8).toUpperCase()}`}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedBooking(null)}
|
||||||
|
className="text-gray-400 hover:text-gray-600 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="px-6 py-4 space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`px-2.5 py-1 rounded-full text-xs font-semibold ${getStatusColor(selectedBooking.status)}`}>
|
||||||
|
{getStatusLabel(selectedBooking.status)}
|
||||||
|
</span>
|
||||||
|
{selectedBooking.carrierName && (
|
||||||
|
<span className="text-sm text-gray-500">{selectedBooking.carrierName}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-gray-400 uppercase tracking-wide">{t('detail.route')}</p>
|
||||||
|
<p className="mt-1 text-gray-900">
|
||||||
|
{selectedBooking.origin || selectedBooking.originCity || 'N/A'}
|
||||||
|
{' → '}
|
||||||
|
{selectedBooking.destination || selectedBooking.destinationCity || 'N/A'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-gray-400 uppercase tracking-wide">{t('detail.date')}</p>
|
||||||
|
<p className="mt-1 text-gray-900">
|
||||||
|
{selectedBooking.createdAt || selectedBooking.requestedAt
|
||||||
|
? new Date(
|
||||||
|
selectedBooking.createdAt || selectedBooking.requestedAt
|
||||||
|
).toLocaleDateString(dateLocale, {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
})
|
||||||
|
: 'N/A'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-gray-400 uppercase tracking-wide">{t('detail.cargo')}</p>
|
||||||
|
<p className="mt-1 text-gray-900">
|
||||||
|
{selectedBooking.palletCount
|
||||||
|
? t('units.palletsCount', { count: selectedBooking.palletCount })
|
||||||
|
: '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-gray-400 uppercase tracking-wide">{t('detail.weight')}</p>
|
||||||
|
<p className="mt-1 text-gray-900">
|
||||||
|
{selectedBooking.weightKG ? t('units.kg', { value: selectedBooking.weightKG }) : 'N/A'}
|
||||||
|
{selectedBooking.volumeCBM ? ` / ${t('units.cbm', { value: selectedBooking.volumeCBM })}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{selectedBooking.requestedPickupDate && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-gray-400 uppercase tracking-wide">{t('detail.pickupDate')}</p>
|
||||||
|
<p className="mt-1 text-gray-900">
|
||||||
|
{new Date(selectedBooking.requestedPickupDate).toLocaleDateString(dateLocale, {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(selectedBooking.priceEUR || selectedBooking.priceUSD) && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-gray-400 uppercase tracking-wide">{t('detail.price')}</p>
|
||||||
|
<p className="mt-1 font-semibold text-gray-900">
|
||||||
|
{selectedBooking.priceEUR
|
||||||
|
? `${selectedBooking.priceEUR} €`
|
||||||
|
: `${selectedBooking.priceUSD} $`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{selectedBooking.status === 'PENDING_PAYMENT' && (
|
||||||
|
<div className="mt-4 pt-4 border-t border-gray-100">
|
||||||
|
<p className="text-sm text-orange-700 bg-orange-50 rounded-lg px-3 py-2">
|
||||||
|
{t('detail.pendingPaymentNotice')}
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/booking/${selectedBooking.id}/pay`}
|
||||||
|
onClick={() => setSelectedBooking(null)}
|
||||||
|
className="mt-3 inline-flex items-center gap-2 px-4 py-2 bg-orange-500 text-white text-sm font-medium rounded-lg hover:bg-orange-600 transition-colors"
|
||||||
|
>
|
||||||
|
<CreditCard className="h-4 w-4" />
|
||||||
|
{t('detail.payNow')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="px-6 py-4 border-t border-gray-200 flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedBooking(null)}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
{t('detail.close')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { useTranslations, useLocale } from 'next-intl';
|
import { useTranslations, useLocale } from 'next-intl';
|
||||||
import { listCsvBookings, CsvBookingResponse } from '@/lib/api/bookings';
|
import { listCsvBookings, CsvBookingResponse } from '@/lib/api/bookings';
|
||||||
|
import { getAuthToken } from '@/lib/api/client';
|
||||||
import { FileText, Image as ImageIcon, FileEdit, FileSpreadsheet, Paperclip } from 'lucide-react';
|
import { FileText, Image as ImageIcon, FileEdit, FileSpreadsheet, Paperclip } from 'lucide-react';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import ExportButton from '@/components/ExportButton';
|
import ExportButton from '@/components/ExportButton';
|
||||||
@ -45,11 +46,15 @@ export default function UserDocumentsPage() {
|
|||||||
const [showAddModal, setShowAddModal] = useState(false);
|
const [showAddModal, setShowAddModal] = useState(false);
|
||||||
const [selectedBookingId, setSelectedBookingId] = useState<string | null>(null);
|
const [selectedBookingId, setSelectedBookingId] = useState<string | null>(null);
|
||||||
const [uploadingFiles, setUploadingFiles] = useState(false);
|
const [uploadingFiles, setUploadingFiles] = useState(false);
|
||||||
|
const [isDraggingAdd, setIsDraggingAdd] = useState(false);
|
||||||
|
const [addFiles, setAddFiles] = useState<File[]>([]);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const [showReplaceModal, setShowReplaceModal] = useState(false);
|
const [showReplaceModal, setShowReplaceModal] = useState(false);
|
||||||
const [documentToReplace, setDocumentToReplace] = useState<DocumentWithBooking | null>(null);
|
const [documentToReplace, setDocumentToReplace] = useState<DocumentWithBooking | null>(null);
|
||||||
const [replacingFile, setReplacingFile] = useState(false);
|
const [replacingFile, setReplacingFile] = useState(false);
|
||||||
|
const [isDraggingReplace, setIsDraggingReplace] = useState(false);
|
||||||
|
const [replaceFile, setReplaceFile] = useState<File | null>(null);
|
||||||
const replaceFileInputRef = useRef<HTMLInputElement>(null);
|
const replaceFileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const [openDropdownId, setOpenDropdownId] = useState<string | null>(null);
|
const [openDropdownId, setOpenDropdownId] = useState<string | null>(null);
|
||||||
@ -187,6 +192,8 @@ export default function UserDocumentsPage() {
|
|||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
const colors: Record<string, string> = {
|
const colors: Record<string, string> = {
|
||||||
|
PENDING_PAYMENT: 'bg-orange-100 text-orange-800',
|
||||||
|
PENDING_BANK_TRANSFER: 'bg-blue-100 text-blue-800',
|
||||||
PENDING: 'bg-yellow-100 text-yellow-800',
|
PENDING: 'bg-yellow-100 text-yellow-800',
|
||||||
ACCEPTED: 'bg-green-100 text-green-800',
|
ACCEPTED: 'bg-green-100 text-green-800',
|
||||||
REJECTED: 'bg-red-100 text-red-800',
|
REJECTED: 'bg-red-100 text-red-800',
|
||||||
@ -196,8 +203,15 @@ export default function UserDocumentsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getStatusLabel = (status: string) => {
|
const getStatusLabel = (status: string) => {
|
||||||
const key = status as 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED';
|
const labels: Record<string, string> = {
|
||||||
return t(`statuses.${key}` as any) || status;
|
PENDING_PAYMENT: t('statuses.PENDING_PAYMENT' as any),
|
||||||
|
PENDING_BANK_TRANSFER: t('statuses.PENDING_BANK_TRANSFER' as any),
|
||||||
|
PENDING: t('statuses.PENDING' as any),
|
||||||
|
ACCEPTED: t('statuses.ACCEPTED' as any),
|
||||||
|
REJECTED: t('statuses.REJECTED' as any),
|
||||||
|
CANCELLED: t('statuses.CANCELLED' as any),
|
||||||
|
};
|
||||||
|
return labels[status] || status;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDownload = async (url: string, fileName: string) => {
|
const handleDownload = async (url: string, fileName: string) => {
|
||||||
@ -235,7 +249,11 @@ export default function UserDocumentsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const bookingsAvailableForDocuments = bookings.filter(
|
const bookingsAvailableForDocuments = bookings.filter(
|
||||||
b => b.status === 'PENDING' || b.status === 'ACCEPTED'
|
b =>
|
||||||
|
b.status === 'PENDING' ||
|
||||||
|
b.status === 'ACCEPTED' ||
|
||||||
|
b.status === 'PENDING_PAYMENT' ||
|
||||||
|
b.status === 'PENDING_BANK_TRANSFER'
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleAddDocumentClick = () => {
|
const handleAddDocumentClick = () => {
|
||||||
@ -245,11 +263,12 @@ export default function UserDocumentsPage() {
|
|||||||
const handleCloseModal = () => {
|
const handleCloseModal = () => {
|
||||||
setShowAddModal(false);
|
setShowAddModal(false);
|
||||||
setSelectedBookingId(null);
|
setSelectedBookingId(null);
|
||||||
|
setAddFiles([]);
|
||||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFileUpload = async () => {
|
const handleFileUpload = async () => {
|
||||||
if (!selectedBookingId || !fileInputRef.current?.files?.length) {
|
if (!selectedBookingId || addFiles.length === 0) {
|
||||||
alert(t('addDocument.noBookingError'));
|
alert(t('addDocument.noBookingError'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -257,18 +276,18 @@ export default function UserDocumentsPage() {
|
|||||||
setUploadingFiles(true);
|
setUploadingFiles(true);
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
const files = fileInputRef.current.files;
|
addFiles.forEach(file => formData.append('documents', file));
|
||||||
for (let i = 0; i < files.length; i++) {
|
|
||||||
formData.append('documents', files[i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = localStorage.getItem('access_token');
|
const token = getAuthToken();
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/csv-bookings/${selectedBookingId}/documents`,
|
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/csv-bookings/${selectedBookingId}/documents`,
|
||||||
{ method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: formData }
|
{ method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: formData }
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) throw new Error(t('addDocument.errorMessage'));
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.message || t('addDocument.errorMessage'));
|
||||||
|
}
|
||||||
|
|
||||||
alert(t('addDocument.successMessage'));
|
alert(t('addDocument.successMessage'));
|
||||||
handleCloseModal();
|
handleCloseModal();
|
||||||
@ -302,11 +321,12 @@ export default function UserDocumentsPage() {
|
|||||||
const handleCloseReplaceModal = () => {
|
const handleCloseReplaceModal = () => {
|
||||||
setShowReplaceModal(false);
|
setShowReplaceModal(false);
|
||||||
setDocumentToReplace(null);
|
setDocumentToReplace(null);
|
||||||
|
setReplaceFile(null);
|
||||||
if (replaceFileInputRef.current) replaceFileInputRef.current.value = '';
|
if (replaceFileInputRef.current) replaceFileInputRef.current.value = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReplaceDocument = async () => {
|
const handleReplaceDocument = async () => {
|
||||||
if (!documentToReplace || !replaceFileInputRef.current?.files?.length) {
|
if (!documentToReplace || !replaceFile) {
|
||||||
alert(t('replaceDocument.noFileError'));
|
alert(t('replaceDocument.noFileError'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -314,9 +334,9 @@ export default function UserDocumentsPage() {
|
|||||||
setReplacingFile(true);
|
setReplacingFile(true);
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('document', replaceFileInputRef.current.files[0]);
|
formData.append('document', replaceFile);
|
||||||
|
|
||||||
const token = localStorage.getItem('access_token');
|
const token = getAuthToken();
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/csv-bookings/${documentToReplace.bookingId}/documents/${documentToReplace.id}`,
|
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/csv-bookings/${documentToReplace.bookingId}/documents/${documentToReplace.id}`,
|
||||||
{ method: 'PATCH', headers: { Authorization: `Bearer ${token}` }, body: formData }
|
{ method: 'PATCH', headers: { Authorization: `Bearer ${token}` }, body: formData }
|
||||||
@ -762,11 +782,7 @@ export default function UserDocumentsPage() {
|
|||||||
<option value="">{t('addDocument.selectBookingPlaceholder')}</option>
|
<option value="">{t('addDocument.selectBookingPlaceholder')}</option>
|
||||||
{bookingsAvailableForDocuments.map(booking => (
|
{bookingsAvailableForDocuments.map(booking => (
|
||||||
<option key={booking.id} value={booking.id}>
|
<option key={booking.id} value={booking.id}>
|
||||||
{getQuoteNumber(booking)} - {booking.origin} → {booking.destination} (
|
{getQuoteNumber(booking)} - {booking.origin} → {booking.destination} ({getStatusLabel(booking.status)})
|
||||||
{booking.status === 'PENDING'
|
|
||||||
? t('statuses.PENDING')
|
|
||||||
: t('statuses.ACCEPTED')}
|
|
||||||
)
|
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@ -775,16 +791,64 @@ export default function UserDocumentsPage() {
|
|||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
{t('addDocument.filesToAdd')}
|
{t('addDocument.filesToAdd')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<div
|
||||||
ref={fileInputRef}
|
className={`relative border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors ${
|
||||||
type="file"
|
isDraggingAdd
|
||||||
multiple
|
? 'border-blue-500 bg-blue-50'
|
||||||
accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.gif"
|
: 'border-gray-300 hover:border-blue-400 hover:bg-gray-50'
|
||||||
className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100"
|
}`}
|
||||||
/>
|
onDragOver={e => {
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
e.preventDefault();
|
||||||
{t('addDocument.acceptedFormats')}
|
setIsDraggingAdd(true);
|
||||||
</p>
|
}}
|
||||||
|
onDragLeave={() => setIsDraggingAdd(false)}
|
||||||
|
onDrop={e => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDraggingAdd(false);
|
||||||
|
const dropped = Array.from(e.dataTransfer.files);
|
||||||
|
if (dropped.length > 0) setAddFiles(dropped);
|
||||||
|
}}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="mx-auto h-10 w-10 text-gray-400 mb-2"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{addFiles.length > 0 ? (
|
||||||
|
<p className="text-sm font-medium text-blue-700">
|
||||||
|
{addFiles.length === 1
|
||||||
|
? addFiles[0].name
|
||||||
|
: `${addFiles.length} fichiers sélectionnés`}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
{isDraggingAdd
|
||||||
|
? t('addDocument.dropHere')
|
||||||
|
: t('addDocument.dragOrClick')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-gray-400 mt-1">{t('addDocument.acceptedFormats')}</p>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.gif"
|
||||||
|
className="hidden"
|
||||||
|
onChange={e => {
|
||||||
|
const selected = Array.from(e.target.files || []);
|
||||||
|
if (selected.length > 0) setAddFiles(selected);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -794,7 +858,7 @@ export default function UserDocumentsPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleFileUpload}
|
onClick={handleFileUpload}
|
||||||
disabled={uploadingFiles || !selectedBookingId}
|
disabled={uploadingFiles || !selectedBookingId || addFiles.length === 0}
|
||||||
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{uploadingFiles ? (
|
{uploadingFiles ? (
|
||||||
@ -884,15 +948,59 @@ export default function UserDocumentsPage() {
|
|||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
{t('replaceDocument.newFile')}
|
{t('replaceDocument.newFile')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<div
|
||||||
ref={replaceFileInputRef}
|
className={`relative border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors ${
|
||||||
type="file"
|
isDraggingReplace
|
||||||
accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.gif"
|
? 'border-blue-500 bg-blue-50'
|
||||||
className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100"
|
: 'border-gray-300 hover:border-blue-400 hover:bg-gray-50'
|
||||||
/>
|
}`}
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
onDragOver={e => {
|
||||||
{t('replaceDocument.acceptedFormats')}
|
e.preventDefault();
|
||||||
</p>
|
setIsDraggingReplace(true);
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setIsDraggingReplace(false)}
|
||||||
|
onDrop={e => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDraggingReplace(false);
|
||||||
|
const dropped = e.dataTransfer.files[0];
|
||||||
|
if (dropped) setReplaceFile(dropped);
|
||||||
|
}}
|
||||||
|
onClick={() => replaceFileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="mx-auto h-10 w-10 text-gray-400 mb-2"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{replaceFile ? (
|
||||||
|
<p className="text-sm font-medium text-blue-700">{replaceFile.name}</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
{isDraggingReplace
|
||||||
|
? t('replaceDocument.dropHere')
|
||||||
|
: t('replaceDocument.dragOrClick')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-gray-400 mt-1">{t('replaceDocument.acceptedFormats')}</p>
|
||||||
|
<input
|
||||||
|
ref={replaceFileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.gif"
|
||||||
|
className="hidden"
|
||||||
|
onChange={e => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) setReplaceFile(file);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -426,7 +426,25 @@
|
|||||||
"status": "Status",
|
"status": "Status",
|
||||||
"date": "Date",
|
"date": "Date",
|
||||||
"quoteNumber": "Quote No.",
|
"quoteNumber": "Quote No.",
|
||||||
"bookingNumber": "Booking No."
|
"bookingNumber": "Booking No.",
|
||||||
|
"actions": "Actions"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"view": "View",
|
||||||
|
"pay": "Pay"
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"title": "Booking",
|
||||||
|
"route": "Route",
|
||||||
|
"date": "Creation date",
|
||||||
|
"cargo": "Cargo",
|
||||||
|
"weight": "Weight / Volume",
|
||||||
|
"pickupDate": "Requested pickup date",
|
||||||
|
"price": "Price",
|
||||||
|
"pendingPaymentNotice": "This booking is awaiting commission payment. Click below to complete the payment.",
|
||||||
|
"payNow": "Complete payment",
|
||||||
|
"newBooking": "New booking",
|
||||||
|
"close": "Close"
|
||||||
},
|
},
|
||||||
"mobile": {
|
"mobile": {
|
||||||
"pallets": "Pallets",
|
"pallets": "Pallets",
|
||||||
@ -1182,24 +1200,46 @@
|
|||||||
"modal": {
|
"modal": {
|
||||||
"createTitle": "Create New Organization",
|
"createTitle": "Create New Organization",
|
||||||
"editTitle": "Edit Organization",
|
"editTitle": "Edit Organization",
|
||||||
"name": "Organization Name *",
|
"sectionGeneral": "General information",
|
||||||
"type": "Type *",
|
"sectionLegal": "Legal identifiers",
|
||||||
"scacLabel": "SCAC Code *",
|
"sectionContact": "Contact",
|
||||||
|
"sectionAddress": "Address",
|
||||||
|
"sectionOther": "Other",
|
||||||
|
"name": "Organization name",
|
||||||
|
"type": "Type",
|
||||||
|
"typeReadOnly": "Cannot be changed after creation",
|
||||||
|
"scacLabel": "SCAC code",
|
||||||
|
"scacHint": "4 uppercase letters (e.g. MAEU). Required for carriers.",
|
||||||
"sirenLabel": "SIREN",
|
"sirenLabel": "SIREN",
|
||||||
"siretLabel": "SIRET (14 digits)",
|
"sirenHint": "9 digits — optional",
|
||||||
|
"siretLabel": "SIRET",
|
||||||
|
"siretHint": "14 digits — optional",
|
||||||
"siretPlaceholder": "12345678901234",
|
"siretPlaceholder": "12345678901234",
|
||||||
"eoriLabel": "EORI",
|
"eoriLabel": "EORI",
|
||||||
"contactPhone": "Contact Phone",
|
"eoriHint": "EU EORI number (e.g. FR123456789) — optional",
|
||||||
"contactEmail": "Contact Email",
|
"contactEmail": "Contact email",
|
||||||
"street": "Street Address *",
|
"contactPhone": "Contact phone",
|
||||||
"city": "City *",
|
"street": "Street address",
|
||||||
"postalCode": "Postal Code *",
|
"city": "City",
|
||||||
|
"postalCode": "Postal code",
|
||||||
"state": "State / Region",
|
"state": "State / Region",
|
||||||
"country": "Country *",
|
"country": "Country",
|
||||||
|
"countryHint": "2-letter ISO code (e.g. FR, DE, US)",
|
||||||
"logoUrl": "Logo URL",
|
"logoUrl": "Logo URL",
|
||||||
|
"isActive": "Active organization",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"create": "Create",
|
"create": "Create",
|
||||||
"update": "Update"
|
"update": "Update",
|
||||||
|
"saving": "Saving..."
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"required": "This field is required",
|
||||||
|
"nameTooShort": "Name must be at least 2 characters",
|
||||||
|
"scacRequired": "SCAC code is required for carriers",
|
||||||
|
"scacFormat": "SCAC must be exactly 4 uppercase letters (e.g. MAEU)",
|
||||||
|
"sirenFormat": "SIREN must be exactly 9 digits",
|
||||||
|
"siretFormat": "SIRET must be exactly 14 digits",
|
||||||
|
"countryFormat": "Country must be a 2-letter ISO code (e.g. FR)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"users": {
|
"users": {
|
||||||
@ -1437,6 +1477,8 @@
|
|||||||
"actions": "Actions"
|
"actions": "Actions"
|
||||||
},
|
},
|
||||||
"statuses": {
|
"statuses": {
|
||||||
|
"PENDING_PAYMENT": "Payment pending",
|
||||||
|
"PENDING_BANK_TRANSFER": "Bank transfer pending",
|
||||||
"PENDING": "Pending",
|
"PENDING": "Pending",
|
||||||
"ACCEPTED": "Accepted",
|
"ACCEPTED": "Accepted",
|
||||||
"REJECTED": "Rejected",
|
"REJECTED": "Rejected",
|
||||||
@ -1472,6 +1514,8 @@
|
|||||||
"selectBookingPlaceholder": "-- Choose a booking --",
|
"selectBookingPlaceholder": "-- Choose a booking --",
|
||||||
"filesToAdd": "Files to add",
|
"filesToAdd": "Files to add",
|
||||||
"acceptedFormats": "Accepted formats: PDF, Word, Excel, Images (max 10 files)",
|
"acceptedFormats": "Accepted formats: PDF, Word, Excel, Images (max 10 files)",
|
||||||
|
"dragOrClick": "Drag & drop your files here or click to select",
|
||||||
|
"dropHere": "Drop your files here",
|
||||||
"uploading": "Uploading...",
|
"uploading": "Uploading...",
|
||||||
"add": "Add",
|
"add": "Add",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
@ -1485,6 +1529,8 @@
|
|||||||
"booking": "Booking",
|
"booking": "Booking",
|
||||||
"newFile": "New file",
|
"newFile": "New file",
|
||||||
"acceptedFormats": "Accepted formats: PDF, Word, Excel, Images",
|
"acceptedFormats": "Accepted formats: PDF, Word, Excel, Images",
|
||||||
|
"dragOrClick": "Drag & drop your file here or click to select",
|
||||||
|
"dropHere": "Drop your file here",
|
||||||
"replacing": "Replacing...",
|
"replacing": "Replacing...",
|
||||||
"replace": "Replace",
|
"replace": "Replace",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
|
|||||||
@ -426,7 +426,25 @@
|
|||||||
"status": "Statut",
|
"status": "Statut",
|
||||||
"date": "Date",
|
"date": "Date",
|
||||||
"quoteNumber": "N° Devis",
|
"quoteNumber": "N° Devis",
|
||||||
"bookingNumber": "N° Booking"
|
"bookingNumber": "N° Booking",
|
||||||
|
"actions": "Actions"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"view": "Voir",
|
||||||
|
"pay": "Payer"
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"title": "Réservation",
|
||||||
|
"route": "Route",
|
||||||
|
"date": "Date de création",
|
||||||
|
"cargo": "Marchandise",
|
||||||
|
"weight": "Poids / Volume",
|
||||||
|
"pickupDate": "Date d'enlèvement souhaitée",
|
||||||
|
"price": "Prix",
|
||||||
|
"pendingPaymentNotice": "Cette réservation est en attente du paiement de la commission. Cliquez ci-dessous pour finaliser le paiement.",
|
||||||
|
"payNow": "Finaliser le paiement",
|
||||||
|
"newBooking": "Nouvelle réservation",
|
||||||
|
"close": "Fermer"
|
||||||
},
|
},
|
||||||
"mobile": {
|
"mobile": {
|
||||||
"pallets": "Palettes",
|
"pallets": "Palettes",
|
||||||
@ -1182,24 +1200,46 @@
|
|||||||
"modal": {
|
"modal": {
|
||||||
"createTitle": "Créer une nouvelle organisation",
|
"createTitle": "Créer une nouvelle organisation",
|
||||||
"editTitle": "Modifier l'organisation",
|
"editTitle": "Modifier l'organisation",
|
||||||
"name": "Nom de l'organisation *",
|
"sectionGeneral": "Informations générales",
|
||||||
"type": "Type *",
|
"sectionLegal": "Identifiants légaux",
|
||||||
"scacLabel": "Code SCAC *",
|
"sectionContact": "Contact",
|
||||||
|
"sectionAddress": "Adresse",
|
||||||
|
"sectionOther": "Autres",
|
||||||
|
"name": "Nom de l'organisation",
|
||||||
|
"type": "Type",
|
||||||
|
"typeReadOnly": "Non modifiable après création",
|
||||||
|
"scacLabel": "Code SCAC",
|
||||||
|
"scacHint": "4 lettres majuscules (ex. MAEU). Obligatoire pour les transporteurs.",
|
||||||
"sirenLabel": "SIREN",
|
"sirenLabel": "SIREN",
|
||||||
"siretLabel": "SIRET (14 chiffres)",
|
"sirenHint": "9 chiffres — facultatif",
|
||||||
|
"siretLabel": "SIRET",
|
||||||
|
"siretHint": "14 chiffres — facultatif",
|
||||||
"siretPlaceholder": "12345678901234",
|
"siretPlaceholder": "12345678901234",
|
||||||
"eoriLabel": "EORI",
|
"eoriLabel": "EORI",
|
||||||
"contactPhone": "Téléphone de contact",
|
"eoriHint": "Numéro EORI européen (ex. FR123456789) — facultatif",
|
||||||
"contactEmail": "Email de contact",
|
"contactEmail": "Email de contact",
|
||||||
"street": "Rue *",
|
"contactPhone": "Téléphone de contact",
|
||||||
"city": "Ville *",
|
"street": "Rue",
|
||||||
"postalCode": "Code postal *",
|
"city": "Ville",
|
||||||
|
"postalCode": "Code postal",
|
||||||
"state": "État / Région",
|
"state": "État / Région",
|
||||||
"country": "Pays *",
|
"country": "Pays",
|
||||||
|
"countryHint": "Code ISO 2 lettres majuscules (ex. FR, DE, US)",
|
||||||
"logoUrl": "URL du logo",
|
"logoUrl": "URL du logo",
|
||||||
|
"isActive": "Organisation active",
|
||||||
"cancel": "Annuler",
|
"cancel": "Annuler",
|
||||||
"create": "Créer",
|
"create": "Créer",
|
||||||
"update": "Mettre à jour"
|
"update": "Mettre à jour",
|
||||||
|
"saving": "Enregistrement..."
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"required": "Ce champ est obligatoire",
|
||||||
|
"nameTooShort": "Le nom doit comporter au moins 2 caractères",
|
||||||
|
"scacRequired": "Le code SCAC est obligatoire pour les transporteurs",
|
||||||
|
"scacFormat": "Le code SCAC doit comporter exactement 4 lettres majuscules (ex. MAEU)",
|
||||||
|
"sirenFormat": "Le SIREN doit comporter exactement 9 chiffres",
|
||||||
|
"siretFormat": "Le SIRET doit comporter exactement 14 chiffres",
|
||||||
|
"countryFormat": "Le pays doit être un code ISO à 2 lettres majuscules (ex. FR)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"users": {
|
"users": {
|
||||||
@ -1437,6 +1477,8 @@
|
|||||||
"actions": "Actions"
|
"actions": "Actions"
|
||||||
},
|
},
|
||||||
"statuses": {
|
"statuses": {
|
||||||
|
"PENDING_PAYMENT": "Paiement en attente",
|
||||||
|
"PENDING_BANK_TRANSFER": "Virement en attente",
|
||||||
"PENDING": "En attente",
|
"PENDING": "En attente",
|
||||||
"ACCEPTED": "Accepté",
|
"ACCEPTED": "Accepté",
|
||||||
"REJECTED": "Refusé",
|
"REJECTED": "Refusé",
|
||||||
@ -1472,6 +1514,8 @@
|
|||||||
"selectBookingPlaceholder": "-- Choisir une réservation --",
|
"selectBookingPlaceholder": "-- Choisir une réservation --",
|
||||||
"filesToAdd": "Fichiers à ajouter",
|
"filesToAdd": "Fichiers à ajouter",
|
||||||
"acceptedFormats": "Formats acceptés: PDF, Word, Excel, Images (max 10 fichiers)",
|
"acceptedFormats": "Formats acceptés: PDF, Word, Excel, Images (max 10 fichiers)",
|
||||||
|
"dragOrClick": "Glissez-déposez vos fichiers ici ou cliquez pour sélectionner",
|
||||||
|
"dropHere": "Déposez vos fichiers ici",
|
||||||
"uploading": "Envoi en cours...",
|
"uploading": "Envoi en cours...",
|
||||||
"add": "Ajouter",
|
"add": "Ajouter",
|
||||||
"cancel": "Annuler",
|
"cancel": "Annuler",
|
||||||
@ -1485,6 +1529,8 @@
|
|||||||
"booking": "Réservation",
|
"booking": "Réservation",
|
||||||
"newFile": "Nouveau fichier",
|
"newFile": "Nouveau fichier",
|
||||||
"acceptedFormats": "Formats acceptés: PDF, Word, Excel, Images",
|
"acceptedFormats": "Formats acceptés: PDF, Word, Excel, Images",
|
||||||
|
"dragOrClick": "Glissez-déposez votre fichier ici ou cliquez pour sélectionner",
|
||||||
|
"dropHere": "Déposez votre fichier ici",
|
||||||
"replacing": "Remplacement en cours...",
|
"replacing": "Remplacement en cours...",
|
||||||
"replace": "Remplacer",
|
"replace": "Remplacer",
|
||||||
"cancel": "Annuler",
|
"cancel": "Annuler",
|
||||||
|
|||||||
@ -51,7 +51,7 @@ export interface CsvBookingResponse {
|
|||||||
primaryCurrency: string;
|
primaryCurrency: string;
|
||||||
transitDays: number;
|
transitDays: number;
|
||||||
containerType: string;
|
containerType: string;
|
||||||
status: 'PENDING_PAYMENT' | 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED';
|
status: 'PENDING_PAYMENT' | 'PENDING_BANK_TRANSFER' | 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED';
|
||||||
documents: Array<{
|
documents: Array<{
|
||||||
type: string;
|
type: string;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
|
|||||||
@ -66,3 +66,12 @@ export async function updateOrganization(
|
|||||||
): Promise<OrganizationResponse> {
|
): Promise<OrganizationResponse> {
|
||||||
return patch<OrganizationResponse>(`/api/v1/organizations/${id}`, data);
|
return patch<OrganizationResponse>(`/api/v1/organizations/${id}`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request SIRET/SIREN approval from admins
|
||||||
|
* POST /api/v1/organizations/request-siret-approval
|
||||||
|
* Requires: Authenticated user
|
||||||
|
*/
|
||||||
|
export async function requestSiretApproval(): Promise<{ message: string }> {
|
||||||
|
return post<{ message: string }>('/api/v1/organizations/request-siret-approval', {});
|
||||||
|
}
|
||||||
|
|||||||
@ -111,13 +111,20 @@ export type OrganizationType = 'FREIGHT_FORWARDER' | 'CARRIER' | 'SHIPPER';
|
|||||||
export interface CreateOrganizationRequest {
|
export interface CreateOrganizationRequest {
|
||||||
name: string;
|
name: string;
|
||||||
type: OrganizationType;
|
type: OrganizationType;
|
||||||
address_street: string;
|
address: {
|
||||||
address_city: string;
|
street: string;
|
||||||
address_postal_code: string;
|
city: string;
|
||||||
address_country: string;
|
state?: string;
|
||||||
|
postalCode: string;
|
||||||
|
country: string;
|
||||||
|
};
|
||||||
|
siren?: string;
|
||||||
|
siret?: string;
|
||||||
|
eori?: string;
|
||||||
|
scac?: string;
|
||||||
contact_email?: string;
|
contact_email?: string;
|
||||||
contact_phone?: string;
|
contact_phone?: string;
|
||||||
logo_url?: string;
|
logoUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateOrganizationRequest {
|
export interface UpdateOrganizationRequest {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user