793 lines
32 KiB
TypeScript
793 lines
32 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
import { listCsvBookings, CsvBookingResponse } from '@/lib/api/bookings';
|
|
|
|
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 DocumentWithBooking extends Document {
|
|
bookingId: string;
|
|
quoteNumber: string;
|
|
route: string;
|
|
status: string;
|
|
carrierName: string;
|
|
fileType?: string;
|
|
}
|
|
|
|
export default function UserDocumentsPage() {
|
|
const [bookings, setBookings] = useState<CsvBookingResponse[]>([]);
|
|
const [documents, setDocuments] = useState<DocumentWithBooking[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [filterStatus, setFilterStatus] = useState('all');
|
|
const [filterQuoteNumber, setFilterQuoteNumber] = useState('');
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [itemsPerPage, setItemsPerPage] = useState(10);
|
|
|
|
// Modal state for adding documents
|
|
const [showAddModal, setShowAddModal] = useState(false);
|
|
const [selectedBookingId, setSelectedBookingId] = useState<string | null>(null);
|
|
const [uploadingFiles, setUploadingFiles] = useState(false);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// Helper function to get formatted quote number
|
|
const getQuoteNumber = (booking: CsvBookingResponse): string => {
|
|
return `#${booking.bookingId || 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);
|
|
// Fetch all user's bookings (paginated, get all pages)
|
|
const response = await listCsvBookings({ page: 1, limit: 1000 });
|
|
const allBookings = response.bookings || [];
|
|
setBookings(allBookings);
|
|
|
|
// Extract all documents from all bookings
|
|
const allDocuments: DocumentWithBooking[] = [];
|
|
|
|
allBookings.forEach((booking: CsvBookingResponse) => {
|
|
if (booking.documents && booking.documents.length > 0) {
|
|
booking.documents.forEach((doc: any, index: number) => {
|
|
// Use the correct field names from the backend
|
|
const actualFileName = doc.fileName || doc.name || 'document';
|
|
const actualFilePath = doc.filePath || doc.url || '';
|
|
const actualMimeType = doc.mimeType || doc.type || '';
|
|
|
|
// Extract clean file type from mimeType or fileName
|
|
let fileType = '';
|
|
if (actualMimeType.includes('/')) {
|
|
const parts = actualMimeType.split('/');
|
|
fileType = getFileType(parts[1]);
|
|
} else {
|
|
fileType = getFileType(actualFileName);
|
|
}
|
|
|
|
allDocuments.push({
|
|
id: doc.id || `${booking.id}-doc-${index}`,
|
|
fileName: actualFileName,
|
|
filePath: actualFilePath,
|
|
type: doc.type || '',
|
|
mimeType: actualMimeType,
|
|
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: fileType,
|
|
});
|
|
});
|
|
}
|
|
});
|
|
|
|
setDocuments(allDocuments);
|
|
setError(null);
|
|
} catch (err: any) {
|
|
setError(err.message || 'Erreur lors du chargement des documents');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchBookingsAndDocuments();
|
|
}, [fetchBookingsAndDocuments]);
|
|
|
|
// Filter documents
|
|
const filteredDocuments = documents.filter(doc => {
|
|
const matchesSearch =
|
|
searchTerm === '' ||
|
|
(doc.fileName && doc.fileName.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
|
(doc.type && doc.type.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
|
doc.route.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
doc.carrierName.toLowerCase().includes(searchTerm.toLowerCase());
|
|
|
|
const matchesStatus = filterStatus === 'all' || doc.status === filterStatus;
|
|
|
|
const matchesQuote =
|
|
filterQuoteNumber === '' ||
|
|
doc.quoteNumber.toLowerCase().includes(filterQuoteNumber.toLowerCase());
|
|
|
|
return matchesSearch && matchesStatus && matchesQuote;
|
|
});
|
|
|
|
// Pagination
|
|
const totalPages = Math.ceil(filteredDocuments.length / itemsPerPage);
|
|
const startIndex = (currentPage - 1) * itemsPerPage;
|
|
const endIndex = startIndex + itemsPerPage;
|
|
const paginatedDocuments = filteredDocuments.slice(startIndex, endIndex);
|
|
|
|
// Reset to page 1 when filters change
|
|
useEffect(() => {
|
|
setCurrentPage(1);
|
|
}, [searchTerm, filterStatus, filterQuoteNumber]);
|
|
|
|
const getDocumentIcon = (type: string) => {
|
|
const typeLower = type.toLowerCase();
|
|
const icons: Record<string, string> = {
|
|
'application/pdf': '📄',
|
|
'image/jpeg': '🖼️',
|
|
'image/png': '🖼️',
|
|
'image/jpg': '🖼️',
|
|
pdf: '📄',
|
|
jpeg: '🖼️',
|
|
jpg: '🖼️',
|
|
png: '🖼️',
|
|
gif: '🖼️',
|
|
image: '🖼️',
|
|
word: '📝',
|
|
doc: '📝',
|
|
docx: '📝',
|
|
excel: '📊',
|
|
xls: '📊',
|
|
xlsx: '📊',
|
|
csv: '📊',
|
|
text: '📄',
|
|
txt: '📄',
|
|
};
|
|
return icons[typeLower] || '📎';
|
|
};
|
|
|
|
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] || 'bg-gray-100 text-gray-800';
|
|
};
|
|
|
|
const getStatusLabel = (status: string) => {
|
|
const labels: Record<string, string> = {
|
|
PENDING: 'En attente',
|
|
ACCEPTED: 'Accepté',
|
|
REJECTED: 'Refusé',
|
|
CANCELLED: 'Annulé',
|
|
};
|
|
return labels[status] || status;
|
|
};
|
|
|
|
const handleDownload = async (url: string, fileName: string) => {
|
|
try {
|
|
// Try direct download first
|
|
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);
|
|
|
|
// If direct download doesn't work, try fetch with blob
|
|
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);
|
|
alert(
|
|
`Erreur lors du téléchargement du document: ${error instanceof Error ? error.message : 'Erreur inconnue'}`
|
|
);
|
|
}
|
|
};
|
|
|
|
// Get unique bookings for add document modal
|
|
const bookingsWithPendingStatus = bookings.filter(b => b.status === 'PENDING');
|
|
|
|
const handleAddDocumentClick = () => {
|
|
setShowAddModal(true);
|
|
};
|
|
|
|
const handleCloseModal = () => {
|
|
setShowAddModal(false);
|
|
setSelectedBookingId(null);
|
|
if (fileInputRef.current) {
|
|
fileInputRef.current.value = '';
|
|
}
|
|
};
|
|
|
|
const handleFileUpload = async () => {
|
|
if (!selectedBookingId || !fileInputRef.current?.files?.length) {
|
|
alert('Veuillez sélectionner une réservation et au moins un fichier');
|
|
return;
|
|
}
|
|
|
|
setUploadingFiles(true);
|
|
try {
|
|
const formData = new FormData();
|
|
const files = fileInputRef.current.files;
|
|
for (let i = 0; i < files.length; i++) {
|
|
formData.append('documents', files[i]);
|
|
}
|
|
|
|
const token = localStorage.getItem('access_token');
|
|
const response = await fetch(
|
|
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/csv-bookings/${selectedBookingId}/documents`,
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
body: formData,
|
|
}
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Erreur lors de l\'ajout des documents');
|
|
}
|
|
|
|
alert('Documents ajoutés avec succès!');
|
|
handleCloseModal();
|
|
fetchBookingsAndDocuments(); // Refresh the list
|
|
} catch (error) {
|
|
console.error('Error uploading documents:', error);
|
|
alert(
|
|
`Erreur lors de l'ajout des documents: ${error instanceof Error ? error.message : 'Erreur inconnue'}`
|
|
);
|
|
} finally {
|
|
setUploadingFiles(false);
|
|
}
|
|
};
|
|
|
|
const handleDeleteDocument = async (bookingId: string, documentId: string, fileName: string) => {
|
|
if (!confirm(`Êtes-vous sûr de vouloir supprimer le document "${fileName}" ?`)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const token = localStorage.getItem('access_token');
|
|
const response = await fetch(
|
|
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/csv-bookings/${bookingId}/documents/${documentId}`,
|
|
{
|
|
method: 'DELETE',
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
}
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Erreur lors de la suppression du document');
|
|
}
|
|
|
|
alert('Document supprimé avec succès!');
|
|
fetchBookingsAndDocuments(); // Refresh the list
|
|
} catch (error) {
|
|
console.error('Error deleting document:', error);
|
|
alert(
|
|
`Erreur lors de la suppression: ${error instanceof Error ? error.message : 'Erreur inconnue'}`
|
|
);
|
|
}
|
|
};
|
|
|
|
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">Chargement des documents...</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-900">Mes Documents</h1>
|
|
<p className="mt-1 text-sm text-gray-500">
|
|
Gérez tous les documents de vos réservations
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={handleAddDocumentClick}
|
|
disabled={bookingsWithPendingStatus.length === 0}
|
|
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M12 4v16m8-8H4"
|
|
/>
|
|
</svg>
|
|
Ajouter un document
|
|
</button>
|
|
</div>
|
|
|
|
{/* 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">Total Documents</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">Réservations avec Documents</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">Documents Filtrés</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">Recherche</label>
|
|
<input
|
|
type="text"
|
|
placeholder="Nom, type, route, transporteur..."
|
|
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">Numéro de Devis</label>
|
|
<input
|
|
type="text"
|
|
placeholder="Ex: #F2CAD5E1"
|
|
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">Statut</label>
|
|
<select
|
|
value={filterStatus}
|
|
onChange={e => setFilterStatus(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">Tous les statuts</option>
|
|
<option value="PENDING">En attente</option>
|
|
<option value="ACCEPTED">Accepté</option>
|
|
<option value="REJECTED">Refusé</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">
|
|
Nom du Document
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Type
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
N° de Devis
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Route
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Transporteur
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Statut
|
|
</th>
|
|
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
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">
|
|
{documents.length === 0
|
|
? 'Aucun document trouvé. Ajoutez des documents à vos réservations.'
|
|
: 'Aucun document ne correspond aux filtres sélectionnés.'}
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
paginatedDocuments.map((doc, index) => (
|
|
<tr key={`${doc.bookingId}-${doc.id}-${index}`} className="hover:bg-gray-50">
|
|
<td className="px-6 py-4">
|
|
<div className="text-sm font-medium text-gray-900">{doc.fileName}</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div className="flex items-center">
|
|
<span className="text-2xl 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">
|
|
<div className="text-sm text-gray-900">{doc.carrierName}</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)}`}
|
|
>
|
|
{getStatusLabel(doc.status)}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-center">
|
|
<div className="flex items-center justify-center space-x-2">
|
|
<button
|
|
onClick={() =>
|
|
handleDownload(doc.filePath || doc.url || '', doc.fileName)
|
|
}
|
|
className="inline-flex items-center px-3 py-1.5 bg-green-600 text-white text-sm font-medium rounded-lg hover:bg-green-700 transition-colors"
|
|
title="Télécharger"
|
|
>
|
|
<svg
|
|
className="w-4 h-4"
|
|
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>
|
|
</button>
|
|
{doc.status === 'PENDING' && (
|
|
<button
|
|
onClick={() => handleDeleteDocument(doc.bookingId, doc.id, doc.fileName)}
|
|
className="inline-flex items-center px-3 py-1.5 bg-red-600 text-white text-sm font-medium rounded-lg hover:bg-red-700 transition-colors"
|
|
title="Supprimer"
|
|
>
|
|
<svg
|
|
className="w-4 h-4"
|
|
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>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</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"
|
|
>
|
|
Précédent
|
|
</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"
|
|
>
|
|
Suivant
|
|
</button>
|
|
</div>
|
|
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
|
<div>
|
|
<p className="text-sm text-gray-700">
|
|
Affichage de <span className="font-medium">{startIndex + 1}</span> à{' '}
|
|
<span className="font-medium">
|
|
{Math.min(endIndex, filteredDocuments.length)}
|
|
</span>{' '}
|
|
sur <span className="font-medium">{filteredDocuments.length}</span> résultats
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-4">
|
|
<div className="flex items-center gap-2">
|
|
<label className="text-sm text-gray-700">Par page:</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">Précédent</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">Suivant</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>
|
|
|
|
{/* Add Document Modal */}
|
|
{showAddModal && (
|
|
<div className="fixed inset-0 z-50 overflow-y-auto">
|
|
<div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
|
{/* Background overlay */}
|
|
<div
|
|
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
|
|
onClick={handleCloseModal}
|
|
/>
|
|
|
|
{/* Modal panel */}
|
|
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
|
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
|
<div className="sm:flex sm:items-start">
|
|
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10">
|
|
<svg
|
|
className="h-6 w-6 text-blue-600"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M12 4v16m8-8H4"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left flex-1">
|
|
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
|
Ajouter un document
|
|
</h3>
|
|
<div className="mt-4 space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Sélectionner une réservation (en attente)
|
|
</label>
|
|
<select
|
|
value={selectedBookingId || ''}
|
|
onChange={e => setSelectedBookingId(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="">-- Choisir une réservation --</option>
|
|
{bookingsWithPendingStatus.map(booking => (
|
|
<option key={booking.id} value={booking.id}>
|
|
{getQuoteNumber(booking)} - {booking.origin} → {booking.destination}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Fichiers à ajouter
|
|
</label>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
multiple
|
|
accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.gif"
|
|
className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100"
|
|
/>
|
|
<p className="mt-1 text-xs text-gray-500">
|
|
Formats acceptés: PDF, Word, Excel, Images (max 10 fichiers)
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
|
<button
|
|
type="button"
|
|
onClick={handleFileUpload}
|
|
disabled={uploadingFiles || !selectedBookingId}
|
|
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{uploadingFiles ? (
|
|
<>
|
|
<svg
|
|
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<circle
|
|
className="opacity-25"
|
|
cx="12"
|
|
cy="12"
|
|
r="10"
|
|
stroke="currentColor"
|
|
strokeWidth="4"
|
|
/>
|
|
<path
|
|
className="opacity-75"
|
|
fill="currentColor"
|
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
/>
|
|
</svg>
|
|
Envoi en cours...
|
|
</>
|
|
) : (
|
|
'Ajouter'
|
|
)}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleCloseModal}
|
|
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
|
>
|
|
Annuler
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|