'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([]); const [documents, setDocuments] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [addOpen, setAddOpen] = useState(false); const [documentToReplace, setDocumentToReplace] = useState(null); const [documentToDelete, setDocumentToDelete] = useState(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[]>( () => [ { id: 'fileName', header: t('table.documentName'), enableSorting: false, meta: { cardRole: 'title', headerLabel: t('table.documentName') }, cell: ({ row }) => (

{row.original.fileName}

{row.original.size > 0 && (

{formatBytes(row.original.size)}

)}
), }, { 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 }) => ( {statusLabel(row.original.status)} ), }, { id: 'actions', header: t('table.actions'), size: 64, enableSorting: false, meta: { align: 'right', cardRole: 'actions' }, cell: ({ row }) => { const doc = row.original; return ( handleDownload(doc.filePath, doc.fileName)}> {t('actions.download')} setDocumentToReplace(doc)}> {t('actions.replace')} setDocumentToDelete(doc)}> {t('actions.delete')} ); }, }, ], // 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 ( <> statusLabel(v) }, { key: 'uploadedAt', label: t('export.uploadedAt'), format: v => (v ? new Date(v).toLocaleDateString(locale) : ''), }, ]} /> } />
b.documents && b.documents.length > 0).length} loading={loading} />
url.clear()} labels={{ filters: tCommon('filters'), clearAll: tCommon('clearFilters'), remove: tCommon('removeFilter'), }} filters={ <> url.set('quote', e.target.value || undefined)} placeholder={t('filters.quoteNumber')} aria-label={t('filters.quoteNumber')} className="w-full md:w-44" /> } > url.set('q', value || undefined)} placeholder={t('filters.searchPlaceholder')} ariaLabel={t('filters.search')} /> 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 => {chunks}, }), }, }} />
!open && setDocumentToReplace(null)} onSubmit={replaceDocument} /> !open && setDocumentToDelete(null)} title={t('deleteDocument.title')} description={t('deleteDocument.message')} confirmLabel={t('deleteDocument.confirm')} cancelLabel={t('deleteDocument.cancel')} variant="destructive" onConfirm={deleteDocument} /> ); }