feat(bookings): Phase 2 edition avant paiement + filtre + ergonomie

- Edition d'une reservation avant paiement: page /booking/:id/edit
  (volume, poids, palettes, notes + gestion documents), endpoint
  PATCH /csv-bookings/:id/details (owner + PENDING_PAYMENT), methode
  domaine editDetails. Acces "Modifier" depuis la liste, la modale detail
  et la page de paiement.
- Filtre "A finaliser" (PENDING_PAYMENT + PENDING_BANK_TRANSFER) + libelles
  et couleurs de tous les statuts.
- Tableau des reservations: 6 colonnes + colonne Actions collante a droite
  (plus de scroll horizontal pour atteindre Payer/Voir).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
David 2026-07-01 13:30:05 +02:00
parent 19b96517d2
commit 7234343e20
10 changed files with 772 additions and 127 deletions

View File

@ -47,6 +47,7 @@ import {
CsvBookingResponseDto, CsvBookingResponseDto,
CsvBookingListResponseDto, CsvBookingListResponseDto,
CsvBookingStatsDto, CsvBookingStatsDto,
UpdateCsvBookingDetailsDto,
} from '../dto/csv-booking.dto'; } from '../dto/csv-booking.dto';
/** /**
@ -560,6 +561,37 @@ export class CsvBookingsController {
return await this.csvBookingService.cancelBooking(id, userId); return await this.csvBookingService.cancelBooking(id, userId);
} }
/**
* Update booking cargo details before payment
*
* PATCH /api/v1/csv-bookings/:id/details
*/
@Patch(':id/details')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({
summary: 'Update booking details before payment',
description:
'Edit cargo characteristics (volume, weight, pallets, notes) of a booking awaiting payment. Only the owner can edit, and only while the booking is PENDING_PAYMENT.',
})
@ApiParam({ name: 'id', description: 'Booking ID (UUID)' })
@ApiResponse({
status: 200,
description: 'Booking details updated successfully',
type: CsvBookingResponseDto,
})
@ApiResponse({ status: 400, description: 'Booking cannot be edited (invalid status or values)' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 404, description: 'Booking not found' })
async updateBookingDetails(
@Param('id') id: string,
@Body() dto: UpdateCsvBookingDetailsDto,
@Request() req: any
): Promise<CsvBookingResponseDto> {
const userId = req.user.id;
return await this.csvBookingService.updateBookingDetails(id, userId, dto);
}
/** /**
* Add documents to an existing booking * Add documents to an existing booking
* *

View File

@ -173,6 +173,38 @@ export class CreateCsvBookingDto {
// Not included in DTO validation but processed separately // Not included in DTO validation but processed separately
} }
/**
* Update CSV Booking Details DTO
*
* Editable cargo characteristics before payment. Carrier, route and price are
* derived from the selected rate and cannot be changed here.
*/
export class UpdateCsvBookingDetailsDto {
@ApiPropertyOptional({ description: 'Total volume in cubic meters (CBM)', example: 12.5 })
@IsOptional()
@IsNumber()
@Min(0.01)
volumeCBM?: number;
@ApiPropertyOptional({ description: 'Total weight in kilograms', example: 1500 })
@IsOptional()
@IsNumber()
@Min(0.01)
weightKG?: number;
@ApiPropertyOptional({ description: 'Number of pallets', example: 5 })
@IsOptional()
@IsNumber()
@Min(0)
palletCount?: number;
@ApiPropertyOptional({ description: 'Additional notes', example: 'Please handle with care' })
@IsOptional()
@IsString()
@MaxLength(2000)
notes?: string;
}
/** /**
* Document DTO for response * Document DTO for response
*/ */

View File

@ -31,6 +31,7 @@ import {
CsvBookingDocumentDto, CsvBookingDocumentDto,
CsvBookingListResponseDto, CsvBookingListResponseDto,
CsvBookingStatsDto, CsvBookingStatsDto,
UpdateCsvBookingDetailsDto,
} from '../dto/csv-booking.dto'; } from '../dto/csv-booking.dto';
import { CarrierDocumentsResponseDto } from '../dto/carrier-documents.dto'; import { CarrierDocumentsResponseDto } from '../dto/carrier-documents.dto';
import { SubscriptionService } from './subscription.service'; import { SubscriptionService } from './subscription.service';
@ -1011,6 +1012,47 @@ export class CsvBookingService {
return this.toResponseDto(updatedBooking); return this.toResponseDto(updatedBooking);
} }
/**
* Update the cargo details of a booking before payment (user action).
*
* Only the owner can edit, and only while the booking is PENDING_PAYMENT.
*/
async updateBookingDetails(
id: string,
userId: string,
dto: UpdateCsvBookingDetailsDto
): Promise<CsvBookingResponseDto> {
this.logger.log(`Updating booking details ${id} by user ${userId}`);
const booking = await this.csvBookingRepository.findById(id);
if (!booking) {
throw new NotFoundException('Booking not found');
}
// Verify user owns this booking
if (booking.userId !== userId) {
throw new NotFoundException('Booking not found');
}
// Apply the changes (domain logic validates status + values)
try {
booking.editDetails({
volumeCBM: dto.volumeCBM,
weightKG: dto.weightKG,
palletCount: dto.palletCount,
notes: dto.notes,
});
} catch (err) {
throw new BadRequestException(err instanceof Error ? err.message : 'Invalid booking details');
}
const updatedBooking = await this.csvBookingRepository.update(booking);
this.logger.log(`Booking ${id} details updated`);
return this.toResponseDto(updatedBooking);
}
/** /**
* Get bookings for a user (paginated) * Get bookings for a user (paginated)
*/ */

View File

@ -67,9 +67,10 @@ export class CsvBooking {
public readonly carrierEmail: string, public readonly carrierEmail: string,
public readonly origin: PortCode, public readonly origin: PortCode,
public readonly destination: PortCode, public readonly destination: PortCode,
public readonly volumeCBM: number, // Cargo characteristics — editable before payment (see editDetails).
public readonly weightKG: number, public volumeCBM: number,
public readonly palletCount: number, public weightKG: number,
public palletCount: number,
public readonly priceUSD: number, public readonly priceUSD: number,
public readonly priceEUR: number, public readonly priceEUR: number,
public readonly primaryCurrency: string, public readonly primaryCurrency: string,
@ -291,6 +292,53 @@ export class CsvBooking {
this.respondedAt = new Date(); this.respondedAt = new Date();
} }
/**
* Edit the cargo details of a booking before it is paid.
*
* Only allowed while the booking is awaiting payment (PENDING_PAYMENT), i.e.
* before it is sent to the carrier. Carrier, route and price derive from the
* selected rate and are not editable here.
*
* @throws Error if the booking is not in PENDING_PAYMENT status or values are invalid
*/
editDetails(details: {
volumeCBM?: number;
weightKG?: number;
palletCount?: number;
notes?: string;
}): void {
if (this.status !== CsvBookingStatus.PENDING_PAYMENT) {
throw new Error(
`Cannot edit booking with status ${this.status}. Only PENDING_PAYMENT bookings can be edited.`
);
}
if (details.volumeCBM !== undefined) {
if (details.volumeCBM <= 0) {
throw new Error('Volume must be positive');
}
this.volumeCBM = details.volumeCBM;
}
if (details.weightKG !== undefined) {
if (details.weightKG <= 0) {
throw new Error('Weight must be positive');
}
this.weightKG = details.weightKG;
}
if (details.palletCount !== undefined) {
if (details.palletCount < 0) {
throw new Error('Pallet count cannot be negative');
}
this.palletCount = details.palletCount;
}
if (details.notes !== undefined) {
this.notes = details.notes;
}
}
/** /**
* Check if booking has expired (7 days without response) * Check if booking has expired (7 days without response)
* *

View File

@ -0,0 +1,416 @@
/**
* Edit Booking Page (before payment)
*
* Lets the owner adjust cargo details (volume, weight, pallets, notes) and manage
* documents of a booking that is still awaiting payment (PENDING_PAYMENT), then
* continue to the payment page. Carrier, route and price derive from the selected
* rate and are shown read-only.
*/
'use client';
import { useState, useEffect, useRef } from 'react';
import { useRouter, useParams } from 'next/navigation';
import {
ArrowLeft,
Loader2,
AlertTriangle,
Save,
CreditCard,
FileText,
Trash2,
Upload,
Package,
} from 'lucide-react';
import { getCsvBooking, updateCsvBookingDetails } from '@/lib/api/bookings';
import { upload, del } from '@/lib/api/client';
interface BookingDoc {
id?: string;
type: string;
fileName: string;
url: string;
}
interface BookingData {
id: string;
bookingNumber?: string;
carrierName: string;
origin: string;
destination: string;
volumeCBM: number;
weightKG: number;
palletCount: number;
containerType: string;
status: string;
notes?: string;
documents: BookingDoc[];
}
export default function EditBookingPage() {
const router = useRouter();
const params = useParams();
const bookingId = params.id as string;
const [booking, setBooking] = useState<BookingData | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [volumeCBM, setVolumeCBM] = useState('');
const [weightKG, setWeightKG] = useState('');
const [palletCount, setPalletCount] = useState('');
const [notes, setNotes] = useState('');
const [uploadingDocs, setUploadingDocs] = useState(false);
const [deletingDocId, setDeletingDocId] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const applyBooking = (data: BookingData) => {
setBooking(data);
setVolumeCBM(String(data.volumeCBM ?? ''));
setWeightKG(String(data.weightKG ?? ''));
setPalletCount(String(data.palletCount ?? ''));
setNotes(data.notes ?? '');
};
const loadBooking = async () => {
try {
const data = (await getCsvBooking(bookingId)) as any as BookingData;
if (data.status !== 'PENDING_PAYMENT') {
// Only bookings awaiting payment can be edited.
router.replace('/dashboard/bookings');
return;
}
applyBooking(data);
} catch (err) {
setError('Impossible de charger la réservation');
} finally {
setLoading(false);
}
};
useEffect(() => {
if (bookingId) loadBooking();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bookingId]);
const persistDetails = async (): Promise<boolean> => {
setError(null);
const vol = parseFloat(volumeCBM);
const wgt = parseFloat(weightKG);
const plt = parseInt(palletCount, 10);
if (!Number.isFinite(vol) || vol <= 0) {
setError('Le volume doit être un nombre positif.');
return false;
}
if (!Number.isFinite(wgt) || wgt <= 0) {
setError('Le poids doit être un nombre positif.');
return false;
}
if (!Number.isFinite(plt) || plt < 0) {
setError('Le nombre de palettes est invalide.');
return false;
}
const updated = (await updateCsvBookingDetails(bookingId, {
volumeCBM: vol,
weightKG: wgt,
palletCount: plt,
notes: notes.trim() || undefined,
})) as any as BookingData;
applyBooking(updated);
return true;
};
const handleSave = async () => {
setSaving(true);
setSuccess(null);
try {
const ok = await persistDetails();
if (ok) setSuccess('Modifications enregistrées.');
} catch (err) {
setError(err instanceof Error ? err.message : "Erreur lors de l'enregistrement");
} finally {
setSaving(false);
}
};
const handleContinueToPayment = async () => {
setSaving(true);
setSuccess(null);
try {
const ok = await persistDetails();
if (ok) router.push(`/dashboard/booking/${bookingId}/pay`);
} catch (err) {
setError(err instanceof Error ? err.message : "Erreur lors de l'enregistrement");
} finally {
setSaving(false);
}
};
const handleAddDocuments = async (files: FileList | null) => {
if (!files || files.length === 0) return;
setUploadingDocs(true);
setError(null);
try {
const formData = new FormData();
Array.from(files).forEach(file => formData.append('documents', file));
await upload(`/api/v1/csv-bookings/${bookingId}/documents`, formData);
await loadBooking();
} catch (err) {
setError(err instanceof Error ? err.message : "Erreur lors de l'ajout des documents");
} finally {
setUploadingDocs(false);
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
const handleDeleteDocument = async (docId?: string) => {
if (!docId) return;
if (!confirm('Supprimer ce document ?')) return;
setDeletingDocId(docId);
setError(null);
try {
await del(`/api/v1/csv-bookings/${bookingId}/documents/${docId}`);
await loadBooking();
} catch (err) {
setError(err instanceof Error ? err.message : 'Erreur lors de la suppression du document');
} finally {
setDeletingDocId(null);
}
};
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-blue-50">
<div className="flex items-center space-x-3">
<Loader2 className="h-6 w-6 animate-spin text-blue-600" />
<span className="text-gray-600">Chargement...</span>
</div>
</div>
);
}
if (!booking) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-blue-50">
<div className="bg-white rounded-xl shadow-md p-8 max-w-md">
<AlertTriangle className="h-12 w-12 text-red-500 mx-auto mb-4" />
<p className="text-center text-gray-700">{error || 'Réservation introuvable'}</p>
<button
onClick={() => router.push('/dashboard/bookings')}
className="mt-4 w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Retour aux réservations
</button>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50 py-10 px-4">
<div className="max-w-4xl mx-auto">
<button
onClick={() => router.back()}
className="mb-6 flex items-center text-blue-600 hover:text-blue-800 font-medium"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Retour
</button>
<h1 className="text-2xl font-bold text-gray-900 mb-1">Modifier la réservation</h1>
<p className="text-gray-500 mb-8">
Corrigez les informations de votre réservation avant de procéder au paiement.
</p>
{error && (
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4 flex items-start space-x-3">
<AlertTriangle className="h-5 w-5 text-red-500 flex-shrink-0 mt-0.5" />
<p className="text-red-700 text-sm">{error}</p>
</div>
)}
{success && (
<div className="mb-6 bg-green-50 border border-green-200 rounded-lg p-4">
<p className="text-green-700 text-sm">{success}</p>
</div>
)}
{/* Read-only rate summary */}
<div className="bg-white rounded-xl border border-gray-200 p-5 mb-6">
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
Réservation
</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<p className="text-gray-500">Transporteur</p>
<p className="font-semibold text-gray-900">{booking.carrierName}</p>
</div>
<div>
<p className="text-gray-500">Trajet</p>
<p className="font-semibold text-gray-900">
{booking.origin} {booking.destination}
</p>
</div>
<div>
<p className="text-gray-500">Type</p>
<p className="font-semibold text-gray-900">{booking.containerType}</p>
</div>
{booking.bookingNumber && (
<div>
<p className="text-gray-500">Numéro</p>
<p className="font-semibold text-gray-900">{booking.bookingNumber}</p>
</div>
)}
</div>
<p className="text-xs text-gray-400 mt-3">
Le transporteur, le trajet et le prix proviennent du tarif sélectionné et ne sont pas
modifiables ici.
</p>
</div>
{/* Editable cargo details */}
<div className="bg-white rounded-xl border border-gray-200 p-5 mb-6 space-y-4">
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide flex items-center gap-2">
<Package className="h-4 w-4" />
Caractéristiques de la marchandise
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Volume (CBM)</label>
<input
type="number"
min="0"
step="0.01"
value={volumeCBM}
onChange={e => setVolumeCBM(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Poids (kg)</label>
<input
type="number"
min="0"
step="0.01"
value={weightKG}
onChange={e => setWeightKG(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Palettes</label>
<input
type="number"
min="0"
step="1"
value={palletCount}
onChange={e => setPalletCount(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Notes</label>
<textarea
rows={3}
value={notes}
onChange={e => setNotes(e.target.value)}
placeholder="Instructions particulières..."
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
/>
</div>
</div>
{/* Documents */}
<div className="bg-white rounded-xl border border-gray-200 p-5 mb-6">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wide flex items-center gap-2">
<FileText className="h-4 w-4" />
Documents
</h2>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploadingDocs}
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-blue-700 bg-blue-50 hover:bg-blue-100 rounded-lg transition-colors disabled:opacity-50"
>
{uploadingDocs ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}
Ajouter
</button>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={e => handleAddDocuments(e.target.files)}
/>
</div>
{booking.documents.length === 0 ? (
<p className="text-sm text-gray-400">Aucun document.</p>
) : (
<ul className="divide-y divide-gray-100">
{booking.documents.map((doc, idx) => (
<li
key={doc.id || `${doc.fileName}-${idx}`}
className="flex items-center justify-between py-2.5"
>
<div className="flex items-center gap-3 min-w-0">
<FileText className="h-4 w-4 text-gray-400 flex-shrink-0" />
<span className="text-sm text-gray-800 truncate">{doc.fileName}</span>
</div>
{doc.id && (
<button
type="button"
onClick={() => handleDeleteDocument(doc.id)}
disabled={deletingDocId === doc.id}
className="p-1.5 text-gray-400 hover:text-red-600 transition-colors disabled:opacity-50"
title="Supprimer"
>
{deletingDocId === doc.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</button>
)}
</li>
))}
</ul>
)}
</div>
{/* Actions */}
<div className="flex flex-col sm:flex-row gap-3">
<button
onClick={handleSave}
disabled={saving}
className="flex-1 py-3 bg-white border border-gray-300 text-gray-800 rounded-lg font-semibold hover:bg-gray-50 disabled:opacity-50 flex items-center justify-center gap-2 transition-colors"
>
{saving ? <Loader2 className="h-5 w-5 animate-spin" /> : <Save className="h-5 w-5" />}
Enregistrer les modifications
</button>
<button
onClick={handleContinueToPayment}
disabled={saving}
className="flex-1 py-3 bg-blue-600 text-white rounded-lg font-semibold hover:bg-blue-700 disabled:opacity-50 flex items-center justify-center gap-2 transition-colors"
>
{saving ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<CreditCard className="h-5 w-5" />
)}
Continuer vers le paiement
</button>
</div>
</div>
</div>
);
}

View File

@ -223,9 +223,16 @@ export default function PayCommissionPage() {
</button> </button>
<h1 className="text-2xl font-bold text-gray-900 mb-1">Paiement du forfait booking</h1> <h1 className="text-2xl font-bold text-gray-900 mb-1">Paiement du forfait booking</h1>
<p className="text-gray-500 mb-8"> <p className="text-gray-500 mb-4">
Finalisez votre booking en réglant le forfait par booking Finalisez votre booking en réglant le forfait par booking
</p> </p>
<button
onClick={() => router.push(`/dashboard/booking/${bookingId}/edit`)}
className="mb-8 inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-blue-700 bg-blue-50 hover:bg-blue-100 rounded-lg transition-colors"
>
<ArrowLeft className="h-4 w-4" />
Modifier la réservation avant de payer
</button>
{/* SIRET/SIREN non vérifié — bannière de demande */} {/* SIRET/SIREN non vérifié — bannière de demande */}
{siretNotVerified && ( {siretNotVerified && (

View File

@ -4,7 +4,7 @@ import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { listCsvBookings } from '@/lib/api'; import { listCsvBookings } from '@/lib/api';
import { Link } from '@/i18n/navigation'; import { Link } from '@/i18n/navigation';
import { Plus, Clock, Eye, X, CreditCard } from 'lucide-react'; import { Plus, Clock, Eye, X, CreditCard, Pencil } from 'lucide-react';
import ExportButton from '@/components/ExportButton'; import ExportButton from '@/components/ExportButton';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
import { PageHeader } from '@/components/ui/PageHeader'; import { PageHeader } from '@/components/ui/PageHeader';
@ -12,6 +12,9 @@ import { useTranslations, useLocale } from 'next-intl';
type SearchType = 'pallets' | 'weight' | 'route' | 'status' | 'date' | 'quote'; type SearchType = 'pallets' | 'weight' | 'route' | 'status' | 'date' | 'quote';
// Statuses considered "not finalized": booking started but not yet paid.
const TO_FINALIZE_STATUSES = ['PENDING_PAYMENT', 'PENDING_BANK_TRANSFER'];
export default function BookingsListPage() { export default function BookingsListPage() {
const t = useTranslations('dashboard.bookingsList'); const t = useTranslations('dashboard.bookingsList');
const locale = useLocale(); const locale = useLocale();
@ -50,7 +53,13 @@ export default function BookingsListPage() {
let filtered = bookings; let filtered = bookings;
if (statusFilter) { if (statusFilter) {
filtered = filtered.filter((booking: any) => booking.status === statusFilter); if (statusFilter === 'to_finalize') {
filtered = filtered.filter((booking: any) =>
TO_FINALIZE_STATUSES.includes(booking.status)
);
} else {
filtered = filtered.filter((booking: any) => booking.status === statusFilter);
}
} }
if (searchTerm.trim()) { if (searchTerm.trim()) {
@ -101,9 +110,13 @@ export default function BookingsListPage() {
const statusOptions = [ const statusOptions = [
{ value: '', label: t('statusFilter.all') }, { value: '', 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: 'PENDING', label: t('status.pending') },
{ value: 'ACCEPTED', label: t('status.accepted') }, { value: 'ACCEPTED', label: t('status.accepted') },
{ value: 'REJECTED', label: t('status.rejected') }, { value: 'REJECTED', label: t('status.rejected') },
{ value: 'CANCELLED', label: t('status.cancelled') },
]; ];
const searchTypeOptions: { value: SearchType; label: string }[] = [ const searchTypeOptions: { value: SearchType; label: string }[] = [
@ -129,18 +142,24 @@ export default function BookingsListPage() {
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
const colors: Record<string, string> = { const colors: Record<string, string> = {
PENDING_PAYMENT: 'bg-orange-100 text-orange-800',
PENDING_BANK_TRANSFER: 'bg-blue-100 text-blue-800',
PENDING: 'bg-yellow-100 text-yellow-800', PENDING: 'bg-yellow-100 text-yellow-800',
ACCEPTED: 'bg-green-100 text-green-800', ACCEPTED: 'bg-green-100 text-green-800',
REJECTED: 'bg-red-100 text-red-800', REJECTED: 'bg-red-100 text-red-800',
CANCELLED: 'bg-gray-100 text-gray-800',
}; };
return colors[status] || 'bg-gray-100 text-gray-800'; return colors[status] || 'bg-gray-100 text-gray-800';
}; };
const getStatusLabel = (status: string) => { const getStatusLabel = (status: string) => {
const map: Record<string, string> = { const map: Record<string, string> = {
PENDING_PAYMENT: t('status.pendingPayment'),
PENDING_BANK_TRANSFER: t('status.pendingBankTransfer'),
PENDING: t('status.pending'), PENDING: t('status.pending'),
ACCEPTED: t('status.accepted'), ACCEPTED: t('status.accepted'),
REJECTED: t('status.rejected'), REJECTED: t('status.rejected'),
CANCELLED: t('status.cancelled'),
}; };
return map[status] || status; return map[status] || status;
}; };
@ -352,13 +371,22 @@ export default function BookingsListPage() {
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{booking.status === 'PENDING_PAYMENT' && ( {booking.status === 'PENDING_PAYMENT' && (
<Link <>
href={`/dashboard/booking/${booking.id}/pay`} <Link
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-white bg-orange-500 hover:bg-orange-600 rounded-lg transition-colors" href={`/dashboard/booking/${booking.id}/edit`}
> className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
<CreditCard className="h-3.5 w-3.5" /> >
{t('actions.pay')} <Pencil className="h-3.5 w-3.5" />
</Link> {t('actions.edit')}
</Link>
<Link
href={`/dashboard/booking/${booking.id}/pay`}
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-white bg-orange-500 hover:bg-orange-600 rounded-lg transition-colors"
>
<CreditCard className="h-3.5 w-3.5" />
{t('actions.pay')}
</Link>
</>
)} )}
<button <button
onClick={() => setSelectedBooking(booking)} onClick={() => setSelectedBooking(booking)}
@ -378,125 +406,123 @@ export default function BookingsListPage() {
<thead className="bg-gray-50"> <thead className="bg-gray-50">
<tr> <tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('columns.palletsPackages')} {t('columns.reference')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('columns.weight')}
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('columns.route')} {t('columns.route')}
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('columns.cargo')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('columns.status')} {t('columns.status')}
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('columns.date')} {t('columns.date')}
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="sticky right-0 z-10 bg-gray-50 px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('columns.quoteNumber')}
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('columns.bookingNumber')}
</th>
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('columns.actions')} {t('columns.actions')}
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody className="bg-white divide-y divide-gray-200"> <tbody className="bg-white divide-y divide-gray-200">
{paginatedBookings.map((booking: any) => ( {paginatedBookings.map((booking: any) => {
<tr key={`${booking.type}-${booking.id}`} className="hover:bg-gray-50"> const reference =
<td className="px-6 py-4 whitespace-nowrap"> booking.type === 'csv'
<div className="text-sm font-medium text-gray-900"> ? `#${booking.bookingId || booking.id.slice(0, 8).toUpperCase()}`
{booking.type === 'csv' : booking.bookingNumber || `#${booking.id.slice(0, 8).toUpperCase()}`;
? t('units.palletsCount', { count: booking.palletCount }) return (
: t('units.containersCount', { <tr key={`${booking.type}-${booking.id}`} className="group hover:bg-gray-50">
count: booking.containers?.length || 0, <td className="px-6 py-4 whitespace-nowrap">
})} <div className="text-sm font-semibold text-brand-navy">
</div> {booking.bookingNumber || reference}
<div className="text-xs text-gray-500"> </div>
{booking.type === 'csv' ? 'LCL' : booking.containerType || 'FCL'} {booking.bookingNumber && (
</div> <div className="text-xs text-gray-400">{reference}</div>
</td> )}
<td className="px-6 py-4 whitespace-nowrap"> </td>
<div className="text-sm font-medium text-gray-900"> <td className="px-6 py-4">
{booking.type === 'csv' <div className="text-sm text-gray-900">
? t('units.kg', { value: booking.weightKG }) {booking.type === 'csv'
: booking.totalWeight ? `${booking.origin}${booking.destination}`
? t('units.kg', { value: booking.totalWeight }) : booking.route || 'N/A'}
: 'N/A'} </div>
</div> <div className="text-sm text-gray-500">
<div className="text-xs text-gray-500"> {booking.type === 'csv'
{booking.type === 'csv' ? `${booking.carrierName}`
? t('units.cbm', { value: booking.volumeCBM }) : booking.carrier || ''}
: booking.totalVolume </div>
? t('units.cbm', { value: booking.totalVolume }) </td>
: ''} <td className="px-6 py-4 whitespace-nowrap">
</div> <div className="text-sm text-gray-900">
</td> {booking.type === 'csv'
<td className="px-6 py-4"> ? t('units.palletsCount', { count: booking.palletCount })
<div className="text-sm text-gray-900"> : t('units.containersCount', {
{booking.type === 'csv' count: booking.containers?.length || 0,
? `${booking.origin}${booking.destination}` })}
: booking.route || 'N/A'} {' · '}
</div> {booking.type === 'csv' ? 'LCL' : booking.containerType || 'FCL'}
<div className="text-sm text-gray-500"> </div>
{booking.type === 'csv' <div className="text-xs text-gray-500">
? `${booking.carrierName}` {booking.type === 'csv'
: booking.carrier || ''} ? `${t('units.kg', { value: booking.weightKG })} · ${t('units.cbm', { value: booking.volumeCBM })}`
</div> : booking.totalWeight
</td> ? t('units.kg', { value: booking.totalWeight })
<td className="px-6 py-4 whitespace-nowrap"> : ''}
<span </div>
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor( </td>
booking.status <td className="px-6 py-4 whitespace-nowrap">
)}`} <span
> className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(
{getStatusLabel(booking.status)} booking.status
</span> )}`}
</td> >
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> {getStatusLabel(booking.status)}
{booking.createdAt || booking.requestedAt </span>
? new Date(booking.createdAt || booking.requestedAt).toLocaleDateString( </td>
dateLocale, <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{ {booking.createdAt || booking.requestedAt
? new Date(
booking.createdAt || booking.requestedAt
).toLocaleDateString(dateLocale, {
day: '2-digit', day: '2-digit',
month: '2-digit', month: '2-digit',
year: 'numeric', year: 'numeric',
} })
) : 'N/A'}
: 'N/A'} </td>
</td> <td className="sticky right-0 z-10 bg-white group-hover:bg-gray-50 px-6 py-4 whitespace-nowrap text-right shadow-[-8px_0_8px_-8px_rgba(0,0,0,0.08)]">
<td className="px-6 py-4 whitespace-nowrap text-left text-sm font-medium"> <div className="flex items-center justify-end gap-2">
{booking.type === 'csv' {booking.status === 'PENDING_PAYMENT' && (
? `#${booking.bookingId || booking.id.slice(0, 8).toUpperCase()}` <>
: booking.bookingNumber || `#${booking.id.slice(0, 8).toUpperCase()}`} <Link
</td> href={`/dashboard/booking/${booking.id}/edit`}
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium text-brand-navy"> className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
{booking.bookingNumber || '-'} >
</td> <Pencil className="h-3.5 w-3.5" />
<td className="px-6 py-4 whitespace-nowrap text-center"> {t('actions.edit')}
<div className="flex items-center justify-center gap-2"> </Link>
{booking.status === 'PENDING_PAYMENT' && ( <Link
<Link href={`/dashboard/booking/${booking.id}/pay`}
href={`/dashboard/booking/${booking.id}/pay`} className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-white bg-orange-500 hover:bg-orange-600 rounded-lg transition-colors"
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-white bg-orange-500 hover:bg-orange-600 rounded-lg transition-colors" >
<CreditCard className="h-3.5 w-3.5" />
{t('actions.pay')}
</Link>
</>
)}
<button
onClick={() => setSelectedBooking(booking)}
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-blue-700 bg-blue-50 hover:bg-blue-100 rounded-lg transition-colors"
> >
<CreditCard className="h-3.5 w-3.5" /> <Eye className="h-3.5 w-3.5" />
{t('actions.pay')} {t('actions.view')}
</Link> </button>
)} </div>
<button </td>
onClick={() => setSelectedBooking(booking)} </tr>
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-blue-700 bg-blue-50 hover:bg-blue-100 rounded-lg transition-colors" );
> })}
<Eye className="h-3.5 w-3.5" />
{t('actions.view')}
</button>
</div>
</td>
</tr>
))}
</tbody> </tbody>
</table> </table>
</div> </div>
@ -744,14 +770,24 @@ export default function BookingsListPage() {
<p className="text-sm text-orange-700 bg-orange-50 rounded-lg px-3 py-2"> <p className="text-sm text-orange-700 bg-orange-50 rounded-lg px-3 py-2">
{t('detail.pendingPaymentNotice')} {t('detail.pendingPaymentNotice')}
</p> </p>
<Link <div className="mt-3 flex items-center gap-2">
href={`/dashboard/booking/${selectedBooking.id}/pay`} <Link
onClick={() => setSelectedBooking(null)} href={`/dashboard/booking/${selectedBooking.id}/edit`}
className="mt-3 inline-flex items-center gap-2 px-4 py-2 bg-orange-500 text-white text-sm font-medium rounded-lg hover:bg-orange-600 transition-colors" onClick={() => setSelectedBooking(null)}
> className="inline-flex items-center gap-2 px-4 py-2 bg-gray-100 text-gray-700 text-sm font-medium rounded-lg hover:bg-gray-200 transition-colors"
<CreditCard className="h-4 w-4" /> >
{t('detail.payNow')} <Pencil className="h-4 w-4" />
</Link> {t('actions.edit')}
</Link>
<Link
href={`/dashboard/booking/${selectedBooking.id}/pay`}
onClick={() => setSelectedBooking(null)}
className="inline-flex items-center gap-2 px-4 py-2 bg-orange-500 text-white text-sm font-medium rounded-lg hover:bg-orange-600 transition-colors"
>
<CreditCard className="h-4 w-4" />
{t('detail.payNow')}
</Link>
</div>
</div> </div>
)} )}
</div> </div>

View File

@ -434,12 +434,16 @@
}, },
"statusFilter": { "statusFilter": {
"label": "Status", "label": "Status",
"all": "All statuses" "all": "All statuses",
"toFinalize": "To finalize"
}, },
"status": { "status": {
"pendingPayment": "Payment pending",
"pendingBankTransfer": "Transfer pending",
"pending": "Pending", "pending": "Pending",
"accepted": "Accepted", "accepted": "Accepted",
"rejected": "Rejected" "rejected": "Rejected",
"cancelled": "Cancelled"
}, },
"loading": "Loading bookings...", "loading": "Loading bookings...",
"search": "Search", "search": "Search",
@ -447,6 +451,8 @@
"palletsPackages": "Pallets/Packages", "palletsPackages": "Pallets/Packages",
"weight": "Weight", "weight": "Weight",
"route": "Route", "route": "Route",
"cargo": "Cargo",
"reference": "Reference",
"status": "Status", "status": "Status",
"date": "Date", "date": "Date",
"quoteNumber": "Quote No.", "quoteNumber": "Quote No.",
@ -455,7 +461,8 @@
}, },
"actions": { "actions": {
"view": "View", "view": "View",
"pay": "Pay" "pay": "Pay",
"edit": "Edit"
}, },
"detail": { "detail": {
"title": "Booking", "title": "Booking",

View File

@ -434,12 +434,16 @@
}, },
"statusFilter": { "statusFilter": {
"label": "Statut", "label": "Statut",
"all": "Tous les statuts" "all": "Tous les statuts",
"toFinalize": "À finaliser"
}, },
"status": { "status": {
"pendingPayment": "Paiement en attente",
"pendingBankTransfer": "Virement en attente",
"pending": "En attente", "pending": "En attente",
"accepted": "Accepté", "accepted": "Accepté",
"rejected": "Refusé" "rejected": "Refusé",
"cancelled": "Annulé"
}, },
"loading": "Chargement des réservations...", "loading": "Chargement des réservations...",
"search": "Rechercher", "search": "Rechercher",
@ -447,6 +451,8 @@
"palletsPackages": "Palettes/Colis", "palletsPackages": "Palettes/Colis",
"weight": "Poids", "weight": "Poids",
"route": "Route", "route": "Route",
"cargo": "Marchandise",
"reference": "Référence",
"status": "Statut", "status": "Statut",
"date": "Date", "date": "Date",
"quoteNumber": "N° Devis", "quoteNumber": "N° Devis",
@ -455,7 +461,8 @@
}, },
"actions": { "actions": {
"view": "Voir", "view": "Voir",
"pay": "Payer" "pay": "Payer",
"edit": "Modifier"
}, },
"detail": { "detail": {
"title": "Réservation", "title": "Réservation",

View File

@ -263,6 +263,24 @@ export async function cancelCsvBooking(id: string): Promise<SuccessResponse> {
return patch<SuccessResponse>(`/api/v1/csv-bookings/${id}/cancel`, {}); return patch<SuccessResponse>(`/api/v1/csv-bookings/${id}/cancel`, {});
} }
/**
* Update cargo details of a booking awaiting payment
* PATCH /api/v1/csv-bookings/:id/details
*/
export interface UpdateCsvBookingDetailsRequest {
volumeCBM?: number;
weightKG?: number;
palletCount?: number;
notes?: string;
}
export async function updateCsvBookingDetails(
id: string,
data: UpdateCsvBookingDetailsRequest
): Promise<CsvBookingResponse> {
return patch<CsvBookingResponse>(`/api/v1/csv-bookings/${id}/details`, data);
}
/** /**
* Accept a CSV booking (public endpoint, no auth required) * Accept a CSV booking (public endpoint, no auth required)
* POST /api/v1/csv-bookings/:token/accept * POST /api/v1/csv-bookings/:token/accept