Compare commits

..

7 Commits

Author SHA1 Message Date
David
37c685d73f feat: request SIRET/SIREN admin approval from payment page 2026-06-07 18:24:23 +02:00
David
295874e11f fix: rework org admin form with full business-rule validation 2026-06-07 18:11:09 +02:00
David
aa301eb447 fix: allow org creation without SIRET/SIREN and pass all DTO fields to entity 2026-06-07 18:02:51 +02:00
David
0e6383693c fix: admin org creation - send nested address object instead of flat fields 2026-06-07 17:51:50 +02:00
David
1da5570e1c fix: unauthorized on document upload and drag-drop in booking creation
- Replace localStorage.getItem('access_token') with getAuthToken() which
  also checks sessionStorage — fixes Unauthorized error for users logged
  in without 'remember me'
- Add real drag-and-drop support to booking creation page (step 2):
  onDragOver/onDragLeave/onDrop handlers on the dropzone container,
  isDragging state with visual feedback (blue border + text change),
  click-to-browse still works via hidden input ref

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 17:42:27 +02:00
David
b344650608 fix: resolve payment access, status labels, and document upload reliability
- Add PENDING_PAYMENT and PENDING_BANK_TRANSFER to document statuses
  translations (was displaying raw i18n key instead of human label)
- Fix getStatusLabel/getStatusColor to handle all booking statuses
- Fix select dropdown in Add Document modal to use getStatusLabel()
  instead of hardcoded PENDING/ACCEPTED binary
- Add direct "Payer" button (orange) on PENDING_PAYMENT rows in both
  desktop table and mobile cards, linking to /dashboard/booking/[id]/pay
- Update detail modal to show payment CTA for PENDING_PAYMENT bookings
- Refactor file handling in document modals to use React state (addFiles,
  replaceFile) instead of reading from DOM ref — fixes upload reliability
  and shows selected filename feedback in the dropzone

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 17:26:44 +02:00
David
9950d96fd6 fix: correct 5 reported bugs across booking and document management
- SIRET/SIREN admin: controller applied siren but skipped siret — add missing updateSiret() call
- Document upload on validated booking: backend blocked PENDING_BANK_TRANSFER;
  frontend filter excluded it — both now allow all non-terminal statuses
- Accent encoding in filenames: Multer stores originalname as latin1;
  re-decode to utf8 before storing and displaying
- Drag & drop in document modals: replace bare input with styled dropzone
  supporting click and native drag-and-drop in both Add and Replace modals
- Resume existing booking: add Actions column with View button and booking
  detail modal to the bookings list page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 16:49:12 +02:00
14 changed files with 1212 additions and 397 deletions

View File

@ -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
* *

View File

@ -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;

View File

@ -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 {}

View File

@ -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
) { ) {

View File

@ -30,19 +30,7 @@ interface Organization {
createdAt: string; createdAt: string;
} }
export default function AdminOrganizationsPage() { interface FormData {
const t = useTranslations('dashboard.admin.organizations');
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedOrg, setSelectedOrg] = useState<Organization | null>(null);
const [showCreateModal, setShowCreateModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [verifyingId, setVerifyingId] = useState<string | null>(null);
// Form state
const [formData, setFormData] = useState<{
name: string; name: string;
type: string; type: string;
scac: string; scac: string;
@ -54,12 +42,15 @@ export default function AdminOrganizationsPage() {
address: { address: {
street: string; street: string;
city: string; city: string;
state?: string; state: string;
postalCode: string; postalCode: string;
country: string; country: string;
}; };
logoUrl: string; logoUrl: string;
}>({ isActive: boolean;
}
const EMPTY_FORM: FormData = {
name: '', name: '',
type: 'FREIGHT_FORWARDER', type: 'FREIGHT_FORWARDER',
scac: '', scac: '',
@ -68,15 +59,34 @@ export default function AdminOrganizationsPage() {
eori: '', eori: '',
contact_phone: '', contact_phone: '',
contact_email: '', contact_email: '',
address: { address: { street: '', city: '', state: '', postalCode: '', country: 'FR' },
street: '',
city: '',
state: '',
postalCode: '',
country: 'FR',
},
logoUrl: '', 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() {
const t = useTranslations('dashboard.admin.organizations');
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedOrg, setSelectedOrg] = useState<Organization | null>(null);
const [showCreateModal, setShowCreateModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [verifyingId, setVerifyingId] = useState<string | null>(null);
const [formData, setFormData] = useState<FormData>(EMPTY_FORM);
const [formError, setFormError] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<Partial<Record<string, string>>>({});
const [submitting, setSubmitting] = useState(false);
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,72 +462,137 @@ 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">
<form
onSubmit={showCreateModal ? handleCreate : handleUpdate}
noValidate
className="space-y-5"
>
{/* ── Informations générales ── */}
<section>
<h3 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
{t('modal.sectionGeneral')}
</h3>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="col-span-2"> <div className="col-span-2">
<label className="block text-sm font-medium text-gray-700"> <label className="block text-sm font-medium text-gray-700">
{t('modal.name')} {t('modal.name')} <span className="text-red-500">*</span>
</label> </label>
<input <input
type="text" type="text"
required
value={formData.name} value={formData.name}
onChange={e => setFormData({ ...formData, name: e.target.value })} onChange={e => {
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" setFormData({ ...formData, name: e.target.value });
setFieldErrors(prev => ({ ...prev, name: undefined }));
}}
minLength={2}
maxLength={200}
className={inputClass(fieldErrors.name)}
placeholder="Acme Freight Forwarding"
/> />
<FieldError message={fieldErrors.name} />
</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.type')} {t('modal.type')} <span className="text-red-500">*</span>
</label> </label>
{showCreateModal ? (
<select <select
value={formData.type} value={formData.type}
onChange={e => setFormData({ ...formData, type: e.target.value })} onChange={e => handleTypeChange(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" className={inputClass()}
> >
<option value="FREIGHT_FORWARDER">{t('types.FREIGHT_FORWARDER')}</option> <option value="FREIGHT_FORWARDER">{t('types.FREIGHT_FORWARDER')}</option>
<option value="CARRIER">{t('types.CARRIER')}</option> <option value="CARRIER">{t('types.CARRIER')}</option>
<option value="SHIPPER">{t('types.SHIPPER')}</option> <option value="SHIPPER">{t('types.SHIPPER')}</option>
</select> </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> </div>
{formData.type === 'CARRIER' && ( {/* 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> <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.scacLabel')}
</label> </label>
<input <div className="mt-1 px-3 py-2 bg-gray-50 border border-gray-200 rounded-md text-sm text-gray-700 font-mono">
type="text" {formData.scac}
required={formData.type === 'CARRIER'} </div>
maxLength={4} <FieldHint>{t('modal.typeReadOnly')}</FieldHint>
value={formData.scac}
onChange={e =>
setFormData({ ...formData, scac: 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>
)} )}
</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> <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.sirenLabel')}
</label> </label>
<input <input
type="text" type="text"
maxLength={9}
value={formData.siren} value={formData.siren}
onChange={e => setFormData({ ...formData, siren: e.target.value })} onChange={e => {
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" 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>
<div> <div>
@ -449,17 +601,21 @@ export default function AdminOrganizationsPage() {
</label> </label>
<input <input
type="text" type="text"
maxLength={14}
value={formData.siret} value={formData.siret}
onChange={e => onChange={e => {
setFormData({ ...formData, siret: e.target.value.replace(/\D/g, '') }) const v = e.target.value.replace(/\D/g, '').slice(0, 14);
} setFormData({ ...formData, siret: v });
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" setFieldErrors(prev => ({ ...prev, siret: undefined }));
placeholder={t('modal.siretPlaceholder')} }}
maxLength={14}
className={inputClass(fieldErrors.siret)}
placeholder="12345678901234"
/> />
<FieldHint>{t('modal.siretHint')}</FieldHint>
<FieldError message={fieldErrors.siret} />
</div> </div>
<div> <div className="col-span-2">
<label className="block text-sm font-medium text-gray-700"> <label className="block text-sm font-medium text-gray-700">
{t('modal.eoriLabel')} {t('modal.eoriLabel')}
</label> </label>
@ -467,7 +623,30 @@ export default function AdminOrganizationsPage() {
type="text" type="text"
value={formData.eori} value={formData.eori}
onChange={e => setFormData({ ...formData, eori: e.target.value })} onChange={e => setFormData({ ...formData, eori: 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" 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>
@ -479,74 +658,68 @@ export default function AdminOrganizationsPage() {
type="tel" type="tel"
value={formData.contact_phone} value={formData.contact_phone}
onChange={e => setFormData({ ...formData, contact_phone: e.target.value })} 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" className={inputClass()}
placeholder="+33 6 00 00 00 00"
/> />
</div> </div>
<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="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>
</section>
{/* ── Adresse ── */}
<section>
<h3 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
{t('modal.sectionAddress')}
</h3>
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2"> <div className="col-span-2">
<label className="block text-sm font-medium text-gray-700"> <label className="block text-sm font-medium text-gray-700">
{t('modal.street')} {t('modal.street')} <span className="text-red-500">*</span>
</label> </label>
<input <input
type="text" type="text"
required
value={formData.address.street} value={formData.address.street}
onChange={e => onChange={e => {
setFormData({ setFormData({ ...formData, address: { ...formData.address, street: e.target.value } });
...formData, setFieldErrors(prev => ({ ...prev, street: undefined }));
address: { ...formData.address, street: e.target.value }, }}
}) className={inputClass(fieldErrors.street)}
} placeholder="123 Rue de la République"
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"
/> />
<FieldError message={fieldErrors.street} />
</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.city')} {t('modal.city')} <span className="text-red-500">*</span>
</label> </label>
<input <input
type="text" type="text"
required
value={formData.address.city} value={formData.address.city}
onChange={e => onChange={e => {
setFormData({ setFormData({ ...formData, address: { ...formData.address, city: e.target.value } });
...formData, setFieldErrors(prev => ({ ...prev, city: undefined }));
address: { ...formData.address, city: e.target.value }, }}
}) className={inputClass(fieldErrors.city)}
} placeholder="Paris"
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"
/> />
<FieldError message={fieldErrors.city} />
</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.postalCode')} {t('modal.postalCode')} <span className="text-red-500">*</span>
</label> </label>
<input <input
type="text" type="text"
required
value={formData.address.postalCode} value={formData.address.postalCode}
onChange={e => onChange={e => {
setFormData({ setFormData({ ...formData, address: { ...formData.address, postalCode: e.target.value } });
...formData, setFieldErrors(prev => ({ ...prev, postalCode: undefined }));
address: { ...formData.address, postalCode: e.target.value }, }}
}) className={inputClass(fieldErrors.postalCode)}
} placeholder="75001"
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"
/> />
<FieldError message={fieldErrors.postalCode} />
</div> </div>
<div> <div>
@ -557,35 +730,43 @@ export default function AdminOrganizationsPage() {
type="text" type="text"
value={formData.address.state} value={formData.address.state}
onChange={e => onChange={e =>
setFormData({ setFormData({ ...formData, address: { ...formData.address, state: e.target.value } })
...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" className={inputClass()}
placeholder="Île-de-France"
/> />
</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.country')} {t('modal.country')} <span className="text-red-500">*</span>
</label> </label>
<input <input
type="text" type="text"
required
maxLength={2}
value={formData.address.country} value={formData.address.country}
onChange={e => onChange={e => {
setFormData({ const v = e.target.value.toUpperCase().replace(/[^A-Z]/g, '').slice(0, 2);
...formData, setFormData({ ...formData, address: { ...formData.address, country: v } });
address: { ...formData.address, country: e.target.value.toUpperCase() }, setFieldErrors(prev => ({ ...prev, country: undefined }));
}) }}
} maxLength={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" minLength={2}
className={inputClass(fieldErrors.country)}
placeholder="FR"
/> />
<FieldHint>{t('modal.countryHint')}</FieldHint>
<FieldError message={fieldErrors.country} />
</div> </div>
</div>
</section>
<div className="col-span-2"> {/* ── Autres ── */}
<section>
<h3 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
{t('modal.sectionOther')}
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700"> <label className="block text-sm font-medium text-gray-700">
{t('modal.logoUrl')} {t('modal.logoUrl')}
</label> </label>
@ -593,29 +774,53 @@ export default function AdminOrganizationsPage() {
type="url" type="url"
value={formData.logoUrl} value={formData.logoUrl}
onChange={e => setFormData({ ...formData, logoUrl: e.target.value })} 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" className={inputClass()}
placeholder="https://exemple.com/logo.png"
/> />
</div> </div>
</div>
<div className="flex justify-end space-x-2 pt-4"> {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>
</section>
{/* Erreur globale */}
{formError && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md text-sm">
{formError}
</div>
)}
<div className="flex justify-end space-x-3 pt-2 border-t border-gray-100">
<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>

View File

@ -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&apos;est pas encore validé
</h3>
<p className="text-sm text-amber-700 mb-4">
Un administrateur doit vérifier votre numéro d&apos;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">

View File

@ -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 */}

View File

@ -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,6 +342,7 @@ export default function BookingsListPage() {
</div> </div>
</div> </div>
</div> </div>
<div className="flex items-center justify-between">
<div className="text-xs text-gray-400"> <div className="text-xs text-gray-400">
{booking.type === 'csv' {booking.type === 'csv'
? t('mobile.ref', { ? t('mobile.ref', {
@ -348,6 +350,25 @@ export default function BookingsListPage() {
}) })
: t('mobile.booking', { number: booking.bookingNumber || '-' })} : t('mobile.booking', { number: booking.bookingNumber || '-' })}
</div> </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> </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>
); );
} }

View File

@ -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>
<div
className={`relative border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors ${
isDraggingAdd
? 'border-blue-500 bg-blue-50'
: 'border-gray-300 hover:border-blue-400 hover:bg-gray-50'
}`}
onDragOver={e => {
e.preventDefault();
setIsDraggingAdd(true);
}}
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 <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
multiple multiple
accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.gif" accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.gif"
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" className="hidden"
onChange={e => {
const selected = Array.from(e.target.files || []);
if (selected.length > 0) setAddFiles(selected);
}}
/> />
<p className="mt-1 text-xs text-gray-500"> </div>
{t('addDocument.acceptedFormats')}
</p>
</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>
<div
className={`relative border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors ${
isDraggingReplace
? 'border-blue-500 bg-blue-50'
: 'border-gray-300 hover:border-blue-400 hover:bg-gray-50'
}`}
onDragOver={e => {
e.preventDefault();
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 <input
ref={replaceFileInputRef} ref={replaceFileInputRef}
type="file" type="file"
accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.gif" accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.gif"
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" className="hidden"
onChange={e => {
const file = e.target.files?.[0];
if (file) setReplaceFile(file);
}}
/> />
<p className="mt-1 text-xs text-gray-500"> </div>
{t('replaceDocument.acceptedFormats')}
</p>
</div> </div>
</div> </div>
</div> </div>

View File

@ -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",

View File

@ -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",

View File

@ -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;

View File

@ -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', {});
}

View File

@ -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 {