xpeditis2.0/apps/frontend/app/[locale]/dashboard/admin/documents/page.tsx
2026-05-12 21:01:52 +02:00

685 lines
28 KiB
TypeScript

'use client';
import { useState, useEffect, useCallback } from 'react';
import { useTranslations, useLocale } from 'next-intl';
import { getAllBookings, getAllUsers, deleteAdminDocument } from '@/lib/api/admin';
import { FileText, Image as ImageIcon, FileEdit, FileSpreadsheet, Paperclip } from 'lucide-react';
import type { ReactNode } from 'react';
import { PageHeader } from '@/components/ui/PageHeader';
interface Document {
id: string;
fileName: string;
filePath: string;
type: string;
mimeType: string;
size: number;
uploadedAt?: Date;
// Legacy fields for compatibility
name?: string;
url?: string;
}
interface Booking {
id: string;
bookingNumber?: string;
bookingId?: string;
type?: string;
userId?: string;
organizationId?: string;
origin?: string;
destination?: string;
carrierName?: string;
documents?: Document[];
requestedAt?: string;
status: string;
}
interface DocumentWithBooking extends Document {
bookingId: string;
quoteNumber: string;
userId: string;
userName?: string;
organizationId: string;
route: string;
status: string;
fileType?: string;
}
export default function AdminDocumentsPage() {
const t = useTranslations('dashboard.admin.documents');
const locale = useLocale();
const dateLocale = locale === 'fr' ? 'fr-FR' : 'en-US';
const [bookings, setBookings] = useState<Booking[]>([]);
const [documents, setDocuments] = useState<DocumentWithBooking[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [filterUserId, setFilterUserId] = useState('all');
const [filterQuoteNumber, setFilterQuoteNumber] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10);
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
const [menuPosition, setMenuPosition] = useState<{ top: number; left: number } | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
// Helper function to get formatted quote number
const getQuoteNumber = (booking: Booking): string => {
if (booking.type === 'csv') {
return `#${booking.bookingId || booking.id.slice(0, 8).toUpperCase()}`;
}
return booking.bookingNumber || `#${booking.id.slice(0, 8).toUpperCase()}`;
};
// Get file extension and type
const getFileType = (fileName: string): string => {
const ext = fileName.split('.').pop()?.toLowerCase() || '';
const typeMap: Record<string, string> = {
pdf: 'PDF',
doc: 'Word',
docx: 'Word',
xls: 'Excel',
xlsx: 'Excel',
jpg: 'Image',
jpeg: 'Image',
png: 'Image',
gif: 'Image',
txt: 'Text',
csv: 'CSV',
};
return typeMap[ext] || ext.toUpperCase();
};
const fetchBookingsAndDocuments = useCallback(async () => {
try {
setLoading(true);
const response = await getAllBookings();
const allBookings = response.bookings || [];
setBookings(allBookings);
const allDocuments: DocumentWithBooking[] = [];
const userIds = new Set<string>();
allBookings.forEach((booking: Booking) => {
userIds.add(booking.userId);
if (booking.documents && booking.documents.length > 0) {
booking.documents.forEach((doc: Document) => {
const actualFileName = doc.fileName || doc.name || 'document';
const actualFilePath = doc.filePath || doc.url || '';
const actualMimeType = doc.mimeType || doc.type || '';
let fileType = '';
if (actualMimeType.includes('/')) {
const parts = actualMimeType.split('/');
fileType = getFileType(parts[1]);
} else {
fileType = getFileType(actualFileName);
}
allDocuments.push({
...doc,
bookingId: booking.id,
quoteNumber: getQuoteNumber(booking),
userId: booking.userId,
organizationId: booking.organizationId,
route: `${booking.origin || 'N/A'}${booking.destination || 'N/A'}`,
status: booking.status,
fileName: actualFileName,
filePath: actualFilePath,
fileType: fileType,
});
});
}
});
try {
const usersData = await getAllUsers();
if (usersData && usersData.users) {
const usersMap = new Map(
usersData.users.map((u: any) => {
const fullName = `${u.firstName || ''} ${u.lastName || ''}`.trim();
return [u.id, fullName || u.email || u.id.substring(0, 8)];
})
);
allDocuments.forEach(doc => {
const userName = usersMap.get(doc.userId);
doc.userName = userName || doc.userId.substring(0, 8) + '...';
});
}
} catch (userError) {
console.error('Failed to fetch user names:', userError);
allDocuments.forEach(doc => {
doc.userName = doc.userId.substring(0, 8) + '...';
});
}
setDocuments(allDocuments);
setError(null);
} catch (err: any) {
setError(err.message || t('loadError'));
} finally {
setLoading(false);
}
}, [t]);
useEffect(() => {
fetchBookingsAndDocuments();
}, [fetchBookingsAndDocuments]);
const uniqueUsers = Array.from(
new Map(
documents.map(doc => [
doc.userId,
{ id: doc.userId, name: doc.userName || doc.userId.substring(0, 8) + '...' },
])
).values()
);
const filteredDocuments = documents.filter(doc => {
const matchesSearch =
searchTerm === '' ||
(doc.fileName && doc.fileName.toLowerCase().includes(searchTerm.toLowerCase())) ||
(doc.name && doc.name.toLowerCase().includes(searchTerm.toLowerCase())) ||
(doc.type && doc.type.toLowerCase().includes(searchTerm.toLowerCase())) ||
doc.route.toLowerCase().includes(searchTerm.toLowerCase());
const matchesUser = filterUserId === 'all' || doc.userId === filterUserId;
const matchesQuote =
filterQuoteNumber === '' ||
doc.quoteNumber.toLowerCase().includes(filterQuoteNumber.toLowerCase());
return matchesSearch && matchesUser && matchesQuote;
});
const totalPages = Math.ceil(filteredDocuments.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
const paginatedDocuments = filteredDocuments.slice(startIndex, endIndex);
useEffect(() => {
setCurrentPage(1);
}, [searchTerm, filterUserId, filterQuoteNumber]);
const getDocumentIcon = (type: string): ReactNode => {
const typeLower = type.toLowerCase();
const cls = 'h-6 w-6';
const iconMap: Record<string, ReactNode> = {
'application/pdf': <FileText className={`${cls} text-red-500`} />,
'image/jpeg': <ImageIcon className={`${cls} text-green-500`} />,
'image/png': <ImageIcon className={`${cls} text-green-500`} />,
'image/jpg': <ImageIcon className={`${cls} text-green-500`} />,
pdf: <FileText className={`${cls} text-red-500`} />,
jpeg: <ImageIcon className={`${cls} text-green-500`} />,
jpg: <ImageIcon className={`${cls} text-green-500`} />,
png: <ImageIcon className={`${cls} text-green-500`} />,
gif: <ImageIcon className={`${cls} text-green-500`} />,
image: <ImageIcon className={`${cls} text-green-500`} />,
word: <FileEdit className={`${cls} text-blue-500`} />,
doc: <FileEdit className={`${cls} text-blue-500`} />,
docx: <FileEdit className={`${cls} text-blue-500`} />,
excel: <FileSpreadsheet className={`${cls} text-green-600`} />,
xls: <FileSpreadsheet className={`${cls} text-green-600`} />,
xlsx: <FileSpreadsheet className={`${cls} text-green-600`} />,
csv: <FileSpreadsheet className={`${cls} text-green-600`} />,
text: <FileText className={`${cls} text-gray-500`} />,
txt: <FileText className={`${cls} text-gray-500`} />,
};
return iconMap[typeLower] || <Paperclip className={`${cls} text-gray-400`} />;
};
const getStatusColor = (status: string) => {
const colors: Record<string, string> = {
pending: 'bg-yellow-100 text-yellow-800',
accepted: 'bg-green-100 text-green-800',
rejected: 'bg-red-100 text-red-800',
cancelled: 'bg-gray-100 text-gray-800',
};
return colors[status.toLowerCase()] || 'bg-gray-100 text-gray-800';
};
const handleDeleteDocument = async (bookingId: string, documentId: string) => {
if (!window.confirm(t('confirmDelete'))) return;
setDeletingId(documentId);
try {
await deleteAdminDocument(bookingId, documentId);
setDocuments(prev => prev.filter(d => d.id !== documentId));
} catch (err: any) {
setError(err.message || t('deleteError'));
} finally {
setDeletingId(null);
}
};
const handleDownload = async (url: string, fileName: string) => {
try {
const link = document.createElement('a');
link.href = url;
link.download = fileName;
link.target = '_blank';
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
setTimeout(async () => {
try {
const response = await fetch(url, {
mode: 'cors',
credentials: 'include',
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
const link2 = document.createElement('a');
link2.href = blobUrl;
link2.download = fileName;
document.body.appendChild(link2);
link2.click();
document.body.removeChild(link2);
window.URL.revokeObjectURL(blobUrl);
} catch (fetchError) {
console.error('Fetch download failed:', fetchError);
}
}, 100);
} catch (error) {
console.error('Error downloading file:', error);
const message = error instanceof Error ? error.message : t('unknownError');
alert(t('downloadError', { message }));
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-96">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">{t('loading')}</p>
</div>
</div>
);
}
return (
<div className="space-y-6">
<PageHeader title={t('title')} description={t('subtitle')} />
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="text-sm text-gray-500">{t('stats.totalDocs')}</div>
<div className="text-2xl font-bold text-gray-900">{documents.length}</div>
</div>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="text-sm text-gray-500">{t('stats.bookingsWithDocs')}</div>
<div className="text-2xl font-bold text-blue-600">
{bookings.filter(b => b.documents && b.documents.length > 0).length}
</div>
</div>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="text-sm text-gray-500">{t('stats.filtered')}</div>
<div className="text-2xl font-bold text-green-600">{filteredDocuments.length}</div>
</div>
</div>
{/* Filters */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{t('filters.search')}
</label>
<input
type="text"
placeholder={t('filters.searchPlaceholder')}
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{t('filters.quoteNumber')}
</label>
<input
type="text"
placeholder={t('filters.quoteNumberPlaceholder')}
value={filterQuoteNumber}
onChange={e => setFilterQuoteNumber(e.target.value)}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{t('filters.user')}
</label>
<select
value={filterUserId}
onChange={e => setFilterUserId(e.target.value)}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
>
<option value="all">{t('filters.allUsers')}</option>
{uniqueUsers.map(user => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))}
</select>
</div>
</div>
</div>
{/* Error Message */}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
{error}
</div>
)}
{/* Documents Table */}
<div className="bg-white rounded-lg shadow overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('table.name')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('table.type')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('table.quoteNumber')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('table.route')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('table.status')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('table.user')}
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('table.actions')}
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{paginatedDocuments.length === 0 ? (
<tr>
<td colSpan={7} className="px-6 py-12 text-center text-gray-500">
{t('table.empty')}
</td>
</tr>
) : (
paginatedDocuments.map((doc, index) => (
<tr key={`${doc.bookingId}-${index}`} className="hover:bg-gray-50">
<td className="px-6 py-4">
<div className="text-sm font-medium text-gray-900">
{doc.fileName || doc.name}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<span className="mr-2">{getDocumentIcon(doc.fileType || doc.type)}</span>
<div className="text-xs text-gray-500">{doc.fileType || doc.type}</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">{doc.quoteNumber}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{doc.route}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(doc.status)}`}
>
{doc.status}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{doc.userName || doc.userId.substring(0, 8) + '...'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<button
onClick={e => {
const menuKey = `${doc.bookingId}::${doc.id}`;
if (openMenuId === menuKey) {
setOpenMenuId(null);
setMenuPosition(null);
} else {
const rect = e.currentTarget.getBoundingClientRect();
setMenuPosition({ top: rect.bottom + 5, left: rect.left - 180 });
setOpenMenuId(menuKey);
}
}}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<svg
className="w-5 h-5 text-gray-600"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
</svg>
</button>
</td>
</tr>
))
)}
</tbody>
</table>
{/* Pagination Controls */}
{filteredDocuments.length > 0 && (
<div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
<div className="flex-1 flex justify-between sm:hidden">
<button
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
{t('pagination.previous')}
</button>
<button
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
{t('pagination.next')}
</button>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p className="text-sm text-gray-700">
{t('pagination.showing')} <span className="font-medium">{startIndex + 1}</span>{' '}
{t('pagination.to')}{' '}
<span className="font-medium">
{Math.min(endIndex, filteredDocuments.length)}
</span>{' '}
{t('pagination.on')}{' '}
<span className="font-medium">{filteredDocuments.length}</span>{' '}
{t('pagination.results')}
</p>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<label className="text-sm text-gray-700">{t('pagination.perPage')}</label>
<select
value={itemsPerPage}
onChange={e => {
setItemsPerPage(Number(e.target.value));
setCurrentPage(1);
}}
className="border border-gray-300 rounded-md px-2 py-1 text-sm"
>
<option value={5}>5</option>
<option value={10}>10</option>
<option value={25}>25</option>
<option value={50}>50</option>
<option value={100}>100</option>
</select>
</div>
<nav
className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px"
aria-label="Pagination"
>
<button
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span className="sr-only">{t('pagination.previous')}</span>
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</button>
{/* Page numbers */}
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
let pageNum;
if (totalPages <= 5) {
pageNum = i + 1;
} else if (currentPage <= 3) {
pageNum = i + 1;
} else if (currentPage >= totalPages - 2) {
pageNum = totalPages - 4 + i;
} else {
pageNum = currentPage - 2 + i;
}
return (
<button
key={pageNum}
onClick={() => setCurrentPage(pageNum)}
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${
currentPage === pageNum
? 'z-10 bg-blue-50 border-blue-500 text-blue-600'
: 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50'
}`}
>
{pageNum}
</button>
);
})}
<button
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span className="sr-only">{t('pagination.next')}</span>
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
clipRule="evenodd"
/>
</svg>
</button>
</nav>
</div>
</div>
</div>
)}
</div>
{/* Actions Dropdown Menu */}
{openMenuId && menuPosition && (
<>
<div
className="fixed inset-0 z-[998]"
onClick={() => {
setOpenMenuId(null);
setMenuPosition(null);
}}
/>
<div
className="fixed w-56 bg-white border-2 border-gray-300 rounded-lg shadow-2xl z-[999]"
style={{ top: `${menuPosition.top}px`, left: `${menuPosition.left}px` }}
>
<div className="py-2">
{(() => {
const [bookingId, documentId] = openMenuId.split('::');
const doc = documents.find(d => d.bookingId === bookingId && d.id === documentId);
if (!doc) return null;
return (
<>
<button
onClick={() => {
setOpenMenuId(null);
setMenuPosition(null);
handleDownload(
doc.filePath || doc.url || '',
doc.fileName || doc.name || 'document'
);
}}
className="w-full px-4 py-3 text-left hover:bg-gray-50 flex items-center space-x-3 border-b border-gray-200"
>
<svg
className="w-5 h-5 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
<span className="text-sm font-medium text-gray-700">
{t('menu.download')}
</span>
</button>
<button
onClick={() => {
const bId = doc.bookingId;
const dId = doc.id;
setOpenMenuId(null);
setMenuPosition(null);
handleDeleteDocument(bId, dId);
}}
disabled={deletingId === doc.id}
className="w-full px-4 py-3 text-left hover:bg-red-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-3"
>
<svg
className="w-5 h-5 text-red-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
<span className="text-sm font-medium text-red-600">{t('menu.delete')}</span>
</button>
</>
);
})()}
</div>
</div>
</>
)}
</div>
);
}