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
550 lines
18 KiB
TypeScript
550 lines
18 KiB
TypeScript
'use client';
|
|
|
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import { useLocale, useTranslations } from 'next-intl';
|
|
import type { ColumnDef } from '@tanstack/react-table';
|
|
import { Download, FileText, MoreVertical, Plus, RefreshCw, Trash2 } from 'lucide-react';
|
|
|
|
import { listCsvBookings, type CsvBookingResponse } from '@/lib/api/bookings';
|
|
import { PageHeader } from '@/components/ui/PageHeader';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
|
import { useToast } from '@/components/ui/toast';
|
|
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 { StatCard } from '@/components/data/stat-card';
|
|
import { BOOKING_STATUS_VARIANT } from '@/components/bookings/BookingStatusBadge';
|
|
import { AddDocumentsDialog } from '@/components/documents/AddDocumentsDialog';
|
|
import { ReplaceDocumentDialog } from '@/components/documents/ReplaceDocumentDialog';
|
|
import { DocumentIcon, fileTypeLabel } from '@/components/documents/document-icon';
|
|
import { formatBytes } from '@/components/forms/file-dropzone';
|
|
import ExportButton from '@/components/ExportButton';
|
|
import { useUrlState } from '@/hooks/use-url-state';
|
|
|
|
interface DocumentWithBooking {
|
|
id: string;
|
|
fileName: string;
|
|
filePath: string;
|
|
type: string;
|
|
mimeType: string;
|
|
size: number;
|
|
uploadedAt?: Date;
|
|
bookingId: string;
|
|
quoteNumber: string;
|
|
route: string;
|
|
status: string;
|
|
carrierName: string;
|
|
fileType?: string;
|
|
}
|
|
|
|
const DOCUMENTABLE_STATUSES = [
|
|
'PENDING',
|
|
'ACCEPTED',
|
|
'QUOTE',
|
|
'PENDING_BANK_TRANSFER',
|
|
];
|
|
|
|
const STATUS_KEYS = [
|
|
'QUOTE',
|
|
'PENDING_BANK_TRANSFER',
|
|
'PENDING',
|
|
'ACCEPTED',
|
|
'REJECTED',
|
|
'CANCELLED',
|
|
];
|
|
|
|
const getQuoteNumber = (booking: CsvBookingResponse): string =>
|
|
`#${booking.bookingId || booking.id.slice(0, 8).toUpperCase()}`;
|
|
|
|
export default function UserDocumentsPage() {
|
|
const t = useTranslations('dashboard.userDocuments');
|
|
const tCommon = useTranslations('common');
|
|
const tErrors = useTranslations('errors');
|
|
const locale = useLocale();
|
|
const { toast } = useToast();
|
|
const url = useUrlState();
|
|
|
|
const [bookings, setBookings] = useState<CsvBookingResponse[]>([]);
|
|
const [documents, setDocuments] = useState<DocumentWithBooking[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const [addOpen, setAddOpen] = useState(false);
|
|
const [documentToReplace, setDocumentToReplace] = useState<DocumentWithBooking | null>(null);
|
|
const [documentToDelete, setDocumentToDelete] = useState<DocumentWithBooking | null>(null);
|
|
|
|
// Etat de vue dans l'URL : la liste filtree est partageable et survit au
|
|
// rechargement. Remplace six `useState` locaux.
|
|
const searchTerm = url.get('q') ?? '';
|
|
const quoteFilter = url.get('quote') ?? '';
|
|
const statusFilter = url.get('status') ?? 'all';
|
|
const page = url.getNumber('page', 1);
|
|
const pageSize = url.getNumber('size', 10);
|
|
|
|
const statusLabel = useCallback(
|
|
(status: string) => {
|
|
try {
|
|
return t(`statuses.${status}` as any);
|
|
} catch {
|
|
return status;
|
|
}
|
|
},
|
|
[t]
|
|
);
|
|
|
|
const fetchBookingsAndDocuments = useCallback(async () => {
|
|
try {
|
|
setLoading(true);
|
|
const response = await listCsvBookings({ page: 1, limit: 1000 });
|
|
const allBookings = response.bookings || [];
|
|
setBookings(allBookings);
|
|
|
|
const allDocuments: DocumentWithBooking[] = [];
|
|
allBookings.forEach((booking: CsvBookingResponse) => {
|
|
booking.documents?.forEach((doc: any, index: number) => {
|
|
const fileName = doc.fileName || doc.name || 'document';
|
|
const mimeType = doc.mimeType || doc.type || '';
|
|
|
|
allDocuments.push({
|
|
id: doc.id || `${booking.id}-doc-${index}`,
|
|
fileName,
|
|
filePath: doc.filePath || doc.url || '',
|
|
type: doc.type || '',
|
|
mimeType,
|
|
size: doc.size || 0,
|
|
uploadedAt: doc.uploadedAt,
|
|
bookingId: booking.id,
|
|
quoteNumber: getQuoteNumber(booking),
|
|
route: `${booking.origin || 'N/A'} → ${booking.destination || 'N/A'}`,
|
|
status: booking.status,
|
|
carrierName: booking.carrierName || 'N/A',
|
|
fileType: mimeType.includes('/')
|
|
? fileTypeLabel(`.${mimeType.split('/')[1]}`)
|
|
: fileTypeLabel(fileName),
|
|
});
|
|
});
|
|
});
|
|
|
|
setDocuments(allDocuments);
|
|
setError(null);
|
|
} catch (err: any) {
|
|
setError(err?.message || t('error'));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [t]);
|
|
|
|
useEffect(() => {
|
|
fetchBookingsAndDocuments();
|
|
}, [fetchBookingsAndDocuments]);
|
|
|
|
const filteredDocuments = useMemo(() => {
|
|
const term = searchTerm.toLowerCase();
|
|
const quote = quoteFilter.toLowerCase();
|
|
|
|
return documents.filter(doc => {
|
|
const matchesSearch =
|
|
term === '' ||
|
|
doc.fileName?.toLowerCase().includes(term) ||
|
|
doc.type?.toLowerCase().includes(term) ||
|
|
doc.route.toLowerCase().includes(term) ||
|
|
doc.carrierName.toLowerCase().includes(term);
|
|
|
|
const matchesStatus = statusFilter === 'all' || doc.status === statusFilter;
|
|
const matchesQuote = quote === '' || doc.quoteNumber.toLowerCase().includes(quote);
|
|
|
|
return matchesSearch && matchesStatus && matchesQuote;
|
|
});
|
|
}, [documents, searchTerm, quoteFilter, statusFilter]);
|
|
|
|
const pageCount = Math.max(1, Math.ceil(filteredDocuments.length / pageSize));
|
|
const safePage = Math.min(page, pageCount);
|
|
const startIndex = (safePage - 1) * pageSize;
|
|
const paginated = filteredDocuments.slice(startIndex, startIndex + pageSize);
|
|
|
|
const documentableBookings = bookings.filter(b => DOCUMENTABLE_STATUSES.includes(b.status));
|
|
|
|
/** Telechargement via blob, pour forcer l'enregistrement plutot que l'ouverture. */
|
|
const handleDownload = async (fileUrl: string, fileName: string) => {
|
|
try {
|
|
const response = await fetch(fileUrl, { mode: 'cors', credentials: 'include' });
|
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
|
|
const blob = await response.blob();
|
|
const blobUrl = window.URL.createObjectURL(blob);
|
|
const link = window.document.createElement('a');
|
|
link.href = blobUrl;
|
|
link.download = fileName;
|
|
window.document.body.appendChild(link);
|
|
link.click();
|
|
window.document.body.removeChild(link);
|
|
window.URL.revokeObjectURL(blobUrl);
|
|
} catch (err) {
|
|
toast.error(t('downloadError'), {
|
|
description: err instanceof Error ? err.message : undefined,
|
|
});
|
|
}
|
|
};
|
|
|
|
const uploadDocuments = async (bookingId: string, files: File[]) => {
|
|
const formData = new FormData();
|
|
files.forEach(file => formData.append('documents', file));
|
|
|
|
const response = await fetch(
|
|
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/csv-bookings/${bookingId}/documents`,
|
|
{ method: 'POST', credentials: 'include', body: formData }
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const data = await response.json().catch(() => ({}));
|
|
toast.error(t('addDocument.errorMessage'), { description: data.message });
|
|
return;
|
|
}
|
|
|
|
toast.success(t('addDocument.successMessage'));
|
|
setAddOpen(false);
|
|
fetchBookingsAndDocuments();
|
|
};
|
|
|
|
const replaceDocument = async (file: File) => {
|
|
if (!documentToReplace) return;
|
|
|
|
const formData = new FormData();
|
|
formData.append('document', file);
|
|
|
|
const response = await fetch(
|
|
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/csv-bookings/${documentToReplace.bookingId}/documents/${documentToReplace.id}`,
|
|
{ method: 'PATCH', credentials: 'include', body: formData }
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const data = await response.json().catch(() => ({}));
|
|
toast.error(t('replaceDocument.errorMessage'), { description: data.message });
|
|
return;
|
|
}
|
|
|
|
toast.success(t('replaceDocument.successMessage'));
|
|
setDocumentToReplace(null);
|
|
fetchBookingsAndDocuments();
|
|
};
|
|
|
|
const deleteDocument = async () => {
|
|
if (!documentToDelete) return;
|
|
|
|
const response = await fetch(
|
|
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/csv-bookings/${documentToDelete.bookingId}/documents/${documentToDelete.id}`,
|
|
{ method: 'DELETE', credentials: 'include' }
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const data = await response.json().catch(() => ({}));
|
|
toast.error(t('deleteDocument.errorMessage'), { description: data.message });
|
|
return;
|
|
}
|
|
|
|
toast.success(t('deleteDocument.successMessage'));
|
|
setDocumentToDelete(null);
|
|
fetchBookingsAndDocuments();
|
|
};
|
|
|
|
const columns = useMemo<ColumnDef<DocumentWithBooking, any>[]>(
|
|
() => [
|
|
{
|
|
id: 'fileName',
|
|
header: t('table.documentName'),
|
|
enableSorting: false,
|
|
meta: { cardRole: 'title', headerLabel: t('table.documentName') },
|
|
cell: ({ row }) => (
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<DocumentIcon type={row.original.mimeType || row.original.fileType || ''} />
|
|
<div className="min-w-0">
|
|
<p className="truncate font-medium text-brand-navy">{row.original.fileName}</p>
|
|
{row.original.size > 0 && (
|
|
<p className="text-body-xs text-neutral-400">{formatBytes(row.original.size)}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
accessorKey: 'fileType',
|
|
header: t('table.type'),
|
|
enableSorting: false,
|
|
meta: { hideBelow: 'xl', cardRole: 'field', headerLabel: t('table.type') },
|
|
},
|
|
{
|
|
accessorKey: 'quoteNumber',
|
|
header: t('table.quoteNumber'),
|
|
enableSorting: false,
|
|
meta: { cardRole: 'field', headerLabel: t('table.quoteNumber') },
|
|
},
|
|
{
|
|
accessorKey: 'route',
|
|
header: t('table.route'),
|
|
enableSorting: false,
|
|
meta: { hideBelow: 'lg', cardRole: 'subtitle', headerLabel: t('table.route') },
|
|
},
|
|
{
|
|
accessorKey: 'carrierName',
|
|
header: t('table.carrier'),
|
|
enableSorting: false,
|
|
meta: { hideBelow: 'xl', cardRole: 'field', headerLabel: t('table.carrier') },
|
|
},
|
|
{
|
|
id: 'status',
|
|
header: t('table.status'),
|
|
enableSorting: false,
|
|
meta: { cardRole: 'field', headerLabel: t('table.status') },
|
|
cell: ({ row }) => (
|
|
<Badge variant={BOOKING_STATUS_VARIANT[row.original.status] ?? 'neutral'}>
|
|
{statusLabel(row.original.status)}
|
|
</Badge>
|
|
),
|
|
},
|
|
{
|
|
id: 'actions',
|
|
header: t('table.actions'),
|
|
size: 64,
|
|
enableSorting: false,
|
|
meta: { align: 'right', cardRole: 'actions' },
|
|
cell: ({ row }) => {
|
|
const doc = row.original;
|
|
return (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" size="icon-sm" aria-label={t('table.actions')}>
|
|
<MoreVertical />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent>
|
|
<DropdownMenuItem onSelect={() => handleDownload(doc.filePath, doc.fileName)}>
|
|
<Download />
|
|
{t('actions.download')}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onSelect={() => setDocumentToReplace(doc)}>
|
|
<RefreshCw />
|
|
{t('actions.replace')}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem variant="destructive" onSelect={() => setDocumentToDelete(doc)}>
|
|
<Trash2 />
|
|
{t('actions.delete')}
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
);
|
|
},
|
|
},
|
|
],
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[t, statusLabel]
|
|
);
|
|
|
|
const activeFilters: ActiveFilter[] = [];
|
|
if (statusFilter !== 'all') {
|
|
activeFilters.push({
|
|
key: 'status',
|
|
label: t('filters.status'),
|
|
value: statusLabel(statusFilter),
|
|
onRemove: () => url.set('status', undefined),
|
|
});
|
|
}
|
|
if (quoteFilter) {
|
|
activeFilters.push({
|
|
key: 'quote',
|
|
label: t('filters.quoteNumber'),
|
|
value: quoteFilter,
|
|
onRemove: () => url.set('quote', undefined),
|
|
});
|
|
}
|
|
if (searchTerm) {
|
|
activeFilters.push({
|
|
key: 'q',
|
|
label: t('filters.search'),
|
|
value: searchTerm,
|
|
onRemove: () => url.set('q', undefined),
|
|
});
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<PageHeader
|
|
title={t('title')}
|
|
description={t('description')}
|
|
actions={
|
|
<>
|
|
<ExportButton
|
|
data={filteredDocuments}
|
|
filename="documents"
|
|
columns={[
|
|
{ key: 'fileName', label: t('export.fileName') },
|
|
{ key: 'fileType', label: t('export.type') },
|
|
{ key: 'quoteNumber', label: t('export.quoteNumber') },
|
|
{ key: 'route', label: t('export.route') },
|
|
{ key: 'carrierName', label: t('export.carrier') },
|
|
{ key: 'status', label: t('export.status'), format: v => statusLabel(v) },
|
|
{
|
|
key: 'uploadedAt',
|
|
label: t('export.uploadedAt'),
|
|
format: v => (v ? new Date(v).toLocaleDateString(locale) : ''),
|
|
},
|
|
]}
|
|
/>
|
|
<Button
|
|
onClick={() => setAddOpen(true)}
|
|
disabled={documentableBookings.length === 0}
|
|
>
|
|
<Plus />
|
|
{t('addDocument.buttonLabel')}
|
|
</Button>
|
|
</>
|
|
}
|
|
/>
|
|
|
|
<div className="flex flex-col gap-4">
|
|
<section className="grid gap-4 sm:grid-cols-3">
|
|
<StatCard label={t('stats.total')} value={documents.length} loading={loading} />
|
|
<StatCard
|
|
label={t('stats.withDocuments')}
|
|
value={bookings.filter(b => b.documents && b.documents.length > 0).length}
|
|
loading={loading}
|
|
/>
|
|
<StatCard
|
|
label={t('stats.filtered')}
|
|
value={filteredDocuments.length}
|
|
loading={loading}
|
|
/>
|
|
</section>
|
|
|
|
<FilterBar
|
|
active={activeFilters}
|
|
onClearAll={() => url.clear()}
|
|
labels={{
|
|
filters: tCommon('filters'),
|
|
clearAll: tCommon('clearFilters'),
|
|
remove: tCommon('removeFilter'),
|
|
}}
|
|
filters={
|
|
<>
|
|
<Input
|
|
value={quoteFilter}
|
|
onChange={e => url.set('quote', e.target.value || undefined)}
|
|
placeholder={t('filters.quoteNumber')}
|
|
aria-label={t('filters.quoteNumber')}
|
|
className="w-full md:w-44"
|
|
/>
|
|
|
|
<Select
|
|
value={statusFilter}
|
|
onValueChange={value => url.set('status', value === 'all' ? undefined : value)}
|
|
>
|
|
<SelectTrigger className="w-full md:w-48" aria-label={t('filters.status')}>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">{t('filters.allStatuses')}</SelectItem>
|
|
{STATUS_KEYS.map(status => (
|
|
<SelectItem key={status} value={status}>
|
|
{statusLabel(status)}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</>
|
|
}
|
|
>
|
|
<SearchInput
|
|
value={searchTerm}
|
|
onDebouncedChange={value => url.set('q', value || undefined)}
|
|
placeholder={t('filters.searchPlaceholder')}
|
|
ariaLabel={t('filters.search')}
|
|
/>
|
|
</FilterBar>
|
|
|
|
<DataTable
|
|
columns={columns}
|
|
data={paginated}
|
|
loading={loading}
|
|
error={
|
|
error
|
|
? {
|
|
title: tErrors('generic'),
|
|
detail: error,
|
|
retryLabel: tCommon('retry'),
|
|
onRetry: fetchBookingsAndDocuments,
|
|
}
|
|
: null
|
|
}
|
|
empty={{
|
|
icon: FileText,
|
|
title: t('empty.noDocuments'),
|
|
description:
|
|
searchTerm || quoteFilter || statusFilter !== 'all' ? t('empty.noMatch') : undefined,
|
|
}}
|
|
caption={t('title')}
|
|
pagination={{
|
|
page: safePage,
|
|
pageCount,
|
|
onPageChange: next => url.set('page', next > 1 ? next : undefined),
|
|
totalItems: filteredDocuments.length,
|
|
pageSize,
|
|
onPageSizeChange: size => url.setMany({ size, page: undefined }),
|
|
labels: {
|
|
previous: t('pagination.previous'),
|
|
next: t('pagination.next'),
|
|
itemsPerPage: t('pagination.perPage'),
|
|
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>
|
|
|
|
<AddDocumentsDialog
|
|
open={addOpen}
|
|
onOpenChange={setAddOpen}
|
|
bookings={documentableBookings}
|
|
getQuoteNumber={getQuoteNumber}
|
|
onSubmit={uploadDocuments}
|
|
/>
|
|
|
|
<ReplaceDocumentDialog
|
|
document={documentToReplace}
|
|
onOpenChange={open => !open && setDocumentToReplace(null)}
|
|
onSubmit={replaceDocument}
|
|
/>
|
|
|
|
<ConfirmDialog
|
|
open={Boolean(documentToDelete)}
|
|
onOpenChange={open => !open && setDocumentToDelete(null)}
|
|
title={t('deleteDocument.title')}
|
|
description={t('deleteDocument.message')}
|
|
confirmLabel={t('deleteDocument.confirm')}
|
|
cancelLabel={t('deleteDocument.cancel')}
|
|
variant="destructive"
|
|
onConfirm={deleteDocument}
|
|
/>
|
|
</>
|
|
);
|
|
}
|