feat(admin): add 3-dot action menus, document deletion, and company email in CSV configs
- Bookings: replace static action cell with vertical dots menu (view details modal, validate transfer, delete) - Documents: replace download button with dots menu (download, delete) + new admin DELETE endpoint bypassing status/ownership restrictions - CSV rates: show company email (from upload form metadata) in active configs table, fix header layout (title left, reload right, same line) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
74221d576e
commit
26a3412658
@ -860,4 +860,55 @@ export class AdminController {
|
|||||||
total: organization.documents.length,
|
total: organization.documents.length,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a document from a CSV booking (admin only)
|
||||||
|
* Bypasses ownership and status restrictions
|
||||||
|
*/
|
||||||
|
@Delete('bookings/:bookingId/documents/:documentId')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Delete document from CSV booking (Admin only)',
|
||||||
|
description: 'Remove a document from a booking, bypassing ownership and status restrictions.',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.OK,
|
||||||
|
description: 'Document deleted successfully',
|
||||||
|
})
|
||||||
|
async deleteDocument(
|
||||||
|
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||||
|
@Param('documentId', ParseUUIDPipe) documentId: string,
|
||||||
|
@CurrentUser() user: UserPayload
|
||||||
|
): Promise<{ success: boolean; message: string }> {
|
||||||
|
this.logger.log(`[ADMIN: ${user.email}] Deleting document ${documentId} from booking ${bookingId}`);
|
||||||
|
|
||||||
|
const booking = await this.csvBookingRepository.findById(bookingId);
|
||||||
|
if (!booking) {
|
||||||
|
throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const documentIndex = booking.documents.findIndex(doc => doc.id === documentId);
|
||||||
|
if (documentIndex === -1) {
|
||||||
|
throw new NotFoundException(`Document ${documentId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedDocuments = booking.documents.filter(doc => doc.id !== documentId);
|
||||||
|
|
||||||
|
const ormBooking = await this.csvBookingRepository['repository'].findOne({ where: { id: bookingId } });
|
||||||
|
if (ormBooking) {
|
||||||
|
ormBooking.documents = updatedDocuments.map(doc => ({
|
||||||
|
id: doc.id,
|
||||||
|
type: doc.type,
|
||||||
|
fileName: doc.fileName,
|
||||||
|
filePath: doc.filePath,
|
||||||
|
mimeType: doc.mimeType,
|
||||||
|
size: doc.size,
|
||||||
|
uploadedAt: doc.uploadedAt,
|
||||||
|
}));
|
||||||
|
await this.csvBookingRepository['repository'].save(ormBooking);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`[ADMIN] Document ${documentId} deleted from booking ${bookingId}`);
|
||||||
|
return { success: true, message: 'Document deleted successfully' };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -489,6 +489,7 @@ export class CsvRatesAdminController {
|
|||||||
size: fileSize,
|
size: fileSize,
|
||||||
uploadedAt: config.uploadedAt.toISOString(),
|
uploadedAt: config.uploadedAt.toISOString(),
|
||||||
rowCount: config.rowCount,
|
rowCount: config.rowCount,
|
||||||
|
companyEmail: config.metadata?.companyEmail ?? null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { getAllBookings, validateBankTransfer } from '@/lib/api/admin';
|
import { getAllBookings, validateBankTransfer, deleteAdminBooking } from '@/lib/api/admin';
|
||||||
|
|
||||||
interface Booking {
|
interface Booking {
|
||||||
id: string;
|
id: string;
|
||||||
@ -32,11 +32,29 @@ export default function AdminBookingsPage() {
|
|||||||
const [filterStatus, setFilterStatus] = useState('all');
|
const [filterStatus, setFilterStatus] = useState('all');
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [validatingId, setValidatingId] = useState<string | null>(null);
|
const [validatingId, setValidatingId] = useState<string | null>(null);
|
||||||
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
|
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
|
||||||
|
const [menuPosition, setMenuPosition] = useState<{ top: number; left: number } | null>(null);
|
||||||
|
const [selectedBooking, setSelectedBooking] = useState<Booking | null>(null);
|
||||||
|
const [showDetailsModal, setShowDetailsModal] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchBookings();
|
fetchBookings();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleDeleteBooking = async (bookingId: string) => {
|
||||||
|
if (!window.confirm('Supprimer définitivement cette réservation ?')) return;
|
||||||
|
setDeletingId(bookingId);
|
||||||
|
try {
|
||||||
|
await deleteAdminBooking(bookingId);
|
||||||
|
setBookings(prev => prev.filter(b => b.id !== bookingId));
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Erreur lors de la suppression');
|
||||||
|
} finally {
|
||||||
|
setDeletingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleValidateTransfer = async (bookingId: string) => {
|
const handleValidateTransfer = async (bookingId: string) => {
|
||||||
if (!window.confirm('Confirmer la réception du virement et activer ce booking ?')) return;
|
if (!window.confirm('Confirmer la réception du virement et activer ce booking ?')) return;
|
||||||
setValidatingId(bookingId);
|
setValidatingId(bookingId);
|
||||||
@ -286,15 +304,23 @@ export default function AdminBookingsPage() {
|
|||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<td className="px-4 py-4 whitespace-nowrap text-right text-sm">
|
<td className="px-4 py-4 whitespace-nowrap text-right text-sm">
|
||||||
{booking.status.toUpperCase() === 'PENDING_BANK_TRANSFER' && (
|
<button
|
||||||
<button
|
onClick={(e) => {
|
||||||
onClick={() => handleValidateTransfer(booking.id)}
|
if (openMenuId === booking.id) {
|
||||||
disabled={validatingId === booking.id}
|
setOpenMenuId(null);
|
||||||
className="px-3 py-1 bg-green-600 text-white text-xs font-semibold rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
setMenuPosition(null);
|
||||||
>
|
} else {
|
||||||
{validatingId === booking.id ? '...' : '✓ Valider virement'}
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
</button>
|
setMenuPosition({ top: rect.bottom + 5, left: rect.left - 180 });
|
||||||
)}
|
setOpenMenuId(booking.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))
|
))
|
||||||
@ -303,6 +329,220 @@ export default function AdminBookingsPage() {
|
|||||||
</table>
|
</table>
|
||||||
</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">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const booking = bookings.find(b => b.id === openMenuId);
|
||||||
|
if (booking) {
|
||||||
|
setSelectedBooking(booking);
|
||||||
|
setShowDetailsModal(true);
|
||||||
|
}
|
||||||
|
setOpenMenuId(null);
|
||||||
|
setMenuPosition(null);
|
||||||
|
}}
|
||||||
|
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="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm font-medium text-gray-700">Voir les détails</span>
|
||||||
|
</button>
|
||||||
|
{(() => {
|
||||||
|
const booking = bookings.find(b => b.id === openMenuId);
|
||||||
|
return booking?.status.toUpperCase() === 'PENDING_BANK_TRANSFER' ? (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const id = openMenuId;
|
||||||
|
setOpenMenuId(null);
|
||||||
|
setMenuPosition(null);
|
||||||
|
if (id) handleValidateTransfer(id);
|
||||||
|
}}
|
||||||
|
disabled={validatingId === openMenuId}
|
||||||
|
className="w-full px-4 py-3 text-left hover:bg-green-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-3 border-b border-gray-200"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm font-medium text-green-700">Valider virement</span>
|
||||||
|
</button>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const id = openMenuId;
|
||||||
|
setOpenMenuId(null);
|
||||||
|
setMenuPosition(null);
|
||||||
|
if (id) handleDeleteBooking(id);
|
||||||
|
}}
|
||||||
|
disabled={deletingId === openMenuId}
|
||||||
|
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">Supprimer</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Details Modal */}
|
||||||
|
{showDetailsModal && selectedBooking && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 overflow-y-auto p-4">
|
||||||
|
<div className="bg-white rounded-lg p-6 max-w-2xl w-full">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900">Détails de la réservation</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowDetailsModal(false); setSelectedBooking(null); }}
|
||||||
|
className="text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-500">N° Booking</label>
|
||||||
|
<div className="mt-1 text-lg font-semibold text-gray-900">
|
||||||
|
{selectedBooking.bookingNumber || getShortId(selectedBooking)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-500">Statut</label>
|
||||||
|
<span className={`mt-1 inline-block px-3 py-1 text-sm font-semibold rounded-full ${getStatusColor(selectedBooking.status)}`}>
|
||||||
|
{getStatusLabel(selectedBooking.status)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t pt-4">
|
||||||
|
<h3 className="text-sm font-medium text-gray-900 mb-3">Route</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-500">Origine</label>
|
||||||
|
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.origin || '—'}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-500">Destination</label>
|
||||||
|
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.destination || '—'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t pt-4">
|
||||||
|
<h3 className="text-sm font-medium text-gray-900 mb-3">Cargo & Transporteur</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-500">Transporteur</label>
|
||||||
|
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.carrierName || '—'}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-500">Type conteneur</label>
|
||||||
|
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.containerType}</div>
|
||||||
|
</div>
|
||||||
|
{selectedBooking.palletCount != null && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-500">Palettes</label>
|
||||||
|
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.palletCount}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedBooking.weightKG != null && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-500">Poids</label>
|
||||||
|
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.weightKG.toLocaleString()} kg</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedBooking.volumeCBM != null && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-500">Volume</label>
|
||||||
|
<div className="mt-1 font-semibold text-gray-900">{selectedBooking.volumeCBM} CBM</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(selectedBooking.priceEUR != null || selectedBooking.priceUSD != null) && (
|
||||||
|
<div className="border-t pt-4">
|
||||||
|
<h3 className="text-sm font-medium text-gray-900 mb-3">Prix</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{selectedBooking.priceEUR != null && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-500">EUR</label>
|
||||||
|
<div className="mt-1 text-xl font-bold text-blue-600">{selectedBooking.priceEUR.toLocaleString()} €</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedBooking.priceUSD != null && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-500">USD</label>
|
||||||
|
<div className="mt-1 text-xl font-bold text-blue-600">{selectedBooking.priceUSD.toLocaleString()} $</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="border-t pt-4">
|
||||||
|
<h3 className="text-sm font-medium text-gray-900 mb-3">Dates</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<label className="block text-gray-500">Créée le</label>
|
||||||
|
<div className="mt-1 text-gray-900">
|
||||||
|
{new Date(selectedBooking.requestedAt || selectedBooking.createdAt || '').toLocaleString('fr-FR')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{selectedBooking.updatedAt && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-gray-500">Mise à jour</label>
|
||||||
|
<div className="mt-1 text-gray-900">{new Date(selectedBooking.updatedAt).toLocaleString('fr-FR')}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedBooking.status.toUpperCase() === 'PENDING_BANK_TRANSFER' && (
|
||||||
|
<div className="border-t pt-4">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowDetailsModal(false);
|
||||||
|
setSelectedBooking(null);
|
||||||
|
handleValidateTransfer(selectedBooking.id);
|
||||||
|
}}
|
||||||
|
disabled={validatingId === selectedBooking.id}
|
||||||
|
className="w-full px-4 py-2 bg-green-600 text-white font-semibold rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{validatingId === selectedBooking.id ? 'Validation...' : '✓ Valider le virement'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end mt-6 pt-4 border-t">
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowDetailsModal(false); setSelectedBooking(null); }}
|
||||||
|
className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Fermer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -81,20 +81,22 @@ export default function AdminCsvRatesPage() {
|
|||||||
|
|
||||||
{/* Configurations Table */}
|
{/* Configurations Table */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader>
|
||||||
<div>
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle>Configurations CSV actives</CardTitle>
|
<div>
|
||||||
<CardDescription>
|
<CardTitle>Configurations CSV actives</CardTitle>
|
||||||
Liste de toutes les compagnies avec fichiers CSV configurés
|
<CardDescription>
|
||||||
</CardDescription>
|
Liste de toutes les compagnies avec fichiers CSV configurés
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={fetchFiles} disabled={loading}>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" onClick={fetchFiles} disabled={loading}>
|
|
||||||
{loading ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<RefreshCw className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{error && (
|
{error && (
|
||||||
@ -120,6 +122,7 @@ export default function AdminCsvRatesPage() {
|
|||||||
<TableHead>Taille</TableHead>
|
<TableHead>Taille</TableHead>
|
||||||
<TableHead>Lignes</TableHead>
|
<TableHead>Lignes</TableHead>
|
||||||
<TableHead>Date d'upload</TableHead>
|
<TableHead>Date d'upload</TableHead>
|
||||||
|
<TableHead>Email</TableHead>
|
||||||
<TableHead>Actions</TableHead>
|
<TableHead>Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@ -142,6 +145,11 @@ export default function AdminCsvRatesPage() {
|
|||||||
{new Date(file.uploadedAt).toLocaleDateString('fr-FR')}
|
{new Date(file.uploadedAt).toLocaleDateString('fr-FR')}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{file.companyEmail ?? '—'}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { getAllBookings, getAllUsers } from '@/lib/api/admin';
|
import { getAllBookings, getAllUsers, deleteAdminDocument } from '@/lib/api/admin';
|
||||||
import { FileText, Image as ImageIcon, FileEdit, FileSpreadsheet, Paperclip } from 'lucide-react';
|
import { FileText, Image as ImageIcon, FileEdit, FileSpreadsheet, Paperclip } from 'lucide-react';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
@ -54,6 +54,9 @@ export default function AdminDocumentsPage() {
|
|||||||
const [filterQuoteNumber, setFilterQuoteNumber] = useState('');
|
const [filterQuoteNumber, setFilterQuoteNumber] = useState('');
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
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
|
// Helper function to get formatted quote number
|
||||||
const getQuoteNumber = (booking: Booking): string => {
|
const getQuoteNumber = (booking: Booking): string => {
|
||||||
@ -265,6 +268,19 @@ export default function AdminDocumentsPage() {
|
|||||||
return colors[status.toLowerCase()] || '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('Supprimer définitivement ce document ?')) return;
|
||||||
|
setDeletingId(documentId);
|
||||||
|
try {
|
||||||
|
await deleteAdminDocument(bookingId, documentId);
|
||||||
|
setDocuments(prev => prev.filter(d => d.id !== documentId));
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Erreur lors de la suppression');
|
||||||
|
} finally {
|
||||||
|
setDeletingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDownload = async (url: string, fileName: string) => {
|
const handleDownload = async (url: string, fileName: string) => {
|
||||||
try {
|
try {
|
||||||
// Try direct download first
|
// Try direct download first
|
||||||
@ -426,8 +442,8 @@ export default function AdminDocumentsPage() {
|
|||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Utilisateur
|
Utilisateur
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Télécharger
|
Actions
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@ -468,15 +484,24 @@ export default function AdminDocumentsPage() {
|
|||||||
{doc.userName || doc.userId.substring(0, 8) + '...'}
|
{doc.userName || doc.userId.substring(0, 8) + '...'}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-center">
|
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDownload(doc.filePath || doc.url || '', doc.fileName || doc.name || 'document')}
|
onClick={(e) => {
|
||||||
className="inline-flex items-center px-4 py-2 bg-green-600 text-white text-sm font-medium rounded-lg hover:bg-green-700 transition-colors"
|
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-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5 text-gray-600" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<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" />
|
<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>
|
</svg>
|
||||||
Télécharger
|
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -586,6 +611,60 @@ export default function AdminDocumentsPage() {
|
|||||||
</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élécharger</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">Supprimer</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -144,6 +144,26 @@ export async function validateBankTransfer(bookingId: string): Promise<BookingRe
|
|||||||
return post<BookingResponse>(`/api/v1/admin/bookings/${bookingId}/validate-transfer`, {});
|
return post<BookingResponse>(`/api/v1/admin/bookings/${bookingId}/validate-transfer`, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a booking (admin only)
|
||||||
|
* DELETE /api/v1/admin/bookings/:id
|
||||||
|
* Permanently deletes a booking from the database
|
||||||
|
* Requires: ADMIN role
|
||||||
|
*/
|
||||||
|
export async function deleteAdminBooking(bookingId: string): Promise<void> {
|
||||||
|
return del<void>(`/api/v1/admin/bookings/${bookingId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a document from a booking (admin only)
|
||||||
|
* DELETE /api/v1/admin/bookings/:bookingId/documents/:documentId
|
||||||
|
* Bypasses ownership and status restrictions
|
||||||
|
* Requires: ADMIN role
|
||||||
|
*/
|
||||||
|
export async function deleteAdminDocument(bookingId: string, documentId: string): Promise<void> {
|
||||||
|
return del<void>(`/api/v1/admin/bookings/${bookingId}/documents/${documentId}`);
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== DOCUMENTS ====================
|
// ==================== DOCUMENTS ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -16,6 +16,7 @@ export interface CsvFileInfo {
|
|||||||
size: number;
|
size: number;
|
||||||
uploadedAt: string;
|
uploadedAt: string;
|
||||||
rowCount?: number;
|
rowCount?: number;
|
||||||
|
companyEmail?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CsvFileListResponse {
|
export interface CsvFileListResponse {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user