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