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'