xpeditis2.0/apps/frontend/app/[locale]/dashboard/booking/[id]/edit/page.tsx
David 7234343e20 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>
2026-07-01 13:30:05 +02:00

417 lines
15 KiB
TypeScript

/**
* 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>
);
}