xpeditis2.0/apps/frontend/app/[locale]/dashboard/documents/page.tsx
2026-08-13 12:26:46 +02:00

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',
'PENDING_PAYMENT',
'PENDING_BANK_TRANSFER',
];
const STATUS_KEYS = [
'PENDING_PAYMENT',
'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}
/>
</>
);
}