From 76d940d73b96398158db195a376eae332c90ab8a Mon Sep 17 00:00:00 2001 From: David Date: Mon, 14 Sep 2026 11:43:57 +0200 Subject: [PATCH] feat: ajouter l'onglet Historique des devis et remplacer le statut Paiement en attente MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le statut PENDING_PAYMENT ne designe plus un paiement en attente mais un devis : une reservation construite dont les frais de booking ne sont pas encore regles. L'enum backend devient QUOTE, converti en place par migration. Les deux onglets se partagent desormais la meme liste : - « Reservations » n'affiche que PENDING, ACCEPTED et REJECTED ; - « Historique des devis » regroupe QUOTE, PENDING_BANK_TRANSFER et CANCELLED, avec les actions propres a un devis (modifier, payer, supprimer). La table, l'export et les filtres sont extraits dans BookingListView pour eviter de dupliquer la page entre les deux vues. Tableau de bord : - l'alerte « Paiement en attente » disparait des points a traiter, les devis non regles ne bloquant aucun envoi en cours ; - « Sans reponse du transporteur » devient « En attente du transporteur ». Migration verifiee en transaction annulee sur la base de dev : les lignes PENDING_PAYMENT deviennent QUOTE, l'enum et le defaut de colonne suivent. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NZJ5aowZJVAcyPZw5TiNEm --- .../controllers/csv-bookings.controller.ts | 8 +- .../src/application/dto/csv-booking.dto.ts | 10 +- .../application/services/analytics.service.ts | 24 +- .../services/csv-booking.service.ts | 30 +- .../entities/csv-booking.entity.spec.ts | 4 +- .../src/domain/entities/csv-booking.entity.ts | 41 +- .../domain/ports/out/shipment-counter.port.ts | 2 +- .../entities/csv-booking.orm-entity.ts | 19 +- ...90000000000-RenamePendingPaymentToQuote.ts | 77 +++ .../shipment-counter.repository.ts | 2 +- .../app/[locale]/admin/bookings/page.tsx | 6 +- .../dashboard/booking/[id]/pay/page.tsx | 4 +- .../[locale]/dashboard/bookings/[id]/page.tsx | 4 +- .../app/[locale]/dashboard/bookings/page.tsx | 553 +---------------- .../app/[locale]/dashboard/documents/page.tsx | 4 +- .../app/[locale]/dashboard/quotes/page.tsx | 24 + apps/frontend/messages/en.json | 140 ++++- apps/frontend/messages/fr.json | 140 ++++- .../bookings/BookingDetailDialog.tsx | 11 +- .../components/bookings/BookingListView.tsx | 585 ++++++++++++++++++ .../bookings/BookingStatusBadge.tsx | 8 +- .../src/components/shell/nav-config.ts | 7 + apps/frontend/src/lib/api/bookings.ts | 2 +- 23 files changed, 1040 insertions(+), 665 deletions(-) create mode 100644 apps/backend/src/infrastructure/persistence/typeorm/migrations/1790000000000-RenamePendingPaymentToQuote.ts create mode 100644 apps/frontend/app/[locale]/dashboard/quotes/page.tsx create mode 100644 apps/frontend/src/components/bookings/BookingListView.tsx diff --git a/apps/backend/src/application/controllers/csv-bookings.controller.ts b/apps/backend/src/application/controllers/csv-bookings.controller.ts index fed40dc..8f93c6f 100644 --- a/apps/backend/src/application/controllers/csv-bookings.controller.ts +++ b/apps/backend/src/application/controllers/csv-bookings.controller.ts @@ -436,7 +436,7 @@ export class CsvBookingsController { }, }, }) - @ApiResponse({ status: 400, description: 'Booking not in PENDING_PAYMENT status' }) + @ApiResponse({ status: 400, description: 'Booking not in QUOTE status' }) @ApiResponse({ status: 404, description: 'Booking not found' }) async payCommission(@Param('id') id: string, @Request() req: any) { const userId = req.user.id; @@ -520,7 +520,7 @@ export class CsvBookingsController { description: 'Bank transfer declared, booking awaiting admin validation', type: CsvBookingResponseDto, }) - @ApiResponse({ status: 400, description: 'Booking not in PENDING_PAYMENT status' }) + @ApiResponse({ status: 400, description: 'Booking not in QUOTE status' }) @ApiResponse({ status: 404, description: 'Booking not found' }) async declareTransfer( @Param('id') id: string, @@ -628,7 +628,7 @@ export class CsvBookingsController { @ApiOperation({ summary: 'Update booking details before payment', description: - 'Edit cargo characteristics (volume, weight, pallets, notes) of a booking awaiting payment. Only the owner can edit, and only while the booking is PENDING_PAYMENT.', + 'Edit cargo characteristics (volume, weight, pallets, notes) of a booking awaiting payment. Only the owner can edit, and only while the booking is QUOTE.', }) @ApiParam({ name: 'id', description: 'Booking ID (UUID)' }) @ApiResponse({ @@ -659,7 +659,7 @@ export class CsvBookingsController { @ApiOperation({ summary: 'Update booking rate/route before payment', description: - 'Re-apply a rate selection (carrier, route, container, transit, cargo, price) to a PENDING_PAYMENT booking. Only the owner can edit.', + 'Re-apply a rate selection (carrier, route, container, transit, cargo, price) to a QUOTE booking. Only the owner can edit.', }) @ApiParam({ name: 'id', description: 'Booking ID (UUID)' }) @ApiResponse({ diff --git a/apps/backend/src/application/dto/csv-booking.dto.ts b/apps/backend/src/application/dto/csv-booking.dto.ts index 775f562..e55f26c 100644 --- a/apps/backend/src/application/dto/csv-booking.dto.ts +++ b/apps/backend/src/application/dto/csv-booking.dto.ts @@ -265,7 +265,7 @@ export class UpdateCsvBookingDetailsDto { * * Full re-selection of a rate before payment (carrier + route + container + * transit + cargo + price). Used when the user re-runs the search and picks a - * (possibly different) rate for a PENDING_PAYMENT booking. + * (possibly different) rate for a QUOTE booking. */ export class UpdateCsvBookingRateDto { @ApiProperty({ example: 'SSC Consolidation' }) @@ -526,8 +526,8 @@ export class CsvBookingResponseDto { @ApiProperty({ description: 'Booking status', - enum: ['PENDING_PAYMENT', 'PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED'], - example: 'PENDING_PAYMENT', + enum: ['QUOTE', 'PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED'], + example: 'QUOTE', }) status: string; @@ -689,10 +689,10 @@ export class CsvBookingListResponseDto { */ export class CsvBookingStatsDto { @ApiProperty({ - description: 'Number of bookings awaiting payment', + description: 'Number of quotes (bookings whose booking fee is unpaid)', example: 1, }) - pendingPayment: number; + quote: number; @ApiProperty({ description: 'Number of pending bookings', diff --git a/apps/backend/src/application/services/analytics.service.ts b/apps/backend/src/application/services/analytics.service.ts index a863d53..d5a8e1e 100644 --- a/apps/backend/src/application/services/analytics.service.ts +++ b/apps/backend/src/application/services/analytics.service.ts @@ -233,7 +233,7 @@ export class AnalyticsService { const waitedMs = now - booking.requestedAt.getTime(); const waitedDays = Math.floor(waitedMs / oneDay); - // Sans reponse du transporteur au-dela de 48 h, la demande decroche. + // En attente du transporteur au-dela de 48 h : la demande decroche. if (booking.status === CsvBookingStatus.PENDING && waitedMs > 2 * oneDay) { alerts.push({ id: `pending-${booking.id}`, @@ -250,26 +250,8 @@ export class AnalyticsService { continue; } - // Reservation bloquee avant paiement : rien ne part tant qu'elle y reste. - if ( - (booking.status === CsvBookingStatus.PENDING_PAYMENT || - booking.status === CsvBookingStatus.PENDING_BANK_TRANSFER) && - waitedMs > oneDay - ) { - alerts.push({ - id: `payment-${booking.id}`, - type: 'payment', - severity: 'critical', - titleKey: 'awaitingPayment', - messageKey: 'awaitingPaymentSince', - messageParams: { days: waitedDays }, - bookingId: booking.id, - bookingNumber: booking.bookingNumber, - createdAt: booking.requestedAt, - isRead: false, - }); - continue; - } + // Les devis non regles ne remontent plus ici : ils vivent dans + // « Historique des devis » et ne bloquent aucun envoi en cours. // Refus recent : il faut replacer la marchandise. if ( diff --git a/apps/backend/src/application/services/csv-booking.service.ts b/apps/backend/src/application/services/csv-booking.service.ts index e4ac8a0..c452338 100644 --- a/apps/backend/src/application/services/csv-booking.service.ts +++ b/apps/backend/src/application/services/csv-booking.service.ts @@ -167,9 +167,7 @@ export class CsvBookingService { // booking skips the payment gate and the carrier is notified immediately. const bookingFeeEur = await this.resolveBookingFeeEur(organizationId); const requiresPayment = bookingFeeEur > 0; - const initialStatus = requiresPayment - ? CsvBookingStatus.PENDING_PAYMENT - : CsvBookingStatus.PENDING; + const initialStatus = requiresPayment ? CsvBookingStatus.QUOTE : CsvBookingStatus.PENDING; // Create domain entity (no email sent yet when a payment is required) let parsedOptions: Record = {}; @@ -280,9 +278,9 @@ export class CsvBookingService { throw new NotFoundException(`Booking with ID ${bookingId} not found`); } - if (booking.status !== CsvBookingStatus.PENDING_PAYMENT) { + if (booking.status !== CsvBookingStatus.QUOTE) { throw new BadRequestException( - `Booking is not awaiting payment. Current status: ${booking.status}` + `Booking is not a quote awaiting payment. Current status: ${booking.status}` ); } @@ -334,13 +332,13 @@ export class CsvBookingService { throw new NotFoundException(`Booking with ID ${bookingId} not found`); } - if (booking.status !== CsvBookingStatus.PENDING_PAYMENT) { + if (booking.status !== CsvBookingStatus.QUOTE) { // Already confirmed - return current state if (booking.status === CsvBookingStatus.PENDING) { return this.toResponseDto(booking); } throw new BadRequestException( - `Booking is not awaiting payment. Current status: ${booking.status}` + `Booking is not a quote awaiting payment. Current status: ${booking.status}` ); } @@ -437,7 +435,7 @@ export class CsvBookingService { /** * Declare bank transfer — user confirms they have sent the wire transfer - * Transitions booking from PENDING_PAYMENT → PENDING_BANK_TRANSFER + * Transitions booking from QUOTE → PENDING_BANK_TRANSFER * Sends an email notification to all ADMIN users */ async declareBankTransfer(bookingId: string, userId: string): Promise { @@ -451,9 +449,9 @@ export class CsvBookingService { throw new NotFoundException(`Booking with ID ${bookingId} not found`); } - if (booking.status !== CsvBookingStatus.PENDING_PAYMENT) { + if (booking.status !== CsvBookingStatus.QUOTE) { throw new BadRequestException( - `Booking is not awaiting payment. Current status: ${booking.status}` + `Booking is not a quote awaiting payment. Current status: ${booking.status}` ); } @@ -1065,7 +1063,7 @@ export class CsvBookingService { /** * Update the cargo details of a booking before payment (user action). * - * Only the owner can edit, and only while the booking is PENDING_PAYMENT. + * Only the owner can edit, and only while the booking is QUOTE. */ async updateBookingDetails( id: string, @@ -1129,7 +1127,7 @@ export class CsvBookingService { * * Used when the user re-runs the search and picks a (possibly different) rate: * carrier, route, container, transit, cargo and price are all replaced. Only - * the owner can edit, and only while the booking is PENDING_PAYMENT. + * the owner can edit, and only while the booking is QUOTE. */ async updateBookingRate( id: string, @@ -1234,7 +1232,7 @@ export class CsvBookingService { const stats = await this.csvBookingRepository.countByStatusForUser(userId); return { - pendingPayment: stats[CsvBookingStatus.PENDING_PAYMENT] || 0, + quote: stats[CsvBookingStatus.QUOTE] || 0, pending: stats[CsvBookingStatus.PENDING] || 0, accepted: stats[CsvBookingStatus.ACCEPTED] || 0, rejected: stats[CsvBookingStatus.REJECTED] || 0, @@ -1250,7 +1248,7 @@ export class CsvBookingService { const stats = await this.csvBookingRepository.countByStatusForOrganization(organizationId); return { - pendingPayment: stats[CsvBookingStatus.PENDING_PAYMENT] || 0, + quote: stats[CsvBookingStatus.QUOTE] || 0, pending: stats[CsvBookingStatus.PENDING] || 0, accepted: stats[CsvBookingStatus.ACCEPTED] || 0, rejected: stats[CsvBookingStatus.REJECTED] || 0, @@ -1348,9 +1346,9 @@ export class CsvBookingService { throw new NotFoundException(`Booking with ID ${bookingId} not found`); } - // Allow adding documents to PENDING_PAYMENT, PENDING_BANK_TRANSFER, PENDING, or ACCEPTED bookings + // Allow adding documents to QUOTE, PENDING_BANK_TRANSFER, PENDING, or ACCEPTED bookings if ( - booking.status !== CsvBookingStatus.PENDING_PAYMENT && + booking.status !== CsvBookingStatus.QUOTE && booking.status !== CsvBookingStatus.PENDING_BANK_TRANSFER && booking.status !== CsvBookingStatus.PENDING && booking.status !== CsvBookingStatus.ACCEPTED diff --git a/apps/backend/src/domain/entities/csv-booking.entity.spec.ts b/apps/backend/src/domain/entities/csv-booking.entity.spec.ts index 025a81f..6cf43a6 100644 --- a/apps/backend/src/domain/entities/csv-booking.entity.spec.ts +++ b/apps/backend/src/domain/entities/csv-booking.entity.spec.ts @@ -318,7 +318,7 @@ describe('CsvBooking Entity', () => { describe('isDeletable', () => { it('allows deleting a booking whose commission is still unpaid', () => { const booking = createValidBooking(); - booking.status = CsvBookingStatus.PENDING_PAYMENT; + booking.status = CsvBookingStatus.QUOTE; expect(booking.isDeletable()).toBe(true); }); @@ -340,7 +340,7 @@ describe('CsvBooking Entity', () => { it('stops being deletable once the payment is completed', () => { const booking = createValidBooking(); - booking.status = CsvBookingStatus.PENDING_PAYMENT; + booking.status = CsvBookingStatus.QUOTE; expect(booking.isDeletable()).toBe(true); booking.markPaymentCompleted(); diff --git a/apps/backend/src/domain/entities/csv-booking.entity.ts b/apps/backend/src/domain/entities/csv-booking.entity.ts index d5f725e..697d577 100644 --- a/apps/backend/src/domain/entities/csv-booking.entity.ts +++ b/apps/backend/src/domain/entities/csv-booking.entity.ts @@ -6,7 +6,7 @@ import { PortCode } from '../value-objects/port-code.vo'; * Represents the lifecycle of a CSV-based booking request */ export enum CsvBookingStatus { - PENDING_PAYMENT = 'PENDING_PAYMENT', // Awaiting commission payment + QUOTE = 'QUOTE', // Devis : reservation creee, frais de booking non regles PENDING_BANK_TRANSFER = 'PENDING_BANK_TRANSFER', // Bank transfer declared, awaiting admin validation PENDING = 'PENDING', // Awaiting carrier response ACCEPTED = 'ACCEPTED', // Carrier accepted the booking @@ -18,7 +18,7 @@ export enum CsvBookingStatus { * Statuts dans lesquels aucun paiement n'a ete encaisse : la reservation peut * alors etre supprimee. Voir `CsvBooking.isDeletable()`. */ -export const DELETABLE_STATUSES: readonly CsvBookingStatus[] = [CsvBookingStatus.PENDING_PAYMENT]; +export const DELETABLE_STATUSES: readonly CsvBookingStatus[] = [CsvBookingStatus.QUOTE]; /** * Document Interface @@ -194,12 +194,12 @@ export class CsvBooking { /** * Mark commission payment as completed → transition to PENDING * - * @throws Error if booking is not in PENDING_PAYMENT status + * @throws Error if booking is not in QUOTE status */ markPaymentCompleted(): void { - if (this.status !== CsvBookingStatus.PENDING_PAYMENT) { + if (this.status !== CsvBookingStatus.QUOTE) { throw new Error( - `Cannot mark payment completed for booking with status ${this.status}. Only PENDING_PAYMENT bookings can transition.` + `Cannot mark payment completed for booking with status ${this.status}. Only QUOTE bookings can transition.` ); } @@ -210,12 +210,12 @@ export class CsvBooking { * Declare bank transfer → transition to PENDING_BANK_TRANSFER * Called when user confirms they have sent the bank transfer * - * @throws Error if booking is not in PENDING_PAYMENT status + * @throws Error if booking is not in QUOTE status */ markBankTransferDeclared(): void { - if (this.status !== CsvBookingStatus.PENDING_PAYMENT) { + if (this.status !== CsvBookingStatus.QUOTE) { throw new Error( - `Cannot declare bank transfer for booking with status ${this.status}. Only PENDING_PAYMENT bookings can transition.` + `Cannot declare bank transfer for booking with status ${this.status}. Only QUOTE bookings can transition.` ); } @@ -325,11 +325,11 @@ export class CsvBooking { /** * Edit the cargo details of a booking before it is paid. * - * Only allowed while the booking is awaiting payment (PENDING_PAYMENT), i.e. + * Only allowed while the booking is awaiting payment (QUOTE), i.e. * before it is sent to the carrier. Carrier, route and price derive from the * selected rate and are not editable here. * - * @throws Error if the booking is not in PENDING_PAYMENT status or values are invalid + * @throws Error if the booking is not in QUOTE status or values are invalid */ editDetails(details: { volumeCBM?: number; @@ -346,9 +346,9 @@ export class CsvBooking { fobCurrency?: string; }; }): void { - if (this.status !== CsvBookingStatus.PENDING_PAYMENT) { + if (this.status !== CsvBookingStatus.QUOTE) { throw new Error( - `Cannot edit booking with status ${this.status}. Only PENDING_PAYMENT bookings can be edited.` + `Cannot edit booking with status ${this.status}. Only QUOTE bookings can be edited.` ); } @@ -399,9 +399,9 @@ export class CsvBooking { /** * Re-apply a full rate selection before payment: the user re-ran the search * and picked a rate, so carrier, route, container, transit, cargo and price - * are all replaced. Only allowed while the booking is PENDING_PAYMENT. + * are all replaced. Only allowed while the booking is QUOTE. * - * @throws Error if the booking is not PENDING_PAYMENT or values are invalid + * @throws Error if the booking is not QUOTE or values are invalid */ editFromRate(data: { carrierName: string; @@ -423,9 +423,9 @@ export class CsvBooking { notes?: string; options?: Record; }): void { - if (this.status !== CsvBookingStatus.PENDING_PAYMENT) { + if (this.status !== CsvBookingStatus.QUOTE) { throw new Error( - `Cannot edit booking with status ${this.status}. Only PENDING_PAYMENT bookings can be edited.` + `Cannot edit booking with status ${this.status}. Only QUOTE bookings can be edited.` ); } if (!data.carrierName || data.carrierName.trim().length === 0) { @@ -462,12 +462,11 @@ export class CsvBooking { } /** - * Check if booking has expired (7 days without response) - * - * @returns true if booking is older than 7 days and still pending + * Un devis : la reservation est construite mais les frais de booking ne sont + * pas regles, donc rien n'est encore parti chez le transporteur. */ - isPendingPayment(): boolean { - return this.status === CsvBookingStatus.PENDING_PAYMENT; + isQuote(): boolean { + return this.status === CsvBookingStatus.QUOTE; } isExpired(): boolean { diff --git a/apps/backend/src/domain/ports/out/shipment-counter.port.ts b/apps/backend/src/domain/ports/out/shipment-counter.port.ts index e489eb1..776f960 100644 --- a/apps/backend/src/domain/ports/out/shipment-counter.port.ts +++ b/apps/backend/src/domain/ports/out/shipment-counter.port.ts @@ -15,7 +15,7 @@ export interface ShipmentCounterPort { /** * Count only PAID shipments (fee paid / payment declared / accepted) created by - * an organization in a given year. Unpaid drafts (PENDING_PAYMENT), rejected and + * an organization in a given year. Unpaid drafts (QUOTE), rejected and * cancelled bookings are excluded. */ countPaidShipmentsForOrganizationInYear( diff --git a/apps/backend/src/infrastructure/persistence/typeorm/entities/csv-booking.orm-entity.ts b/apps/backend/src/infrastructure/persistence/typeorm/entities/csv-booking.orm-entity.ts index ee8de24..b033004 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/entities/csv-booking.orm-entity.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/csv-booking.orm-entity.ts @@ -75,24 +75,11 @@ export class CsvBookingOrmEntity { @Column({ name: 'status', type: 'enum', - enum: [ - 'PENDING_PAYMENT', - 'PENDING_BANK_TRANSFER', - 'PENDING', - 'ACCEPTED', - 'REJECTED', - 'CANCELLED', - ], - default: 'PENDING_PAYMENT', + enum: ['QUOTE', 'PENDING_BANK_TRANSFER', 'PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED'], + default: 'QUOTE', }) @Index() - status: - | 'PENDING_PAYMENT' - | 'PENDING_BANK_TRANSFER' - | 'PENDING' - | 'ACCEPTED' - | 'REJECTED' - | 'CANCELLED'; + status: 'QUOTE' | 'PENDING_BANK_TRANSFER' | 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED'; @Column({ name: 'documents', type: 'jsonb' }) documents: Array<{ diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1790000000000-RenamePendingPaymentToQuote.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1790000000000-RenamePendingPaymentToQuote.ts new file mode 100644 index 0000000..1181342 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1790000000000-RenamePendingPaymentToQuote.ts @@ -0,0 +1,77 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Migration: renomme le statut PENDING_PAYMENT en QUOTE. + * + * Le statut ne designe plus un « paiement en attente » mais un devis : une + * reservation construite dont les frais de booking ne sont pas encore regles. + * L'etape du cycle de vie est inchangee, seul son nom l'est — les lignes + * existantes sont donc converties en place. + */ +export class RenamePendingPaymentToQuote1790000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "csv_bookings" ALTER COLUMN "status" DROP DEFAULT + `); + + await queryRunner.query(` + CREATE TYPE "csv_booking_status_new" AS ENUM ( + 'QUOTE', + 'PENDING_BANK_TRANSFER', + 'PENDING', + 'ACCEPTED', + 'REJECTED', + 'CANCELLED' + ) + `); + + // PENDING_PAYMENT n'existe pas dans le nouveau type : la conversion doit + // donc reecrire la valeur pendant le changement de type, pas apres. + await queryRunner.query(` + ALTER TABLE "csv_bookings" + ALTER COLUMN "status" TYPE "csv_booking_status_new" + USING ( + CASE WHEN "status"::text = 'PENDING_PAYMENT' THEN 'QUOTE' ELSE "status"::text END + )::"csv_booking_status_new" + `); + + await queryRunner.query(`DROP TYPE "csv_booking_status"`); + await queryRunner.query(`ALTER TYPE "csv_booking_status_new" RENAME TO "csv_booking_status"`); + + await queryRunner.query(` + ALTER TABLE "csv_bookings" ALTER COLUMN "status" SET DEFAULT 'QUOTE' + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "csv_bookings" ALTER COLUMN "status" DROP DEFAULT + `); + + await queryRunner.query(` + CREATE TYPE "csv_booking_status_old" AS ENUM ( + 'PENDING_PAYMENT', + 'PENDING_BANK_TRANSFER', + 'PENDING', + 'ACCEPTED', + 'REJECTED', + 'CANCELLED' + ) + `); + + await queryRunner.query(` + ALTER TABLE "csv_bookings" + ALTER COLUMN "status" TYPE "csv_booking_status_old" + USING ( + CASE WHEN "status"::text = 'QUOTE' THEN 'PENDING_PAYMENT' ELSE "status"::text END + )::"csv_booking_status_old" + `); + + await queryRunner.query(`DROP TYPE "csv_booking_status"`); + await queryRunner.query(`ALTER TYPE "csv_booking_status_old" RENAME TO "csv_booking_status"`); + + await queryRunner.query(` + ALTER TABLE "csv_bookings" ALTER COLUMN "status" SET DEFAULT 'PENDING_PAYMENT' + `); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/repositories/shipment-counter.repository.ts b/apps/backend/src/infrastructure/persistence/typeorm/repositories/shipment-counter.repository.ts index ef99d3a..37467d2 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/repositories/shipment-counter.repository.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/repositories/shipment-counter.repository.ts @@ -38,7 +38,7 @@ export class TypeOrmShipmentCounterRepository implements ShipmentCounterPort { const startOfNextYear = new Date(year + 1, 0, 1); // "Paid" = payment completed / declared / accepted. Unpaid drafts - // (PENDING_PAYMENT), rejected and cancelled bookings do not count. + // (QUOTE), rejected and cancelled bookings do not count. const PAID_STATUSES = ['PENDING_BANK_TRANSFER', 'PENDING', 'ACCEPTED']; return this.csvBookingRepository diff --git a/apps/frontend/app/[locale]/admin/bookings/page.tsx b/apps/frontend/app/[locale]/admin/bookings/page.tsx index d47c8c9..115b4a1 100644 --- a/apps/frontend/app/[locale]/admin/bookings/page.tsx +++ b/apps/frontend/app/[locale]/admin/bookings/page.tsx @@ -95,7 +95,7 @@ export default function AdminBookingsPage() { const getStatusColor = (status: string) => { const colors: Record = { - pending_payment: 'bg-orange-100 text-orange-800', + quote: 'bg-neutral-100 text-neutral-800', pending_bank_transfer: 'bg-amber-100 text-amber-900', pending: 'bg-yellow-100 text-yellow-800', accepted: 'bg-green-100 text-green-800', @@ -108,7 +108,7 @@ export default function AdminBookingsPage() { const getStatusLabel = (status: string) => { const key = status.toUpperCase(); const allowed = [ - 'PENDING_PAYMENT', + 'QUOTE', 'PENDING_BANK_TRANSFER', 'PENDING', 'ACCEPTED', @@ -225,7 +225,7 @@ export default function AdminBookingsPage() { > - + diff --git a/apps/frontend/app/[locale]/dashboard/booking/[id]/pay/page.tsx b/apps/frontend/app/[locale]/dashboard/booking/[id]/pay/page.tsx index 008a70d..f2faa59 100644 --- a/apps/frontend/app/[locale]/dashboard/booking/[id]/pay/page.tsx +++ b/apps/frontend/app/[locale]/dashboard/booking/[id]/pay/page.tsx @@ -94,7 +94,7 @@ export default function PayCommissionPage() { setBooking(bookingData as any); if (orgData) setOrg(orgData); - if ((bookingData as any).status !== 'PENDING_PAYMENT') { + if ((bookingData as any).status !== 'QUOTE') { router.replace('/dashboard/bookings'); } } catch (err) { @@ -123,7 +123,7 @@ export default function PayCommissionPage() { setError(null); try { await declareBankTransfer(bookingId); - router.push('/dashboard/bookings?transfer=declared'); + router.push('/dashboard/quotes?transfer=declared'); } catch (err) { setError(err instanceof Error ? err.message : 'Erreur lors de la déclaration du virement'); setDeclaring(false); diff --git a/apps/frontend/app/[locale]/dashboard/bookings/[id]/page.tsx b/apps/frontend/app/[locale]/dashboard/bookings/[id]/page.tsx index 717f55f..1c20fbc 100644 --- a/apps/frontend/app/[locale]/dashboard/bookings/[id]/page.tsx +++ b/apps/frontend/app/[locale]/dashboard/bookings/[id]/page.tsx @@ -84,7 +84,7 @@ export default function BookingDetailPage() { ); } - const unpaid = booking.status === 'PENDING_PAYMENT'; + const unpaid = booking.status === 'QUOTE'; const price = booking.priceEUR ? `${booking.priceEUR} €` : booking.priceUSD @@ -113,7 +113,7 @@ export default function BookingDetailPage() { {unpaid && ( - {tList('detail.pendingPaymentNotice')} + {tList('detail.quoteNotice')} )} diff --git a/apps/frontend/app/[locale]/dashboard/bookings/page.tsx b/apps/frontend/app/[locale]/dashboard/bookings/page.tsx index 4cdfd08..246a71f 100644 --- a/apps/frontend/app/[locale]/dashboard/bookings/page.tsx +++ b/apps/frontend/app/[locale]/dashboard/bookings/page.tsx @@ -1,549 +1,22 @@ 'use client'; -import { useEffect, useMemo, useState } from 'react'; -import { useQuery } from '@tanstack/react-query'; -import { useSearchParams } from 'next/navigation'; -import { useLocale, useTranslations } from 'next-intl'; -import type { ColumnDef } from '@tanstack/react-table'; -import { - CreditCard, - Eye, - MoreVertical, - Package, - Pencil, - Plus, - Sparkles, - Trash2, - X, -} from 'lucide-react'; +import { Package } from 'lucide-react'; -import { deleteCsvBooking, listCsvBookings } from '@/lib/api'; -import { Link } from '@/i18n/navigation'; -import { PageHeader } from '@/components/ui/PageHeader'; -import { Button } from '@/components/ui/button'; -import { Callout } from '@/components/ui/callout'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { DataTable } from '@/components/data/data-table'; -import { FilterBar, type ActiveFilter } from '@/components/data/filter-bar'; -import { SearchInput } from '@/components/data/search-input'; -import { BookingDetailDialog } from '@/components/bookings/BookingDetailDialog'; -import { - BookingStatusBadge, - useBookingStatusLabel, -} from '@/components/bookings/BookingStatusBadge'; -import { useConfirm } from '@/components/ui/use-confirm'; -import { useToast } from '@/components/ui/toast'; -import ExportButton from '@/components/ExportButton'; -import { useReservationQuota } from '@/hooks/useReservationQuota'; -import { useUrlState } from '@/hooks/use-url-state'; +import { BookingListView } from '@/components/bookings/BookingListView'; -type SearchType = 'pallets' | 'weight' | 'route' | 'status' | 'date' | 'quote'; - -// Statuts consideres comme « non finalises » : reservation commencee, non payee. -const TO_FINALIZE_STATUSES = ['PENDING_PAYMENT', 'PENDING_BANK_TRANSFER']; -const ITEMS_PER_PAGE = 20; +/** + * Reservations engagees : les frais de booking sont regles et la demande est + * partie chez le transporteur. Les devis non regles vivent dans l'onglet + * « Historique des devis ». + */ +const BOOKING_STATUSES = ['PENDING', 'ACCEPTED', 'REJECTED']; export default function BookingsListPage() { - const t = useTranslations('dashboard.bookingsList'); - const tCommon = useTranslations('common'); - const tErrors = useTranslations('errors'); - const locale = useLocale(); - const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US'; - - const searchParams = useSearchParams(); - const url = useUrlState(); - const statusLabel = useBookingStatusLabel(); - const confirm = useConfirm(); - const { toast } = useToast(); - - // L'etat de vue vit dans l'URL : la liste est partageable et survit au - // rechargement, ce qui n'etait pas le cas avec un etat purement local. - const searchTerm = url.get('q') ?? ''; - const searchType = (url.get('by') as SearchType) ?? 'route'; - const statusFilter = url.get('status') ?? ''; - const page = url.getNumber('page', 1); - - const [showTransferBanner, setShowTransferBanner] = useState(false); - const [selectedBooking, setSelectedBooking] = useState(null); - - useEffect(() => { - if (searchParams.get('transfer') === 'declared') setShowTransferBanner(true); - }, [searchParams]); - - const { - data: csvData, - isLoading, - error: csvError, - refetch, - } = useQuery({ - queryKey: ['csv-bookings'], - queryFn: () => listCsvBookings({ page: 1, limit: 1000 }), - }); - - const quota = useReservationQuota(); - - /** - * Supprime une reservation impayee, apres confirmation. - * - * L'action est destructrice et sans annulation : la confirmation nomme la - * reservation concernee plutot que de demander « etes-vous sur ? ». - */ - const remove = async (b: any) => { - const reference = b.bookingNumber || b.quoteNumber || `${b.origin} → ${b.destination}`; - const confirmed = await confirm({ - title: t('deleteConfirm.title'), - description: t('deleteConfirm.body', { reference }), - confirmLabel: t('actions.delete'), - destructive: true, - }); - if (!confirmed) return; - - try { - await deleteCsvBooking(b.id); - toast.success(t('deleteConfirm.done')); - await refetch(); - } catch { - toast.error(t('deleteConfirm.failed')); - } - }; - - // « Modifier » rouvre le parcours existant (recherche → transporteur → - // paiement) pre-rempli, afin qu'un changement redonne lieu a une cotation. - const buildEditUrl = (b: any) => { - const enabledOptions = Object.keys(b.options || {}) - .filter(k => b.options[k]) - .join(','); - const params = new URLSearchParams({ - editBookingId: b.id, - origin: b.origin || b.originCity || '', - destination: b.destination || b.destinationCity || '', - volumeCBM: String(b.volumeCBM ?? ''), - weightKG: String(b.weightKG ?? ''), - palletCount: String(b.palletCount ?? ''), - hasDangerousGoods: String(!!(b.options && b.options.dangerousGoods)), - }); - if (enabledOptions) params.set('options', enabledOptions); - return `/dashboard/search-advanced?${params.toString()}`; - }; - - const statusOptions = [ - { value: 'all', label: t('statusFilter.all') }, - { value: 'to_finalize', label: t('statusFilter.toFinalize') }, - { value: 'PENDING_PAYMENT', label: t('status.pendingPayment') }, - { value: 'PENDING_BANK_TRANSFER', label: t('status.pendingBankTransfer') }, - { value: 'PENDING', label: t('status.pending') }, - { value: 'ACCEPTED', label: t('status.accepted') }, - { value: 'REJECTED', label: t('status.rejected') }, - { value: 'CANCELLED', label: t('status.cancelled') }, - ]; - - const searchTypeOptions: { value: SearchType; label: string }[] = [ - { value: 'route', label: t('searchType.route') }, - { value: 'pallets', label: t('searchType.pallets') }, - { value: 'weight', label: t('searchType.weight') }, - { value: 'status', label: t('searchType.status') }, - { value: 'date', label: t('searchType.date') }, - { value: 'quote', label: t('searchType.quote') }, - ]; - - const placeholder = t(`searchPlaceholder.${searchType}` as any); - - const filteredBookings = useMemo(() => { - let filtered = (csvData?.bookings || []).map(b => ({ ...b, type: 'csv' as const })); - - if (statusFilter) { - filtered = - statusFilter === 'to_finalize' - ? filtered.filter((b: any) => TO_FINALIZE_STATUSES.includes(b.status)) - : filtered.filter((b: any) => b.status === statusFilter); - } - - const term = searchTerm.trim().toLowerCase(); - if (term) { - filtered = filtered.filter((booking: any) => { - switch (searchType) { - case 'pallets': - return booking.palletCount?.toString().includes(term); - case 'weight': - return booking.weightKG?.toString().includes(term); - case 'route': { - const origin = booking.originCity?.toLowerCase() || ''; - const destination = booking.destinationCity?.toLowerCase() || ''; - return origin.includes(term) || destination.includes(term); - } - case 'status': - return booking.status?.toLowerCase().includes(term); - case 'date': - return new Date(booking.requestedPickupDate || booking.requestedAt) - .toLocaleDateString(dateLocale) - .includes(term); - case 'quote': - // `bookingNumber` est le numero affiche en premiere colonne : sans - // lui, rechercher le numero qu'on a sous les yeux ne donnait rien. - return ( - booking.bookingNumber?.toLowerCase().includes(term) || - booking.id?.toLowerCase().includes(term) || - booking.quoteNumber?.toLowerCase().includes(term) - ); - default: - return true; - } - }); - } - - return filtered; - }, [csvData, statusFilter, searchTerm, searchType, dateLocale]); - - const totalBookings = filteredBookings.length; - const pageCount = Math.max(1, Math.ceil(totalBookings / ITEMS_PER_PAGE)); - const safePage = Math.min(page, pageCount); - const startIndex = (safePage - 1) * ITEMS_PER_PAGE; - const paginatedBookings = filteredBookings.slice(startIndex, startIndex + ITEMS_PER_PAGE); - - const shortDate = (value?: string) => - value - ? new Date(value).toLocaleDateString(dateLocale, { - day: '2-digit', - month: '2-digit', - year: 'numeric', - }) - : 'N/A'; - - const columns = useMemo[]>( - () => [ - { - id: 'reference', - header: t('columns.reference'), - enableSorting: false, - meta: { cardRole: 'title', headerLabel: t('columns.reference') }, - cell: ({ row }) => { - const b = row.original; - const fallback = `#${b.bookingId || b.id.slice(0, 8).toUpperCase()}`; - return ( -
-

{b.bookingNumber || fallback}

- {b.bookingNumber && ( -

{fallback}

- )} -
- ); - }, - }, - { - id: 'route', - header: t('columns.route'), - enableSorting: false, - meta: { cardRole: 'subtitle', headerLabel: t('columns.route') }, - cell: ({ row }) => { - const b = row.original; - return ( -
-

- {b.origin} → {b.destination} -

-

{b.carrierName}

-
- ); - }, - }, - { - id: 'cargo', - header: t('columns.cargo'), - enableSorting: false, - meta: { hideBelow: 'lg', cardRole: 'field', headerLabel: t('columns.cargo') }, - cell: ({ row }) => { - const b = row.original; - return ( -
-

- {t('units.palletsCount', { count: b.palletCount })} · LCL -

-

- {t('units.kg', { value: b.weightKG })} · {t('units.cbm', { value: b.volumeCBM })} -

-
- ); - }, - }, - { - id: 'status', - header: t('columns.status'), - enableSorting: false, - meta: { cardRole: 'field', headerLabel: t('columns.status') }, - cell: ({ row }) => , - }, - { - id: 'date', - header: t('columns.date'), - enableSorting: false, - meta: { hideBelow: 'lg', cardRole: 'field', headerLabel: t('columns.date') }, - cell: ({ row }) => ( - - {shortDate(row.original.createdAt || row.original.requestedAt)} - - ), - }, - { - id: 'actions', - header: t('columns.actions'), - size: 64, - enableSorting: false, - meta: { align: 'right', cardRole: 'actions' }, - cell: ({ row }) => { - const b = row.original; - return ( - - - - - - setSelectedBooking(b)}> - - {t('actions.view')} - - - {b.status === 'PENDING_PAYMENT' && ( - <> - - - - {t('actions.edit')} - - - - - - {t('actions.pay')} - - - {/* Suppression reservee aux reservations impayees : une fois - la commission reglee, la reservation est partie chez le - transporteur et ne peut plus qu'etre annulee. */} - remove(b)} - className="text-destructive focus:text-destructive" - > - - {t('actions.delete')} - - - )} - - - ); - }, - }, - ], - // eslint-disable-next-line react-hooks/exhaustive-deps - [t, dateLocale] - ); - - const activeFilters: ActiveFilter[] = []; - if (statusFilter) { - activeFilters.push({ - key: 'status', - label: t('statusFilter.label'), - value: statusOptions.find(o => o.value === statusFilter)?.label ?? statusFilter, - onRemove: () => url.set('status', undefined), - }); - } - if (searchTerm) { - activeFilters.push({ - key: 'q', - label: searchTypeOptions.find(o => o.value === searchType)?.label ?? t('search'), - value: searchTerm, - onRemove: () => url.set('q', undefined), - }); - } - - const newReservationAction = quota.blocked ? ( - - ) : ( - - ); - return ( - <> - - `${v || 0}` }, - { key: 'weightKG', label: t('export.weight'), format: v => `${v || 0}` }, - { key: 'volumeCBM', label: t('export.volume'), format: v => `${v || 0}` }, - { key: 'origin', label: t('export.origin') }, - { key: 'destination', label: t('export.destination') }, - { key: 'carrierName', label: t('export.carrier') }, - { key: 'status', label: t('export.status'), format: v => statusLabel(v) }, - { - key: 'createdAt', - label: t('export.createdAt'), - format: v => (v ? new Date(v).toLocaleDateString(dateLocale) : ''), - }, - ]} - /> - {newReservationAction} - - } - /> - -
- {showTransferBanner && ( - -
-

{t('transferBanner.message')}

- -
-
- )} - - {quota.blocked && ( - -

{t('limit.message', { max: quota.max })}

- -
- )} - - url.clear()} - labels={{ - filters: tCommon('filters'), - clearAll: tCommon('clearFilters'), - remove: tCommon('removeFilter'), - }} - filters={ - <> - - - - - } - > - url.set('q', value || undefined)} - placeholder={placeholder} - ariaLabel={t('search')} - /> - - - refetch(), - } - : null - } - empty={{ - icon: Package, - title: t('empty.title'), - description: searchTerm || statusFilter ? t('empty.hasFilters') : t('empty.noBookings'), - action: newReservationAction, - }} - caption={t('title')} - pagination={{ - page: safePage, - pageCount, - onPageChange: next => url.set('page', next > 1 ? next : undefined), - totalItems: totalBookings, - pageSize: ITEMS_PER_PAGE, - labels: { - previous: t('pagination.previous'), - next: t('pagination.next'), - showing: (from, to, total) => - t.rich('pagination.showing', { - start: from, - end: to, - total, - b: chunks => {chunks}, - }), - }, - }} - /> -
- - !open && setSelectedBooking(null)} - buildEditUrl={buildEditUrl} - /> - + ); } diff --git a/apps/frontend/app/[locale]/dashboard/documents/page.tsx b/apps/frontend/app/[locale]/dashboard/documents/page.tsx index 008d87d..ad5ac53 100644 --- a/apps/frontend/app/[locale]/dashboard/documents/page.tsx +++ b/apps/frontend/app/[locale]/dashboard/documents/page.tsx @@ -56,12 +56,12 @@ interface DocumentWithBooking { const DOCUMENTABLE_STATUSES = [ 'PENDING', 'ACCEPTED', - 'PENDING_PAYMENT', + 'QUOTE', 'PENDING_BANK_TRANSFER', ]; const STATUS_KEYS = [ - 'PENDING_PAYMENT', + 'QUOTE', 'PENDING_BANK_TRANSFER', 'PENDING', 'ACCEPTED', diff --git a/apps/frontend/app/[locale]/dashboard/quotes/page.tsx b/apps/frontend/app/[locale]/dashboard/quotes/page.tsx new file mode 100644 index 0000000..51d6087 --- /dev/null +++ b/apps/frontend/app/[locale]/dashboard/quotes/page.tsx @@ -0,0 +1,24 @@ +'use client'; + +import { FileClock } from 'lucide-react'; + +import { BookingListView } from '@/components/bookings/BookingListView'; + +/** + * Historique des devis : tout ce qui n'est pas une reservation engagee chez le + * transporteur — devis non regles, virements en attente de validation, et + * demandes annulees. + */ +const QUOTE_STATUSES = ['QUOTE', 'PENDING_BANK_TRANSFER', 'CANCELLED']; + +export default function QuotesHistoryPage() { + return ( + + ); +} diff --git a/apps/frontend/messages/en.json b/apps/frontend/messages/en.json index a86404a..f1c1c8d 100644 --- a/apps/frontend/messages/en.json +++ b/apps/frontend/messages/en.json @@ -412,6 +412,7 @@ "nav": { "dashboard": "Dashboard", "bookings": "Bookings", + "quotes": "Quote history", "documents": "Documents", "tracking": "Tracking", "wiki": "Maritime Wiki", @@ -518,10 +519,8 @@ }, "empty": "Nothing to report", "items": { - "awaitingCarrier": "No carrier response", + "awaitingCarrier": "Awaiting carrier", "awaitingCarrierSince": "Waiting {days} days on {carrier}", - "awaitingPayment": "Payment pending", - "awaitingPaymentSince": "Blocked for {days} days awaiting payment", "rejected": "Rejected by carrier", "rejectedBy": "{carrier} declined the request — needs rebooking" } @@ -574,11 +573,10 @@ }, "statusFilter": { "label": "Status", - "all": "All statuses", - "toFinalize": "To finalize" + "all": "All statuses" }, "status": { - "pendingPayment": "Payment pending", + "quote": "Quote", "pendingBankTransfer": "Transfer pending", "pending": "Pending", "accepted": "Accepted", @@ -619,10 +617,10 @@ "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" + "close": "Close", + "quoteNotice": "This quote is awaiting the booking fee payment. Click below to complete it." }, "mobile": { "pallets": "Pallets", @@ -650,6 +648,128 @@ "showing": "Showing {start} to {end} of {total} {total, plural, one {result} other {results}}" } }, + "quotesList": { + "title": "Quote history", + "description": "Your requests not yet committed with a carrier", + "new": "New Booking", + "limit": { + "title": "Reservation limit reached", + "message": "Your free plan is limited to {max} reservations per year. Upgrade your plan to keep booking.", + "upgrade": "Upgrade my plan", + "upgradeShort": "Upgrade", + "back": "Back to bookings" + }, + "transferBanner": { + "title": "Transfer declared", + "message": "Your transfer has been recorded. An administrator will verify receipt and activate your booking. You will be notified once validated." + }, + "exportFilename": "quotes", + "export": { + "id": "ID", + "pallets": "Pallets", + "weight": "Weight (kg)", + "volume": "Volume (CBM)", + "origin": "Origin", + "destination": "Destination", + "carrier": "Carrier", + "status": "Status", + "createdAt": "Created at" + }, + "searchType": { + "label": "Search type", + "route": "Route (Origin/Destination)", + "pallets": "Pallets/Packages", + "weight": "Weight (kg)", + "status": "Status", + "date": "Date", + "quote": "Quote No." + }, + "searchPlaceholder": { + "route": "Search by city (origin or destination)...", + "pallets": "Search by pallet count...", + "weight": "Search by weight in kg...", + "status": "Search by status...", + "date": "Search by date (DD/MM/YYYY)...", + "quote": "Search by quote number...", + "default": "Search..." + }, + "statusFilter": { + "label": "Status", + "all": "All statuses" + }, + "status": { + "quote": "Quote", + "pendingBankTransfer": "Transfer pending", + "pending": "Pending", + "accepted": "Accepted", + "rejected": "Rejected", + "cancelled": "Cancelled" + }, + "loading": "Loading bookings...", + "search": "Search", + "columns": { + "palletsPackages": "Pallets/Packages", + "weight": "Weight", + "route": "Route", + "cargo": "Cargo", + "reference": "Reference", + "status": "Status", + "date": "Date", + "quoteNumber": "Quote No.", + "bookingNumber": "Booking No.", + "actions": "Actions" + }, + "actions": { + "view": "View", + "pay": "Pay", + "edit": "Edit", + "delete": "Delete" + }, + "deleteConfirm": { + "title": "Delete this quote?", + "body": "{reference} will be permanently deleted. This cannot be undone.", + "done": "Quote deleted", + "failed": "The quote could not be deleted" + }, + "detail": { + "title": "Quote", + "route": "Route", + "date": "Creation date", + "cargo": "Cargo", + "weight": "Weight / Volume", + "pickupDate": "Requested pickup date", + "price": "Price", + "payNow": "Complete payment", + "newBooking": "New booking", + "close": "Close", + "quoteNotice": "This quote is awaiting the booking fee payment. Click below to complete it." + }, + "mobile": { + "pallets": "Pallets", + "weight": "Weight", + "date": "Date", + "ref": "Ref: #{id}", + "booking": "Booking: {number}" + }, + "units": { + "palletsCount": "{count, plural, one {# pallet} other {# pallets}}", + "containersCount": "{count, plural, one {# container} other {# containers}}", + "palletsShort": "{count} pal.", + "containersShort": "{count} cont.", + "kg": "{value} kg", + "cbm": "{value} CBM" + }, + "empty": { + "title": "No quotes found", + "hasFilters": "Try adjusting your filters", + "noBookings": "Your unfinalized quotes will appear here" + }, + "pagination": { + "previous": "Previous", + "next": "Next", + "showing": "Showing {start} to {end} of {total} {total, plural, one {result} other {results}}" + } + }, "rateSearch": { "title": "Advanced Rate Search", "subtitle": "Complete form with all transport options", @@ -1119,7 +1239,7 @@ "all": "All statuses" }, "status": { - "PENDING_PAYMENT": "Payment pending", + "QUOTE": "Quote", "PENDING_BANK_TRANSFER": "Transfer to validate", "PENDING": "Awaiting carrier", "ACCEPTED": "Accepted", @@ -1613,7 +1733,7 @@ "actions": "Actions" }, "statuses": { - "PENDING_PAYMENT": "Payment pending", + "QUOTE": "Quote", "PENDING_BANK_TRANSFER": "Bank transfer pending", "PENDING": "Pending", "ACCEPTED": "Accepted", diff --git a/apps/frontend/messages/fr.json b/apps/frontend/messages/fr.json index 60f79fa..1b05670 100644 --- a/apps/frontend/messages/fr.json +++ b/apps/frontend/messages/fr.json @@ -412,6 +412,7 @@ "nav": { "dashboard": "Tableau de bord", "bookings": "Réservations", + "quotes": "Historique des devis", "documents": "Documents", "tracking": "Suivi", "wiki": "Wiki Maritime", @@ -518,10 +519,8 @@ }, "empty": "Rien à signaler", "items": { - "awaitingCarrier": "Sans réponse du transporteur", + "awaitingCarrier": "En attente du transporteur", "awaitingCarrierSince": "En attente depuis {days} jours chez {carrier}", - "awaitingPayment": "Paiement en attente", - "awaitingPaymentSince": "Bloquée depuis {days} jours faute de règlement", "rejected": "Refusée par le transporteur", "rejectedBy": "{carrier} a refusé la demande — à replacer" } @@ -574,11 +573,10 @@ }, "statusFilter": { "label": "Statut", - "all": "Tous les statuts", - "toFinalize": "À finaliser" + "all": "Tous les statuts" }, "status": { - "pendingPayment": "Paiement en attente", + "quote": "Devis", "pendingBankTransfer": "Virement en attente", "pending": "En attente", "accepted": "Accepté", @@ -619,10 +617,10 @@ "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" + "close": "Fermer", + "quoteNotice": "Ce devis est en attente du règlement des frais de booking. Cliquez ci-dessous pour le finaliser." }, "mobile": { "pallets": "Palettes", @@ -650,6 +648,128 @@ "showing": "Affichage de {start} à {end} sur {total} {total, plural, one {résultat} other {résultats}}" } }, + "quotesList": { + "title": "Historique des devis", + "description": "Vos demandes non engagées chez un transporteur", + "new": "Nouvelle Réservation", + "limit": { + "title": "Limite de réservations atteinte", + "message": "Votre abonnement gratuit est limité à {max} réservations par an. Passez à un abonnement supérieur pour continuer à réserver.", + "upgrade": "Améliorer mon abonnement", + "upgradeShort": "Améliorer", + "back": "Retour aux réservations" + }, + "transferBanner": { + "title": "Virement déclaré", + "message": "Votre virement a été enregistré. Un administrateur va vérifier la réception et activer votre booking. Vous serez notifié dès la validation." + }, + "exportFilename": "devis", + "export": { + "id": "ID", + "pallets": "Palettes", + "weight": "Poids (kg)", + "volume": "Volume (CBM)", + "origin": "Origine", + "destination": "Destination", + "carrier": "Transporteur", + "status": "Statut", + "createdAt": "Date de création" + }, + "searchType": { + "label": "Type de recherche", + "route": "Route (Origine/Destination)", + "pallets": "Palettes/Colis", + "weight": "Poids (kg)", + "status": "Statut", + "date": "Date", + "quote": "N° Devis" + }, + "searchPlaceholder": { + "route": "Rechercher par ville (origine ou destination)...", + "pallets": "Rechercher par nombre de palettes...", + "weight": "Rechercher par poids en kg...", + "status": "Rechercher par statut...", + "date": "Rechercher par date (JJ/MM/AAAA)...", + "quote": "Rechercher par numéro de devis...", + "default": "Rechercher..." + }, + "statusFilter": { + "label": "Statut", + "all": "Tous les statuts" + }, + "status": { + "quote": "Devis", + "pendingBankTransfer": "Virement en attente", + "pending": "En attente", + "accepted": "Accepté", + "rejected": "Refusé", + "cancelled": "Annulé" + }, + "loading": "Chargement des réservations...", + "search": "Rechercher", + "columns": { + "palletsPackages": "Palettes/Colis", + "weight": "Poids", + "route": "Route", + "cargo": "Marchandise", + "reference": "Référence", + "status": "Statut", + "date": "Date", + "quoteNumber": "N° Devis", + "bookingNumber": "N° Booking", + "actions": "Actions" + }, + "actions": { + "view": "Voir", + "pay": "Payer", + "edit": "Modifier", + "delete": "Supprimer" + }, + "deleteConfirm": { + "title": "Supprimer ce devis ?", + "body": "{reference} sera définitivement supprimé. Cette action est irréversible.", + "done": "Devis supprimé", + "failed": "Le devis n’a pas pu être supprimé" + }, + "detail": { + "title": "Devis", + "route": "Route", + "date": "Date de création", + "cargo": "Marchandise", + "weight": "Poids / Volume", + "pickupDate": "Date d'enlèvement souhaitée", + "price": "Prix", + "payNow": "Finaliser le paiement", + "newBooking": "Nouvelle réservation", + "close": "Fermer", + "quoteNotice": "Ce devis est en attente du règlement des frais de booking. Cliquez ci-dessous pour le finaliser." + }, + "mobile": { + "pallets": "Palettes", + "weight": "Poids", + "date": "Date", + "ref": "Réf: #{id}", + "booking": "Booking: {number}" + }, + "units": { + "palletsCount": "{count, plural, one {# palette} other {# palettes}}", + "containersCount": "{count, plural, one {# conteneur} other {# conteneurs}}", + "palletsShort": "{count} pal.", + "containersShort": "{count} cont.", + "kg": "{value} kg", + "cbm": "{value} CBM" + }, + "empty": { + "title": "Aucun devis trouvé", + "hasFilters": "Essayez d'ajuster vos filtres", + "noBookings": "Vos devis non finalisés apparaîtront ici" + }, + "pagination": { + "previous": "Précédent", + "next": "Suivant", + "showing": "Affichage de {start} à {end} sur {total} {total, plural, one {résultat} other {résultats}}" + } + }, "rateSearch": { "title": "Recherche Avancée de Tarifs", "subtitle": "Formulaire complet avec toutes les options de transport", @@ -1119,7 +1239,7 @@ "all": "Tous les statuts" }, "status": { - "PENDING_PAYMENT": "Paiement en attente", + "QUOTE": "Devis", "PENDING_BANK_TRANSFER": "Virement à valider", "PENDING": "En attente transporteur", "ACCEPTED": "Accepté", @@ -1613,7 +1733,7 @@ "actions": "Actions" }, "statuses": { - "PENDING_PAYMENT": "Paiement en attente", + "QUOTE": "Devis", "PENDING_BANK_TRANSFER": "Virement en attente", "PENDING": "En attente", "ACCEPTED": "Accepté", diff --git a/apps/frontend/src/components/bookings/BookingDetailDialog.tsx b/apps/frontend/src/components/bookings/BookingDetailDialog.tsx index 15bda3b..257314c 100644 --- a/apps/frontend/src/components/bookings/BookingDetailDialog.tsx +++ b/apps/frontend/src/components/bookings/BookingDetailDialog.tsx @@ -24,14 +24,17 @@ interface BookingDetailDialogProps { booking: any | null; onOpenChange: (open: boolean) => void; buildEditUrl: (booking: any) => string; + /** Namespace de la vue appelante, pour que le titre suive l'onglet. */ + namespace?: string; } export function BookingDetailDialog({ booking, onOpenChange, buildEditUrl, + namespace = 'dashboard.bookingsList', }: BookingDetailDialogProps) { - const t = useTranslations('dashboard.bookingsList'); + const t = useTranslations(namespace as any); const locale = useLocale(); const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US'; @@ -100,8 +103,8 @@ export function BookingDetailDialog({ )} - {booking.status === 'PENDING_PAYMENT' && ( - {t('detail.pendingPaymentNotice')} + {booking.status === 'QUOTE' && ( + {t('detail.quoteNotice')} )} @@ -110,7 +113,7 @@ export function BookingDetailDialog({ {t('detail.close')} - {booking.status === 'PENDING_PAYMENT' && ( + {booking.status === 'QUOTE' && ( <> + + + setSelectedBooking(b)}> + + {t('actions.view')} + + + {quoteActions && b.status === 'QUOTE' && ( + <> + + + + {t('actions.edit')} + + + + + + {t('actions.pay')} + + + {/* Suppression reservee aux devis non regles : une fois les + frais de booking payes, la reservation est partie chez le + transporteur et ne peut plus qu'etre annulee. */} + remove(b)} + className="text-destructive focus:text-destructive" + > + + {t('actions.delete')} + + + )} + + + ); + }, + }, + ], + // eslint-disable-next-line react-hooks/exhaustive-deps + [t, dateLocale, quoteActions] + ); + + const activeFilters: ActiveFilter[] = []; + if (statusFilter) { + activeFilters.push({ + key: 'status', + label: t('statusFilter.label'), + value: statusOptions.find(o => o.value === statusFilter)?.label ?? statusFilter, + onRemove: () => url.set('status', undefined), + }); + } + if (searchTerm) { + activeFilters.push({ + key: 'q', + label: searchTypeOptions.find(o => o.value === searchType)?.label ?? t('search'), + value: searchTerm, + onRemove: () => url.set('q', undefined), + }); + } + + const newReservationAction = quota.blocked ? ( + + ) : ( + + ); + + return ( + <> + + `${v || 0}` }, + { key: 'weightKG', label: t('export.weight'), format: v => `${v || 0}` }, + { key: 'volumeCBM', label: t('export.volume'), format: v => `${v || 0}` }, + { key: 'origin', label: t('export.origin') }, + { key: 'destination', label: t('export.destination') }, + { key: 'carrierName', label: t('export.carrier') }, + { key: 'status', label: t('export.status'), format: v => statusLabel(v) }, + { + key: 'createdAt', + label: t('export.createdAt'), + format: v => (v ? new Date(v).toLocaleDateString(dateLocale) : ''), + }, + ]} + /> + {newReservationAction} + + } + /> + +
+ {showTransferBanner && ( + +
+

{t('transferBanner.message')}

+ +
+
+ )} + + {quota.blocked && ( + +

{t('limit.message', { max: quota.max })}

+ +
+ )} + + url.clear()} + labels={{ + filters: tCommon('filters'), + clearAll: tCommon('clearFilters'), + remove: tCommon('removeFilter'), + }} + filters={ + <> + + + + + } + > + url.set('q', value || undefined)} + placeholder={placeholder} + ariaLabel={t('search')} + /> + + + refetch(), + } + : null + } + empty={{ + icon: emptyIcon, + title: t('empty.title'), + description: searchTerm || statusFilter ? t('empty.hasFilters') : t('empty.noBookings'), + action: newReservationAction, + }} + caption={t('title')} + pagination={{ + page: safePage, + pageCount, + onPageChange: next => url.set('page', next > 1 ? next : undefined), + totalItems: totalBookings, + pageSize: ITEMS_PER_PAGE, + labels: { + previous: t('pagination.previous'), + next: t('pagination.next'), + showing: (from, to, total) => + t.rich('pagination.showing', { + start: from, + end: to, + total, + b: chunks => {chunks}, + }), + }, + }} + /> +
+ + !open && setSelectedBooking(null)} + buildEditUrl={buildEditUrl} + namespace={namespace} + /> + + ); +} diff --git a/apps/frontend/src/components/bookings/BookingStatusBadge.tsx b/apps/frontend/src/components/bookings/BookingStatusBadge.tsx index 428d921..c81c6a9 100644 --- a/apps/frontend/src/components/bookings/BookingStatusBadge.tsx +++ b/apps/frontend/src/components/bookings/BookingStatusBadge.tsx @@ -10,7 +10,7 @@ import { Badge, type BadgeProps } from '@/components/ui/badge'; * dupliquee sous forme de tables de classes Tailwind dans plusieurs pages. */ export const BOOKING_STATUS_VARIANT: Record> = { - PENDING_PAYMENT: 'warning', + QUOTE: 'neutral', PENDING_BANK_TRANSFER: 'info', PENDING: 'warning', ACCEPTED: 'success', @@ -18,8 +18,8 @@ export const BOOKING_STATUS_VARIANT: Record = { - PENDING_PAYMENT: 'pendingPayment', +export const BOOKING_STATUS_KEY: Record = { + QUOTE: 'quote', PENDING_BANK_TRANSFER: 'pendingBankTransfer', PENDING: 'pending', ACCEPTED: 'accepted', @@ -30,7 +30,7 @@ const STATUS_KEY: Record = { export function useBookingStatusLabel() { const t = useTranslations('dashboard.bookingsList'); return (status: string) => { - const key = STATUS_KEY[status]; + const key = BOOKING_STATUS_KEY[status]; return key ? t(`status.${key}` as any) : status; }; } diff --git a/apps/frontend/src/components/shell/nav-config.ts b/apps/frontend/src/components/shell/nav-config.ts index c8a3a79..9b6d3c1 100644 --- a/apps/frontend/src/components/shell/nav-config.ts +++ b/apps/frontend/src/components/shell/nav-config.ts @@ -3,6 +3,7 @@ import { Bot, BookOpen, Building2, + FileClock, FileText, Key, Newspaper, @@ -64,6 +65,12 @@ export function buildAppNav({ t, tShell, role }: AppNavParams): NavGroup[] { requiredFeature: 'dashboard', }, { key: 'bookings', label: t('nav.bookings'), href: '/dashboard/bookings', icon: Package }, + { + key: 'quotes', + label: t('nav.quotes'), + href: '/dashboard/quotes', + icon: FileClock, + }, { key: 'documents', label: t('nav.documents'), diff --git a/apps/frontend/src/lib/api/bookings.ts b/apps/frontend/src/lib/api/bookings.ts index 471c151..a701641 100644 --- a/apps/frontend/src/lib/api/bookings.ts +++ b/apps/frontend/src/lib/api/bookings.ts @@ -54,7 +54,7 @@ export interface CsvBookingResponse { transitDays: number; containerType: string; status: - | 'PENDING_PAYMENT' + | 'QUOTE' | 'PENDING_BANK_TRANSFER' | 'PENDING' | 'ACCEPTED'