feat: ajouter l'onglet Historique des devis et remplacer le statut Paiement en attente
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NZJ5aowZJVAcyPZw5TiNEm
This commit is contained in:
parent
8446f879b6
commit
76d940d73b
@ -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' })
|
@ApiResponse({ status: 404, description: 'Booking not found' })
|
||||||
async payCommission(@Param('id') id: string, @Request() req: any) {
|
async payCommission(@Param('id') id: string, @Request() req: any) {
|
||||||
const userId = req.user.id;
|
const userId = req.user.id;
|
||||||
@ -520,7 +520,7 @@ export class CsvBookingsController {
|
|||||||
description: 'Bank transfer declared, booking awaiting admin validation',
|
description: 'Bank transfer declared, booking awaiting admin validation',
|
||||||
type: CsvBookingResponseDto,
|
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' })
|
@ApiResponse({ status: 404, description: 'Booking not found' })
|
||||||
async declareTransfer(
|
async declareTransfer(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@ -628,7 +628,7 @@ export class CsvBookingsController {
|
|||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Update booking details before payment',
|
summary: 'Update booking details before payment',
|
||||||
description:
|
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)' })
|
@ApiParam({ name: 'id', description: 'Booking ID (UUID)' })
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
@ -659,7 +659,7 @@ export class CsvBookingsController {
|
|||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Update booking rate/route before payment',
|
summary: 'Update booking rate/route before payment',
|
||||||
description:
|
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)' })
|
@ApiParam({ name: 'id', description: 'Booking ID (UUID)' })
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
|
|||||||
@ -265,7 +265,7 @@ export class UpdateCsvBookingDetailsDto {
|
|||||||
*
|
*
|
||||||
* Full re-selection of a rate before payment (carrier + route + container +
|
* 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
|
* 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 {
|
export class UpdateCsvBookingRateDto {
|
||||||
@ApiProperty({ example: 'SSC Consolidation' })
|
@ApiProperty({ example: 'SSC Consolidation' })
|
||||||
@ -526,8 +526,8 @@ export class CsvBookingResponseDto {
|
|||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'Booking status',
|
description: 'Booking status',
|
||||||
enum: ['PENDING_PAYMENT', 'PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED'],
|
enum: ['QUOTE', 'PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED'],
|
||||||
example: 'PENDING_PAYMENT',
|
example: 'QUOTE',
|
||||||
})
|
})
|
||||||
status: string;
|
status: string;
|
||||||
|
|
||||||
@ -689,10 +689,10 @@ export class CsvBookingListResponseDto {
|
|||||||
*/
|
*/
|
||||||
export class CsvBookingStatsDto {
|
export class CsvBookingStatsDto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'Number of bookings awaiting payment',
|
description: 'Number of quotes (bookings whose booking fee is unpaid)',
|
||||||
example: 1,
|
example: 1,
|
||||||
})
|
})
|
||||||
pendingPayment: number;
|
quote: number;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'Number of pending bookings',
|
description: 'Number of pending bookings',
|
||||||
|
|||||||
@ -233,7 +233,7 @@ export class AnalyticsService {
|
|||||||
const waitedMs = now - booking.requestedAt.getTime();
|
const waitedMs = now - booking.requestedAt.getTime();
|
||||||
const waitedDays = Math.floor(waitedMs / oneDay);
|
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) {
|
if (booking.status === CsvBookingStatus.PENDING && waitedMs > 2 * oneDay) {
|
||||||
alerts.push({
|
alerts.push({
|
||||||
id: `pending-${booking.id}`,
|
id: `pending-${booking.id}`,
|
||||||
@ -250,26 +250,8 @@ export class AnalyticsService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reservation bloquee avant paiement : rien ne part tant qu'elle y reste.
|
// Les devis non regles ne remontent plus ici : ils vivent dans
|
||||||
if (
|
// « Historique des devis » et ne bloquent aucun envoi en cours.
|
||||||
(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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refus recent : il faut replacer la marchandise.
|
// Refus recent : il faut replacer la marchandise.
|
||||||
if (
|
if (
|
||||||
|
|||||||
@ -167,9 +167,7 @@ export class CsvBookingService {
|
|||||||
// booking skips the payment gate and the carrier is notified immediately.
|
// booking skips the payment gate and the carrier is notified immediately.
|
||||||
const bookingFeeEur = await this.resolveBookingFeeEur(organizationId);
|
const bookingFeeEur = await this.resolveBookingFeeEur(organizationId);
|
||||||
const requiresPayment = bookingFeeEur > 0;
|
const requiresPayment = bookingFeeEur > 0;
|
||||||
const initialStatus = requiresPayment
|
const initialStatus = requiresPayment ? CsvBookingStatus.QUOTE : CsvBookingStatus.PENDING;
|
||||||
? CsvBookingStatus.PENDING_PAYMENT
|
|
||||||
: CsvBookingStatus.PENDING;
|
|
||||||
|
|
||||||
// Create domain entity (no email sent yet when a payment is required)
|
// Create domain entity (no email sent yet when a payment is required)
|
||||||
let parsedOptions: Record<string, boolean> = {};
|
let parsedOptions: Record<string, boolean> = {};
|
||||||
@ -280,9 +278,9 @@ export class CsvBookingService {
|
|||||||
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
|
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (booking.status !== CsvBookingStatus.PENDING_PAYMENT) {
|
if (booking.status !== CsvBookingStatus.QUOTE) {
|
||||||
throw new BadRequestException(
|
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`);
|
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
|
// Already confirmed - return current state
|
||||||
if (booking.status === CsvBookingStatus.PENDING) {
|
if (booking.status === CsvBookingStatus.PENDING) {
|
||||||
return this.toResponseDto(booking);
|
return this.toResponseDto(booking);
|
||||||
}
|
}
|
||||||
throw new BadRequestException(
|
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
|
* 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
|
* Sends an email notification to all ADMIN users
|
||||||
*/
|
*/
|
||||||
async declareBankTransfer(bookingId: string, userId: string): Promise<CsvBookingResponseDto> {
|
async declareBankTransfer(bookingId: string, userId: string): Promise<CsvBookingResponseDto> {
|
||||||
@ -451,9 +449,9 @@ export class CsvBookingService {
|
|||||||
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
|
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (booking.status !== CsvBookingStatus.PENDING_PAYMENT) {
|
if (booking.status !== CsvBookingStatus.QUOTE) {
|
||||||
throw new BadRequestException(
|
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).
|
* 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(
|
async updateBookingDetails(
|
||||||
id: string,
|
id: string,
|
||||||
@ -1129,7 +1127,7 @@ export class CsvBookingService {
|
|||||||
*
|
*
|
||||||
* Used when the user re-runs the search and picks a (possibly different) rate:
|
* 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
|
* 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(
|
async updateBookingRate(
|
||||||
id: string,
|
id: string,
|
||||||
@ -1234,7 +1232,7 @@ export class CsvBookingService {
|
|||||||
const stats = await this.csvBookingRepository.countByStatusForUser(userId);
|
const stats = await this.csvBookingRepository.countByStatusForUser(userId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pendingPayment: stats[CsvBookingStatus.PENDING_PAYMENT] || 0,
|
quote: stats[CsvBookingStatus.QUOTE] || 0,
|
||||||
pending: stats[CsvBookingStatus.PENDING] || 0,
|
pending: stats[CsvBookingStatus.PENDING] || 0,
|
||||||
accepted: stats[CsvBookingStatus.ACCEPTED] || 0,
|
accepted: stats[CsvBookingStatus.ACCEPTED] || 0,
|
||||||
rejected: stats[CsvBookingStatus.REJECTED] || 0,
|
rejected: stats[CsvBookingStatus.REJECTED] || 0,
|
||||||
@ -1250,7 +1248,7 @@ export class CsvBookingService {
|
|||||||
const stats = await this.csvBookingRepository.countByStatusForOrganization(organizationId);
|
const stats = await this.csvBookingRepository.countByStatusForOrganization(organizationId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pendingPayment: stats[CsvBookingStatus.PENDING_PAYMENT] || 0,
|
quote: stats[CsvBookingStatus.QUOTE] || 0,
|
||||||
pending: stats[CsvBookingStatus.PENDING] || 0,
|
pending: stats[CsvBookingStatus.PENDING] || 0,
|
||||||
accepted: stats[CsvBookingStatus.ACCEPTED] || 0,
|
accepted: stats[CsvBookingStatus.ACCEPTED] || 0,
|
||||||
rejected: stats[CsvBookingStatus.REJECTED] || 0,
|
rejected: stats[CsvBookingStatus.REJECTED] || 0,
|
||||||
@ -1348,9 +1346,9 @@ export class CsvBookingService {
|
|||||||
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
|
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow adding documents to PENDING_PAYMENT, PENDING_BANK_TRANSFER, PENDING, or ACCEPTED bookings
|
// Allow adding documents to QUOTE, PENDING_BANK_TRANSFER, PENDING, or ACCEPTED bookings
|
||||||
if (
|
if (
|
||||||
booking.status !== CsvBookingStatus.PENDING_PAYMENT &&
|
booking.status !== CsvBookingStatus.QUOTE &&
|
||||||
booking.status !== CsvBookingStatus.PENDING_BANK_TRANSFER &&
|
booking.status !== CsvBookingStatus.PENDING_BANK_TRANSFER &&
|
||||||
booking.status !== CsvBookingStatus.PENDING &&
|
booking.status !== CsvBookingStatus.PENDING &&
|
||||||
booking.status !== CsvBookingStatus.ACCEPTED
|
booking.status !== CsvBookingStatus.ACCEPTED
|
||||||
|
|||||||
@ -318,7 +318,7 @@ describe('CsvBooking Entity', () => {
|
|||||||
describe('isDeletable', () => {
|
describe('isDeletable', () => {
|
||||||
it('allows deleting a booking whose commission is still unpaid', () => {
|
it('allows deleting a booking whose commission is still unpaid', () => {
|
||||||
const booking = createValidBooking();
|
const booking = createValidBooking();
|
||||||
booking.status = CsvBookingStatus.PENDING_PAYMENT;
|
booking.status = CsvBookingStatus.QUOTE;
|
||||||
|
|
||||||
expect(booking.isDeletable()).toBe(true);
|
expect(booking.isDeletable()).toBe(true);
|
||||||
});
|
});
|
||||||
@ -340,7 +340,7 @@ describe('CsvBooking Entity', () => {
|
|||||||
|
|
||||||
it('stops being deletable once the payment is completed', () => {
|
it('stops being deletable once the payment is completed', () => {
|
||||||
const booking = createValidBooking();
|
const booking = createValidBooking();
|
||||||
booking.status = CsvBookingStatus.PENDING_PAYMENT;
|
booking.status = CsvBookingStatus.QUOTE;
|
||||||
expect(booking.isDeletable()).toBe(true);
|
expect(booking.isDeletable()).toBe(true);
|
||||||
|
|
||||||
booking.markPaymentCompleted();
|
booking.markPaymentCompleted();
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { PortCode } from '../value-objects/port-code.vo';
|
|||||||
* Represents the lifecycle of a CSV-based booking request
|
* Represents the lifecycle of a CSV-based booking request
|
||||||
*/
|
*/
|
||||||
export enum CsvBookingStatus {
|
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_BANK_TRANSFER = 'PENDING_BANK_TRANSFER', // Bank transfer declared, awaiting admin validation
|
||||||
PENDING = 'PENDING', // Awaiting carrier response
|
PENDING = 'PENDING', // Awaiting carrier response
|
||||||
ACCEPTED = 'ACCEPTED', // Carrier accepted the booking
|
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
|
* Statuts dans lesquels aucun paiement n'a ete encaisse : la reservation peut
|
||||||
* alors etre supprimee. Voir `CsvBooking.isDeletable()`.
|
* 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
|
* Document Interface
|
||||||
@ -194,12 +194,12 @@ export class CsvBooking {
|
|||||||
/**
|
/**
|
||||||
* Mark commission payment as completed → transition to PENDING
|
* 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 {
|
markPaymentCompleted(): void {
|
||||||
if (this.status !== CsvBookingStatus.PENDING_PAYMENT) {
|
if (this.status !== CsvBookingStatus.QUOTE) {
|
||||||
throw new Error(
|
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
|
* Declare bank transfer → transition to PENDING_BANK_TRANSFER
|
||||||
* Called when user confirms they have sent the 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 {
|
markBankTransferDeclared(): void {
|
||||||
if (this.status !== CsvBookingStatus.PENDING_PAYMENT) {
|
if (this.status !== CsvBookingStatus.QUOTE) {
|
||||||
throw new Error(
|
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.
|
* 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
|
* before it is sent to the carrier. Carrier, route and price derive from the
|
||||||
* selected rate and are not editable here.
|
* 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: {
|
editDetails(details: {
|
||||||
volumeCBM?: number;
|
volumeCBM?: number;
|
||||||
@ -346,9 +346,9 @@ export class CsvBooking {
|
|||||||
fobCurrency?: string;
|
fobCurrency?: string;
|
||||||
};
|
};
|
||||||
}): void {
|
}): void {
|
||||||
if (this.status !== CsvBookingStatus.PENDING_PAYMENT) {
|
if (this.status !== CsvBookingStatus.QUOTE) {
|
||||||
throw new Error(
|
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
|
* 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
|
* 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: {
|
editFromRate(data: {
|
||||||
carrierName: string;
|
carrierName: string;
|
||||||
@ -423,9 +423,9 @@ export class CsvBooking {
|
|||||||
notes?: string;
|
notes?: string;
|
||||||
options?: Record<string, boolean>;
|
options?: Record<string, boolean>;
|
||||||
}): void {
|
}): void {
|
||||||
if (this.status !== CsvBookingStatus.PENDING_PAYMENT) {
|
if (this.status !== CsvBookingStatus.QUOTE) {
|
||||||
throw new Error(
|
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) {
|
if (!data.carrierName || data.carrierName.trim().length === 0) {
|
||||||
@ -462,12 +462,11 @@ export class CsvBooking {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if booking has expired (7 days without response)
|
* Un devis : la reservation est construite mais les frais de booking ne sont
|
||||||
*
|
* pas regles, donc rien n'est encore parti chez le transporteur.
|
||||||
* @returns true if booking is older than 7 days and still pending
|
|
||||||
*/
|
*/
|
||||||
isPendingPayment(): boolean {
|
isQuote(): boolean {
|
||||||
return this.status === CsvBookingStatus.PENDING_PAYMENT;
|
return this.status === CsvBookingStatus.QUOTE;
|
||||||
}
|
}
|
||||||
|
|
||||||
isExpired(): boolean {
|
isExpired(): boolean {
|
||||||
|
|||||||
@ -15,7 +15,7 @@ export interface ShipmentCounterPort {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Count only PAID shipments (fee paid / payment declared / accepted) created by
|
* 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.
|
* cancelled bookings are excluded.
|
||||||
*/
|
*/
|
||||||
countPaidShipmentsForOrganizationInYear(
|
countPaidShipmentsForOrganizationInYear(
|
||||||
|
|||||||
@ -75,24 +75,11 @@ export class CsvBookingOrmEntity {
|
|||||||
@Column({
|
@Column({
|
||||||
name: 'status',
|
name: 'status',
|
||||||
type: 'enum',
|
type: 'enum',
|
||||||
enum: [
|
enum: ['QUOTE', 'PENDING_BANK_TRANSFER', 'PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED'],
|
||||||
'PENDING_PAYMENT',
|
default: 'QUOTE',
|
||||||
'PENDING_BANK_TRANSFER',
|
|
||||||
'PENDING',
|
|
||||||
'ACCEPTED',
|
|
||||||
'REJECTED',
|
|
||||||
'CANCELLED',
|
|
||||||
],
|
|
||||||
default: 'PENDING_PAYMENT',
|
|
||||||
})
|
})
|
||||||
@Index()
|
@Index()
|
||||||
status:
|
status: 'QUOTE' | 'PENDING_BANK_TRANSFER' | 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED';
|
||||||
| 'PENDING_PAYMENT'
|
|
||||||
| 'PENDING_BANK_TRANSFER'
|
|
||||||
| 'PENDING'
|
|
||||||
| 'ACCEPTED'
|
|
||||||
| 'REJECTED'
|
|
||||||
| 'CANCELLED';
|
|
||||||
|
|
||||||
@Column({ name: 'documents', type: 'jsonb' })
|
@Column({ name: 'documents', type: 'jsonb' })
|
||||||
documents: Array<{
|
documents: Array<{
|
||||||
|
|||||||
@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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'
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -38,7 +38,7 @@ export class TypeOrmShipmentCounterRepository implements ShipmentCounterPort {
|
|||||||
const startOfNextYear = new Date(year + 1, 0, 1);
|
const startOfNextYear = new Date(year + 1, 0, 1);
|
||||||
|
|
||||||
// "Paid" = payment completed / declared / accepted. Unpaid drafts
|
// "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'];
|
const PAID_STATUSES = ['PENDING_BANK_TRANSFER', 'PENDING', 'ACCEPTED'];
|
||||||
|
|
||||||
return this.csvBookingRepository
|
return this.csvBookingRepository
|
||||||
|
|||||||
@ -95,7 +95,7 @@ export default function AdminBookingsPage() {
|
|||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
const colors: Record<string, string> = {
|
const colors: Record<string, string> = {
|
||||||
pending_payment: 'bg-orange-100 text-orange-800',
|
quote: 'bg-neutral-100 text-neutral-800',
|
||||||
pending_bank_transfer: 'bg-amber-100 text-amber-900',
|
pending_bank_transfer: 'bg-amber-100 text-amber-900',
|
||||||
pending: 'bg-yellow-100 text-yellow-800',
|
pending: 'bg-yellow-100 text-yellow-800',
|
||||||
accepted: 'bg-green-100 text-green-800',
|
accepted: 'bg-green-100 text-green-800',
|
||||||
@ -108,7 +108,7 @@ export default function AdminBookingsPage() {
|
|||||||
const getStatusLabel = (status: string) => {
|
const getStatusLabel = (status: string) => {
|
||||||
const key = status.toUpperCase();
|
const key = status.toUpperCase();
|
||||||
const allowed = [
|
const allowed = [
|
||||||
'PENDING_PAYMENT',
|
'QUOTE',
|
||||||
'PENDING_BANK_TRANSFER',
|
'PENDING_BANK_TRANSFER',
|
||||||
'PENDING',
|
'PENDING',
|
||||||
'ACCEPTED',
|
'ACCEPTED',
|
||||||
@ -225,7 +225,7 @@ export default function AdminBookingsPage() {
|
|||||||
>
|
>
|
||||||
<option value="all">{t('filter.all')}</option>
|
<option value="all">{t('filter.all')}</option>
|
||||||
<option value="pending_bank_transfer">{t('status.PENDING_BANK_TRANSFER')}</option>
|
<option value="pending_bank_transfer">{t('status.PENDING_BANK_TRANSFER')}</option>
|
||||||
<option value="pending_payment">{t('status.PENDING_PAYMENT')}</option>
|
<option value="quote">{t('status.QUOTE')}</option>
|
||||||
<option value="pending">{t('status.PENDING')}</option>
|
<option value="pending">{t('status.PENDING')}</option>
|
||||||
<option value="accepted">{t('status.ACCEPTED')}</option>
|
<option value="accepted">{t('status.ACCEPTED')}</option>
|
||||||
<option value="rejected">{t('status.REJECTED')}</option>
|
<option value="rejected">{t('status.REJECTED')}</option>
|
||||||
|
|||||||
@ -94,7 +94,7 @@ export default function PayCommissionPage() {
|
|||||||
setBooking(bookingData as any);
|
setBooking(bookingData as any);
|
||||||
if (orgData) setOrg(orgData);
|
if (orgData) setOrg(orgData);
|
||||||
|
|
||||||
if ((bookingData as any).status !== 'PENDING_PAYMENT') {
|
if ((bookingData as any).status !== 'QUOTE') {
|
||||||
router.replace('/dashboard/bookings');
|
router.replace('/dashboard/bookings');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -123,7 +123,7 @@ export default function PayCommissionPage() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await declareBankTransfer(bookingId);
|
await declareBankTransfer(bookingId);
|
||||||
router.push('/dashboard/bookings?transfer=declared');
|
router.push('/dashboard/quotes?transfer=declared');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Erreur lors de la déclaration du virement');
|
setError(err instanceof Error ? err.message : 'Erreur lors de la déclaration du virement');
|
||||||
setDeclaring(false);
|
setDeclaring(false);
|
||||||
|
|||||||
@ -84,7 +84,7 @@ export default function BookingDetailPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const unpaid = booking.status === 'PENDING_PAYMENT';
|
const unpaid = booking.status === 'QUOTE';
|
||||||
const price = booking.priceEUR
|
const price = booking.priceEUR
|
||||||
? `${booking.priceEUR} €`
|
? `${booking.priceEUR} €`
|
||||||
: booking.priceUSD
|
: booking.priceUSD
|
||||||
@ -113,7 +113,7 @@ export default function BookingDetailPage() {
|
|||||||
|
|
||||||
{unpaid && (
|
{unpaid && (
|
||||||
<Callout variant="warning" className="mb-4">
|
<Callout variant="warning" className="mb-4">
|
||||||
{tList('detail.pendingPaymentNotice')}
|
{tList('detail.quoteNotice')}
|
||||||
</Callout>
|
</Callout>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -1,549 +1,22 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { Package } from 'lucide-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 { deleteCsvBooking, listCsvBookings } from '@/lib/api';
|
import { BookingListView } from '@/components/bookings/BookingListView';
|
||||||
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';
|
|
||||||
|
|
||||||
type SearchType = 'pallets' | 'weight' | 'route' | 'status' | 'date' | 'quote';
|
/**
|
||||||
|
* Reservations engagees : les frais de booking sont regles et la demande est
|
||||||
// Statuts consideres comme « non finalises » : reservation commencee, non payee.
|
* partie chez le transporteur. Les devis non regles vivent dans l'onglet
|
||||||
const TO_FINALIZE_STATUSES = ['PENDING_PAYMENT', 'PENDING_BANK_TRANSFER'];
|
* « Historique des devis ».
|
||||||
const ITEMS_PER_PAGE = 20;
|
*/
|
||||||
|
const BOOKING_STATUSES = ['PENDING', 'ACCEPTED', 'REJECTED'];
|
||||||
|
|
||||||
export default function BookingsListPage() {
|
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<any | null>(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 (
|
return (
|
||||||
booking.bookingNumber?.toLowerCase().includes(term) ||
|
<BookingListView
|
||||||
booking.id?.toLowerCase().includes(term) ||
|
namespace="dashboard.bookingsList"
|
||||||
booking.quoteNumber?.toLowerCase().includes(term)
|
statuses={BOOKING_STATUSES}
|
||||||
);
|
emptyIcon={Package}
|
||||||
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<ColumnDef<any, any>[]>(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
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 (
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate font-medium text-brand-navy">{b.bookingNumber || fallback}</p>
|
|
||||||
{b.bookingNumber && (
|
|
||||||
<p className="truncate text-body-xs text-neutral-400">{fallback}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'route',
|
|
||||||
header: t('columns.route'),
|
|
||||||
enableSorting: false,
|
|
||||||
meta: { cardRole: 'subtitle', headerLabel: t('columns.route') },
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const b = row.original;
|
|
||||||
return (
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate text-neutral-700">
|
|
||||||
{b.origin} → {b.destination}
|
|
||||||
</p>
|
|
||||||
<p className="truncate text-body-xs text-neutral-500">{b.carrierName}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'cargo',
|
|
||||||
header: t('columns.cargo'),
|
|
||||||
enableSorting: false,
|
|
||||||
meta: { hideBelow: 'lg', cardRole: 'field', headerLabel: t('columns.cargo') },
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const b = row.original;
|
|
||||||
return (
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate text-neutral-700">
|
|
||||||
{t('units.palletsCount', { count: b.palletCount })} · LCL
|
|
||||||
</p>
|
|
||||||
<p className="truncate text-body-xs text-neutral-500">
|
|
||||||
{t('units.kg', { value: b.weightKG })} · {t('units.cbm', { value: b.volumeCBM })}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'status',
|
|
||||||
header: t('columns.status'),
|
|
||||||
enableSorting: false,
|
|
||||||
meta: { cardRole: 'field', headerLabel: t('columns.status') },
|
|
||||||
cell: ({ row }) => <BookingStatusBadge status={row.original.status} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'date',
|
|
||||||
header: t('columns.date'),
|
|
||||||
enableSorting: false,
|
|
||||||
meta: { hideBelow: 'lg', cardRole: 'field', headerLabel: t('columns.date') },
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<span className="whitespace-nowrap text-neutral-500">
|
|
||||||
{shortDate(row.original.createdAt || row.original.requestedAt)}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'actions',
|
|
||||||
header: t('columns.actions'),
|
|
||||||
size: 64,
|
|
||||||
enableSorting: false,
|
|
||||||
meta: { align: 'right', cardRole: 'actions' },
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const b = row.original;
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" size="icon-sm" aria-label={t('columns.actions')}>
|
|
||||||
<MoreVertical />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent>
|
|
||||||
<DropdownMenuItem onSelect={() => setSelectedBooking(b)}>
|
|
||||||
<Eye />
|
|
||||||
{t('actions.view')}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
|
|
||||||
{b.status === 'PENDING_PAYMENT' && (
|
|
||||||
<>
|
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<Link href={buildEditUrl(b)}>
|
|
||||||
<Pencil />
|
|
||||||
{t('actions.edit')}
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<Link href={`/dashboard/booking/${b.id}/pay`}>
|
|
||||||
<CreditCard />
|
|
||||||
{t('actions.pay')}
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
{/* Suppression reservee aux reservations impayees : une fois
|
|
||||||
la commission reglee, la reservation est partie chez le
|
|
||||||
transporteur et ne peut plus qu'etre annulee. */}
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={() => remove(b)}
|
|
||||||
className="text-destructive focus:text-destructive"
|
|
||||||
>
|
|
||||||
<Trash2 />
|
|
||||||
{t('actions.delete')}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
// 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 ? (
|
|
||||||
<Button asChild variant="secondary">
|
|
||||||
<Link href="/dashboard/settings/subscription">
|
|
||||||
<Sparkles />
|
|
||||||
<span className="hidden sm:inline">{t('limit.upgrade')}</span>
|
|
||||||
<span className="sm:hidden">{t('limit.upgradeShort')}</span>
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button asChild>
|
|
||||||
<Link href="/dashboard/search-advanced">
|
|
||||||
<Plus />
|
|
||||||
{t('new')}
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageHeader
|
|
||||||
title={t('title')}
|
|
||||||
description={t('description')}
|
|
||||||
actions={
|
|
||||||
<>
|
|
||||||
<ExportButton
|
|
||||||
data={filteredBookings}
|
|
||||||
filename={t('exportFilename')}
|
|
||||||
columns={[
|
|
||||||
{ key: 'id', label: t('export.id') },
|
|
||||||
{ key: 'palletCount', label: t('export.pallets'), format: v => `${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}
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
{showTransferBanner && (
|
|
||||||
<Callout variant="warning" title={t('transferBanner.title')}>
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<p>{t('transferBanner.message')}</p>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
className="-mr-1 -mt-1 shrink-0"
|
|
||||||
onClick={() => setShowTransferBanner(false)}
|
|
||||||
aria-label={tCommon('dismiss')}
|
|
||||||
>
|
|
||||||
<X />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Callout>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{quota.blocked && (
|
|
||||||
<Callout variant="warning" title={t('limit.title')}>
|
|
||||||
<p>{t('limit.message', { max: quota.max })}</p>
|
|
||||||
<Button asChild variant="secondary" size="sm" className="mt-3">
|
|
||||||
<Link href="/dashboard/settings/subscription">
|
|
||||||
<Sparkles />
|
|
||||||
{t('limit.upgrade')}
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</Callout>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<FilterBar
|
|
||||||
active={activeFilters}
|
|
||||||
onClearAll={() => url.clear()}
|
|
||||||
labels={{
|
|
||||||
filters: tCommon('filters'),
|
|
||||||
clearAll: tCommon('clearFilters'),
|
|
||||||
remove: tCommon('removeFilter'),
|
|
||||||
}}
|
|
||||||
filters={
|
|
||||||
<>
|
|
||||||
<Select value={searchType} onValueChange={value => url.set('by', value)}>
|
|
||||||
<SelectTrigger className="w-full md:w-44" aria-label={t('searchType.label')}>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{searchTypeOptions.map(option => (
|
|
||||||
<SelectItem key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
value={statusFilter || 'all'}
|
|
||||||
onValueChange={value => url.set('status', value === 'all' ? undefined : value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-full md:w-48" aria-label={t('statusFilter.label')}>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{statusOptions.map(option => (
|
|
||||||
<SelectItem key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SearchInput
|
|
||||||
value={searchTerm}
|
|
||||||
onDebouncedChange={value => url.set('q', value || undefined)}
|
|
||||||
placeholder={placeholder}
|
|
||||||
ariaLabel={t('search')}
|
|
||||||
/>
|
|
||||||
</FilterBar>
|
|
||||||
|
|
||||||
<DataTable
|
|
||||||
columns={columns}
|
|
||||||
data={paginatedBookings}
|
|
||||||
loading={isLoading}
|
|
||||||
error={
|
|
||||||
csvError
|
|
||||||
? {
|
|
||||||
title: tErrors('generic'),
|
|
||||||
detail: csvError instanceof Error ? csvError.message : undefined,
|
|
||||||
retryLabel: tCommon('retry'),
|
|
||||||
onRetry: () => 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 => <span className="font-medium text-brand-navy">{chunks}</span>,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<BookingDetailDialog
|
|
||||||
booking={selectedBooking}
|
|
||||||
onOpenChange={open => !open && setSelectedBooking(null)}
|
|
||||||
buildEditUrl={buildEditUrl}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -56,12 +56,12 @@ interface DocumentWithBooking {
|
|||||||
const DOCUMENTABLE_STATUSES = [
|
const DOCUMENTABLE_STATUSES = [
|
||||||
'PENDING',
|
'PENDING',
|
||||||
'ACCEPTED',
|
'ACCEPTED',
|
||||||
'PENDING_PAYMENT',
|
'QUOTE',
|
||||||
'PENDING_BANK_TRANSFER',
|
'PENDING_BANK_TRANSFER',
|
||||||
];
|
];
|
||||||
|
|
||||||
const STATUS_KEYS = [
|
const STATUS_KEYS = [
|
||||||
'PENDING_PAYMENT',
|
'QUOTE',
|
||||||
'PENDING_BANK_TRANSFER',
|
'PENDING_BANK_TRANSFER',
|
||||||
'PENDING',
|
'PENDING',
|
||||||
'ACCEPTED',
|
'ACCEPTED',
|
||||||
|
|||||||
24
apps/frontend/app/[locale]/dashboard/quotes/page.tsx
Normal file
24
apps/frontend/app/[locale]/dashboard/quotes/page.tsx
Normal file
@ -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 (
|
||||||
|
<BookingListView
|
||||||
|
namespace="dashboard.quotesList"
|
||||||
|
statuses={QUOTE_STATUSES}
|
||||||
|
quoteActions
|
||||||
|
transferBanner
|
||||||
|
emptyIcon={FileClock}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -412,6 +412,7 @@
|
|||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"bookings": "Bookings",
|
"bookings": "Bookings",
|
||||||
|
"quotes": "Quote history",
|
||||||
"documents": "Documents",
|
"documents": "Documents",
|
||||||
"tracking": "Tracking",
|
"tracking": "Tracking",
|
||||||
"wiki": "Maritime Wiki",
|
"wiki": "Maritime Wiki",
|
||||||
@ -518,10 +519,8 @@
|
|||||||
},
|
},
|
||||||
"empty": "Nothing to report",
|
"empty": "Nothing to report",
|
||||||
"items": {
|
"items": {
|
||||||
"awaitingCarrier": "No carrier response",
|
"awaitingCarrier": "Awaiting carrier",
|
||||||
"awaitingCarrierSince": "Waiting {days} days on {carrier}",
|
"awaitingCarrierSince": "Waiting {days} days on {carrier}",
|
||||||
"awaitingPayment": "Payment pending",
|
|
||||||
"awaitingPaymentSince": "Blocked for {days} days awaiting payment",
|
|
||||||
"rejected": "Rejected by carrier",
|
"rejected": "Rejected by carrier",
|
||||||
"rejectedBy": "{carrier} declined the request — needs rebooking"
|
"rejectedBy": "{carrier} declined the request — needs rebooking"
|
||||||
}
|
}
|
||||||
@ -574,11 +573,10 @@
|
|||||||
},
|
},
|
||||||
"statusFilter": {
|
"statusFilter": {
|
||||||
"label": "Status",
|
"label": "Status",
|
||||||
"all": "All statuses",
|
"all": "All statuses"
|
||||||
"toFinalize": "To finalize"
|
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"pendingPayment": "Payment pending",
|
"quote": "Quote",
|
||||||
"pendingBankTransfer": "Transfer pending",
|
"pendingBankTransfer": "Transfer pending",
|
||||||
"pending": "Pending",
|
"pending": "Pending",
|
||||||
"accepted": "Accepted",
|
"accepted": "Accepted",
|
||||||
@ -619,10 +617,10 @@
|
|||||||
"weight": "Weight / Volume",
|
"weight": "Weight / Volume",
|
||||||
"pickupDate": "Requested pickup date",
|
"pickupDate": "Requested pickup date",
|
||||||
"price": "Price",
|
"price": "Price",
|
||||||
"pendingPaymentNotice": "This booking is awaiting commission payment. Click below to complete the payment.",
|
|
||||||
"payNow": "Complete payment",
|
"payNow": "Complete payment",
|
||||||
"newBooking": "New booking",
|
"newBooking": "New booking",
|
||||||
"close": "Close"
|
"close": "Close",
|
||||||
|
"quoteNotice": "This quote is awaiting the booking fee payment. Click below to complete it."
|
||||||
},
|
},
|
||||||
"mobile": {
|
"mobile": {
|
||||||
"pallets": "Pallets",
|
"pallets": "Pallets",
|
||||||
@ -650,6 +648,128 @@
|
|||||||
"showing": "Showing <b>{start}</b> to <b>{end}</b> of <b>{total}</b> {total, plural, one {result} other {results}}"
|
"showing": "Showing <b>{start}</b> to <b>{end}</b> of <b>{total}</b> {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 <b>{start}</b> to <b>{end}</b> of <b>{total}</b> {total, plural, one {result} other {results}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
"rateSearch": {
|
"rateSearch": {
|
||||||
"title": "Advanced Rate Search",
|
"title": "Advanced Rate Search",
|
||||||
"subtitle": "Complete form with all transport options",
|
"subtitle": "Complete form with all transport options",
|
||||||
@ -1119,7 +1239,7 @@
|
|||||||
"all": "All statuses"
|
"all": "All statuses"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"PENDING_PAYMENT": "Payment pending",
|
"QUOTE": "Quote",
|
||||||
"PENDING_BANK_TRANSFER": "Transfer to validate",
|
"PENDING_BANK_TRANSFER": "Transfer to validate",
|
||||||
"PENDING": "Awaiting carrier",
|
"PENDING": "Awaiting carrier",
|
||||||
"ACCEPTED": "Accepted",
|
"ACCEPTED": "Accepted",
|
||||||
@ -1613,7 +1733,7 @@
|
|||||||
"actions": "Actions"
|
"actions": "Actions"
|
||||||
},
|
},
|
||||||
"statuses": {
|
"statuses": {
|
||||||
"PENDING_PAYMENT": "Payment pending",
|
"QUOTE": "Quote",
|
||||||
"PENDING_BANK_TRANSFER": "Bank transfer pending",
|
"PENDING_BANK_TRANSFER": "Bank transfer pending",
|
||||||
"PENDING": "Pending",
|
"PENDING": "Pending",
|
||||||
"ACCEPTED": "Accepted",
|
"ACCEPTED": "Accepted",
|
||||||
|
|||||||
@ -412,6 +412,7 @@
|
|||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "Tableau de bord",
|
"dashboard": "Tableau de bord",
|
||||||
"bookings": "Réservations",
|
"bookings": "Réservations",
|
||||||
|
"quotes": "Historique des devis",
|
||||||
"documents": "Documents",
|
"documents": "Documents",
|
||||||
"tracking": "Suivi",
|
"tracking": "Suivi",
|
||||||
"wiki": "Wiki Maritime",
|
"wiki": "Wiki Maritime",
|
||||||
@ -518,10 +519,8 @@
|
|||||||
},
|
},
|
||||||
"empty": "Rien à signaler",
|
"empty": "Rien à signaler",
|
||||||
"items": {
|
"items": {
|
||||||
"awaitingCarrier": "Sans réponse du transporteur",
|
"awaitingCarrier": "En attente du transporteur",
|
||||||
"awaitingCarrierSince": "En attente depuis {days} jours chez {carrier}",
|
"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",
|
"rejected": "Refusée par le transporteur",
|
||||||
"rejectedBy": "{carrier} a refusé la demande — à replacer"
|
"rejectedBy": "{carrier} a refusé la demande — à replacer"
|
||||||
}
|
}
|
||||||
@ -574,11 +573,10 @@
|
|||||||
},
|
},
|
||||||
"statusFilter": {
|
"statusFilter": {
|
||||||
"label": "Statut",
|
"label": "Statut",
|
||||||
"all": "Tous les statuts",
|
"all": "Tous les statuts"
|
||||||
"toFinalize": "À finaliser"
|
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"pendingPayment": "Paiement en attente",
|
"quote": "Devis",
|
||||||
"pendingBankTransfer": "Virement en attente",
|
"pendingBankTransfer": "Virement en attente",
|
||||||
"pending": "En attente",
|
"pending": "En attente",
|
||||||
"accepted": "Accepté",
|
"accepted": "Accepté",
|
||||||
@ -619,10 +617,10 @@
|
|||||||
"weight": "Poids / Volume",
|
"weight": "Poids / Volume",
|
||||||
"pickupDate": "Date d'enlèvement souhaitée",
|
"pickupDate": "Date d'enlèvement souhaitée",
|
||||||
"price": "Prix",
|
"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",
|
"payNow": "Finaliser le paiement",
|
||||||
"newBooking": "Nouvelle réservation",
|
"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": {
|
"mobile": {
|
||||||
"pallets": "Palettes",
|
"pallets": "Palettes",
|
||||||
@ -650,6 +648,128 @@
|
|||||||
"showing": "Affichage de <b>{start}</b> à <b>{end}</b> sur <b>{total}</b> {total, plural, one {résultat} other {résultats}}"
|
"showing": "Affichage de <b>{start}</b> à <b>{end}</b> sur <b>{total}</b> {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 <b>{start}</b> à <b>{end}</b> sur <b>{total}</b> {total, plural, one {résultat} other {résultats}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
"rateSearch": {
|
"rateSearch": {
|
||||||
"title": "Recherche Avancée de Tarifs",
|
"title": "Recherche Avancée de Tarifs",
|
||||||
"subtitle": "Formulaire complet avec toutes les options de transport",
|
"subtitle": "Formulaire complet avec toutes les options de transport",
|
||||||
@ -1119,7 +1239,7 @@
|
|||||||
"all": "Tous les statuts"
|
"all": "Tous les statuts"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"PENDING_PAYMENT": "Paiement en attente",
|
"QUOTE": "Devis",
|
||||||
"PENDING_BANK_TRANSFER": "Virement à valider",
|
"PENDING_BANK_TRANSFER": "Virement à valider",
|
||||||
"PENDING": "En attente transporteur",
|
"PENDING": "En attente transporteur",
|
||||||
"ACCEPTED": "Accepté",
|
"ACCEPTED": "Accepté",
|
||||||
@ -1613,7 +1733,7 @@
|
|||||||
"actions": "Actions"
|
"actions": "Actions"
|
||||||
},
|
},
|
||||||
"statuses": {
|
"statuses": {
|
||||||
"PENDING_PAYMENT": "Paiement en attente",
|
"QUOTE": "Devis",
|
||||||
"PENDING_BANK_TRANSFER": "Virement en attente",
|
"PENDING_BANK_TRANSFER": "Virement en attente",
|
||||||
"PENDING": "En attente",
|
"PENDING": "En attente",
|
||||||
"ACCEPTED": "Accepté",
|
"ACCEPTED": "Accepté",
|
||||||
|
|||||||
@ -24,14 +24,17 @@ interface BookingDetailDialogProps {
|
|||||||
booking: any | null;
|
booking: any | null;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
buildEditUrl: (booking: any) => string;
|
buildEditUrl: (booking: any) => string;
|
||||||
|
/** Namespace de la vue appelante, pour que le titre suive l'onglet. */
|
||||||
|
namespace?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BookingDetailDialog({
|
export function BookingDetailDialog({
|
||||||
booking,
|
booking,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
buildEditUrl,
|
buildEditUrl,
|
||||||
|
namespace = 'dashboard.bookingsList',
|
||||||
}: BookingDetailDialogProps) {
|
}: BookingDetailDialogProps) {
|
||||||
const t = useTranslations('dashboard.bookingsList');
|
const t = useTranslations(namespace as any);
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US';
|
const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US';
|
||||||
|
|
||||||
@ -100,8 +103,8 @@ export function BookingDetailDialog({
|
|||||||
)}
|
)}
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
{booking.status === 'PENDING_PAYMENT' && (
|
{booking.status === 'QUOTE' && (
|
||||||
<Callout variant="warning">{t('detail.pendingPaymentNotice')}</Callout>
|
<Callout variant="warning">{t('detail.quoteNotice')}</Callout>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -110,7 +113,7 @@ export function BookingDetailDialog({
|
|||||||
{t('detail.close')}
|
{t('detail.close')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{booking.status === 'PENDING_PAYMENT' && (
|
{booking.status === 'QUOTE' && (
|
||||||
<>
|
<>
|
||||||
<Button asChild variant="outline">
|
<Button asChild variant="outline">
|
||||||
<Link href={buildEditUrl(booking)} onClick={() => onOpenChange(false)}>
|
<Link href={buildEditUrl(booking)} onClick={() => onOpenChange(false)}>
|
||||||
|
|||||||
585
apps/frontend/src/components/bookings/BookingListView.tsx
Normal file
585
apps/frontend/src/components/bookings/BookingListView.tsx
Normal file
@ -0,0 +1,585 @@
|
|||||||
|
'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,
|
||||||
|
type LucideIcon,
|
||||||
|
} 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';
|
||||||
|
|
||||||
|
type SearchType = 'pallets' | 'weight' | 'route' | 'status' | 'date' | 'quote';
|
||||||
|
|
||||||
|
const ITEMS_PER_PAGE = 20;
|
||||||
|
|
||||||
|
export interface BookingListViewProps {
|
||||||
|
/**
|
||||||
|
* Namespace de traduction de la vue (`dashboard.bookingsList` ou
|
||||||
|
* `dashboard.quotesList`). Les libelles de statut, eux, restent communs.
|
||||||
|
*/
|
||||||
|
namespace: string;
|
||||||
|
/** Statuts affiches par la vue. Le reste est pris en charge par l'autre onglet. */
|
||||||
|
statuses: string[];
|
||||||
|
/**
|
||||||
|
* Actions propres a un devis non regle : modifier, payer, supprimer. Une fois
|
||||||
|
* les frais de booking regles, la demande est partie chez le transporteur.
|
||||||
|
*/
|
||||||
|
quoteActions?: boolean;
|
||||||
|
/** Bandeau de confirmation apres declaration d'un virement. */
|
||||||
|
transferBanner?: boolean;
|
||||||
|
/** Icone de l'etat vide. */
|
||||||
|
emptyIcon?: LucideIcon;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liste de reservations filtree par statut.
|
||||||
|
*
|
||||||
|
* Les deux onglets (« Reservations » et « Historique des devis ») partagent la
|
||||||
|
* meme table, le meme export et les memes filtres : seuls les statuts couverts
|
||||||
|
* et les actions disponibles les distinguent.
|
||||||
|
*/
|
||||||
|
export function BookingListView({
|
||||||
|
namespace,
|
||||||
|
statuses,
|
||||||
|
quoteActions = false,
|
||||||
|
transferBanner = false,
|
||||||
|
emptyIcon = Package,
|
||||||
|
}: BookingListViewProps) {
|
||||||
|
const t = useTranslations(namespace as any);
|
||||||
|
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<any | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (transferBanner && searchParams.get('transfer') === 'declared') {
|
||||||
|
setShowTransferBanner(true);
|
||||||
|
}
|
||||||
|
}, [searchParams, transferBanner]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: csvData,
|
||||||
|
isLoading,
|
||||||
|
error: csvError,
|
||||||
|
refetch,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['csv-bookings'],
|
||||||
|
queryFn: () => listCsvBookings({ page: 1, limit: 1000 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const quota = useReservationQuota();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime un devis non regle, 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()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Le filtre de statut ne propose que les statuts couverts par la vue : y
|
||||||
|
// laisser les autres donnerait des listes systematiquement vides.
|
||||||
|
const statusOptions = useMemo(
|
||||||
|
() => [
|
||||||
|
{ value: 'all', label: t('statusFilter.all') },
|
||||||
|
...statuses.map(status => ({ value: status, label: statusLabel(status) })),
|
||||||
|
],
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[t, statuses.join(',')]
|
||||||
|
);
|
||||||
|
|
||||||
|
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(() => {
|
||||||
|
// La vue ne connait que ses statuts : une reservation reglee n'apparait pas
|
||||||
|
// dans l'historique des devis, et inversement.
|
||||||
|
let filtered = (csvData?.bookings || [])
|
||||||
|
.filter((b: any) => statuses.includes(b.status))
|
||||||
|
.map(b => ({ ...b, type: 'csv' as const }));
|
||||||
|
|
||||||
|
if (statusFilter) {
|
||||||
|
filtered = 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;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [csvData, statusFilter, searchTerm, searchType, dateLocale, statuses.join(',')]);
|
||||||
|
|
||||||
|
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<ColumnDef<any, any>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
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 (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-medium text-brand-navy">{b.bookingNumber || fallback}</p>
|
||||||
|
{b.bookingNumber && (
|
||||||
|
<p className="truncate text-body-xs text-neutral-400">{fallback}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'route',
|
||||||
|
header: t('columns.route'),
|
||||||
|
enableSorting: false,
|
||||||
|
meta: { cardRole: 'subtitle', headerLabel: t('columns.route') },
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const b = row.original;
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-neutral-700">
|
||||||
|
{b.origin} → {b.destination}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-body-xs text-neutral-500">{b.carrierName}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cargo',
|
||||||
|
header: t('columns.cargo'),
|
||||||
|
enableSorting: false,
|
||||||
|
meta: { hideBelow: 'lg', cardRole: 'field', headerLabel: t('columns.cargo') },
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const b = row.original;
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-neutral-700">
|
||||||
|
{t('units.palletsCount', { count: b.palletCount })} · LCL
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-body-xs text-neutral-500">
|
||||||
|
{t('units.kg', { value: b.weightKG })} · {t('units.cbm', { value: b.volumeCBM })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'status',
|
||||||
|
header: t('columns.status'),
|
||||||
|
enableSorting: false,
|
||||||
|
meta: { cardRole: 'field', headerLabel: t('columns.status') },
|
||||||
|
cell: ({ row }) => <BookingStatusBadge status={row.original.status} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'date',
|
||||||
|
header: t('columns.date'),
|
||||||
|
enableSorting: false,
|
||||||
|
meta: { hideBelow: 'lg', cardRole: 'field', headerLabel: t('columns.date') },
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="whitespace-nowrap text-neutral-500">
|
||||||
|
{shortDate(row.original.createdAt || row.original.requestedAt)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
header: t('columns.actions'),
|
||||||
|
size: 64,
|
||||||
|
enableSorting: false,
|
||||||
|
meta: { align: 'right', cardRole: 'actions' },
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const b = row.original;
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon-sm" aria-label={t('columns.actions')}>
|
||||||
|
<MoreVertical />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<DropdownMenuItem onSelect={() => setSelectedBooking(b)}>
|
||||||
|
<Eye />
|
||||||
|
{t('actions.view')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
{quoteActions && b.status === 'QUOTE' && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href={buildEditUrl(b)}>
|
||||||
|
<Pencil />
|
||||||
|
{t('actions.edit')}
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href={`/dashboard/booking/${b.id}/pay`}>
|
||||||
|
<CreditCard />
|
||||||
|
{t('actions.pay')}
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{/* 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. */}
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={() => remove(b)}
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
{t('actions.delete')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// 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 ? (
|
||||||
|
<Button asChild variant="secondary">
|
||||||
|
<Link href="/dashboard/settings/subscription">
|
||||||
|
<Sparkles />
|
||||||
|
<span className="hidden sm:inline">{t('limit.upgrade')}</span>
|
||||||
|
<span className="sm:hidden">{t('limit.upgradeShort')}</span>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/dashboard/search-advanced">
|
||||||
|
<Plus />
|
||||||
|
{t('new')}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title={t('title')}
|
||||||
|
description={t('description')}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<ExportButton
|
||||||
|
data={filteredBookings}
|
||||||
|
filename={t('exportFilename')}
|
||||||
|
columns={[
|
||||||
|
{ key: 'id', label: t('export.id') },
|
||||||
|
{ key: 'palletCount', label: t('export.pallets'), format: v => `${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}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{showTransferBanner && (
|
||||||
|
<Callout variant="warning" title={t('transferBanner.title')}>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<p>{t('transferBanner.message')}</p>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="-mr-1 -mt-1 shrink-0"
|
||||||
|
onClick={() => setShowTransferBanner(false)}
|
||||||
|
aria-label={tCommon('dismiss')}
|
||||||
|
>
|
||||||
|
<X />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Callout>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{quota.blocked && (
|
||||||
|
<Callout variant="warning" title={t('limit.title')}>
|
||||||
|
<p>{t('limit.message', { max: quota.max })}</p>
|
||||||
|
<Button asChild variant="secondary" size="sm" className="mt-3">
|
||||||
|
<Link href="/dashboard/settings/subscription">
|
||||||
|
<Sparkles />
|
||||||
|
{t('limit.upgrade')}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</Callout>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FilterBar
|
||||||
|
active={activeFilters}
|
||||||
|
onClearAll={() => url.clear()}
|
||||||
|
labels={{
|
||||||
|
filters: tCommon('filters'),
|
||||||
|
clearAll: tCommon('clearFilters'),
|
||||||
|
remove: tCommon('removeFilter'),
|
||||||
|
}}
|
||||||
|
filters={
|
||||||
|
<>
|
||||||
|
<Select value={searchType} onValueChange={value => url.set('by', value)}>
|
||||||
|
<SelectTrigger className="w-full md:w-44" aria-label={t('searchType.label')}>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{searchTypeOptions.map(option => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={statusFilter || 'all'}
|
||||||
|
onValueChange={value => url.set('status', value === 'all' ? undefined : value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full md:w-48" aria-label={t('statusFilter.label')}>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{statusOptions.map(option => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SearchInput
|
||||||
|
value={searchTerm}
|
||||||
|
onDebouncedChange={value => url.set('q', value || undefined)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
ariaLabel={t('search')}
|
||||||
|
/>
|
||||||
|
</FilterBar>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={paginatedBookings}
|
||||||
|
loading={isLoading}
|
||||||
|
error={
|
||||||
|
csvError
|
||||||
|
? {
|
||||||
|
title: tErrors('generic'),
|
||||||
|
detail: csvError instanceof Error ? csvError.message : undefined,
|
||||||
|
retryLabel: tCommon('retry'),
|
||||||
|
onRetry: () => 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 => <span className="font-medium text-brand-navy">{chunks}</span>,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BookingDetailDialog
|
||||||
|
booking={selectedBooking}
|
||||||
|
onOpenChange={open => !open && setSelectedBooking(null)}
|
||||||
|
buildEditUrl={buildEditUrl}
|
||||||
|
namespace={namespace}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -10,7 +10,7 @@ import { Badge, type BadgeProps } from '@/components/ui/badge';
|
|||||||
* dupliquee sous forme de tables de classes Tailwind dans plusieurs pages.
|
* dupliquee sous forme de tables de classes Tailwind dans plusieurs pages.
|
||||||
*/
|
*/
|
||||||
export const BOOKING_STATUS_VARIANT: Record<string, NonNullable<BadgeProps['variant']>> = {
|
export const BOOKING_STATUS_VARIANT: Record<string, NonNullable<BadgeProps['variant']>> = {
|
||||||
PENDING_PAYMENT: 'warning',
|
QUOTE: 'neutral',
|
||||||
PENDING_BANK_TRANSFER: 'info',
|
PENDING_BANK_TRANSFER: 'info',
|
||||||
PENDING: 'warning',
|
PENDING: 'warning',
|
||||||
ACCEPTED: 'success',
|
ACCEPTED: 'success',
|
||||||
@ -18,8 +18,8 @@ export const BOOKING_STATUS_VARIANT: Record<string, NonNullable<BadgeProps['vari
|
|||||||
CANCELLED: 'neutral',
|
CANCELLED: 'neutral',
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_KEY: Record<string, string> = {
|
export const BOOKING_STATUS_KEY: Record<string, string> = {
|
||||||
PENDING_PAYMENT: 'pendingPayment',
|
QUOTE: 'quote',
|
||||||
PENDING_BANK_TRANSFER: 'pendingBankTransfer',
|
PENDING_BANK_TRANSFER: 'pendingBankTransfer',
|
||||||
PENDING: 'pending',
|
PENDING: 'pending',
|
||||||
ACCEPTED: 'accepted',
|
ACCEPTED: 'accepted',
|
||||||
@ -30,7 +30,7 @@ const STATUS_KEY: Record<string, string> = {
|
|||||||
export function useBookingStatusLabel() {
|
export function useBookingStatusLabel() {
|
||||||
const t = useTranslations('dashboard.bookingsList');
|
const t = useTranslations('dashboard.bookingsList');
|
||||||
return (status: string) => {
|
return (status: string) => {
|
||||||
const key = STATUS_KEY[status];
|
const key = BOOKING_STATUS_KEY[status];
|
||||||
return key ? t(`status.${key}` as any) : status;
|
return key ? t(`status.${key}` as any) : status;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import {
|
|||||||
Bot,
|
Bot,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
Building2,
|
Building2,
|
||||||
|
FileClock,
|
||||||
FileText,
|
FileText,
|
||||||
Key,
|
Key,
|
||||||
Newspaper,
|
Newspaper,
|
||||||
@ -64,6 +65,12 @@ export function buildAppNav({ t, tShell, role }: AppNavParams): NavGroup[] {
|
|||||||
requiredFeature: 'dashboard',
|
requiredFeature: 'dashboard',
|
||||||
},
|
},
|
||||||
{ key: 'bookings', label: t('nav.bookings'), href: '/dashboard/bookings', icon: Package },
|
{ key: 'bookings', label: t('nav.bookings'), href: '/dashboard/bookings', icon: Package },
|
||||||
|
{
|
||||||
|
key: 'quotes',
|
||||||
|
label: t('nav.quotes'),
|
||||||
|
href: '/dashboard/quotes',
|
||||||
|
icon: FileClock,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'documents',
|
key: 'documents',
|
||||||
label: t('nav.documents'),
|
label: t('nav.documents'),
|
||||||
|
|||||||
@ -54,7 +54,7 @@ export interface CsvBookingResponse {
|
|||||||
transitDays: number;
|
transitDays: number;
|
||||||
containerType: string;
|
containerType: string;
|
||||||
status:
|
status:
|
||||||
| 'PENDING_PAYMENT'
|
| 'QUOTE'
|
||||||
| 'PENDING_BANK_TRANSFER'
|
| 'PENDING_BANK_TRANSFER'
|
||||||
| 'PENDING'
|
| 'PENDING'
|
||||||
| 'ACCEPTED'
|
| 'ACCEPTED'
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user