- 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>
417 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|