795 lines
37 KiB
TypeScript
795 lines
37 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { useNotification } from './NotificationProvider';
|
|
import ConfirmModal from './ConfirmModal';
|
|
import ParticipationEditModal from './ParticipationEditModal';
|
|
import { getParticipationRef } from '@/lib/participation-ref';
|
|
|
|
interface Participation {
|
|
id: string;
|
|
destinataireEmail: string;
|
|
destinataireNom: string;
|
|
destinataireType: string;
|
|
montant: number | null;
|
|
complement: string | null;
|
|
filePath: string | null;
|
|
statut: string;
|
|
createdAt: string;
|
|
adherent: {
|
|
id: string;
|
|
nom: string;
|
|
prenom: string;
|
|
email: string;
|
|
};
|
|
trajet: {
|
|
id: string;
|
|
date: string;
|
|
adresseDepart: string;
|
|
adresseArrivee: string;
|
|
statut: string;
|
|
chauffeur: {
|
|
id: string;
|
|
nom: string;
|
|
prenom: string;
|
|
} | null;
|
|
};
|
|
}
|
|
|
|
const STATUT_CONFIG: Record<string, { label: string; className: string; dot: string }> = {
|
|
en_attente: {
|
|
label: "En attente d'envoi",
|
|
className: 'bg-amber-100 text-amber-800',
|
|
dot: 'bg-amber-500',
|
|
},
|
|
envoye: {
|
|
label: 'Envoyé',
|
|
className: 'bg-blue-100 text-blue-800',
|
|
dot: 'bg-blue-500',
|
|
},
|
|
paye: {
|
|
label: 'Payé',
|
|
className: 'bg-emerald-100 text-emerald-800',
|
|
dot: 'bg-emerald-500',
|
|
},
|
|
archive: {
|
|
label: 'Archivé',
|
|
className: 'bg-gray-200 text-gray-700',
|
|
dot: 'bg-gray-500',
|
|
},
|
|
};
|
|
|
|
|
|
export default function ParticipationFinanciereList() {
|
|
const { showNotification } = useNotification();
|
|
const [participations, setParticipations] = useState<Participation[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
|
const [participationToDelete, setParticipationToDelete] = useState<string | null>(null);
|
|
const [sendingId, setSendingId] = useState<string | null>(null);
|
|
const [editingParticipation, setEditingParticipation] = useState<Participation | null>(null);
|
|
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
|
|
const [menuAnchor, setMenuAnchor] = useState<{ top: number; left: number } | null>(null);
|
|
const [statusFilter, setStatusFilter] = useState<string>('');
|
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
|
const [bulkUpdating, setBulkUpdating] = useState(false);
|
|
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false);
|
|
const [openBulkDropdown, setOpenBulkDropdown] = useState<'statut' | 'actions' | null>(null);
|
|
|
|
useEffect(() => {
|
|
fetchParticipations();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const handleClickOutside = () => {
|
|
setOpenMenuId(null);
|
|
setMenuAnchor(null);
|
|
};
|
|
if (openMenuId) {
|
|
document.addEventListener('click', handleClickOutside);
|
|
return () => document.removeEventListener('click', handleClickOutside);
|
|
}
|
|
}, [openMenuId]);
|
|
|
|
useEffect(() => {
|
|
const handleClickOutside = () => setOpenBulkDropdown(null);
|
|
if (openBulkDropdown) {
|
|
document.addEventListener('click', handleClickOutside);
|
|
return () => document.removeEventListener('click', handleClickOutside);
|
|
}
|
|
}, [openBulkDropdown]);
|
|
|
|
const fetchParticipations = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const response = await fetch('/api/participations');
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
setParticipations(data);
|
|
} else {
|
|
showNotification('error', 'Erreur lors du chargement des participations');
|
|
}
|
|
} catch {
|
|
showNotification('error', 'Erreur lors du chargement des participations');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleViewPdf = (id: string) => {
|
|
window.open(`/api/participations/${id}/pdf`, '_blank', 'noopener,noreferrer');
|
|
};
|
|
|
|
const handleSendEmail = async (id: string) => {
|
|
setSendingId(id);
|
|
setOpenMenuId(null);
|
|
try {
|
|
const response = await fetch(`/api/participations/${id}/send`, {
|
|
method: 'POST',
|
|
});
|
|
const data = await response.json();
|
|
if (response.ok) {
|
|
showNotification('success', data.message);
|
|
fetchParticipations();
|
|
} else {
|
|
showNotification('error', data.error || "Erreur lors de l'envoi");
|
|
}
|
|
} catch {
|
|
showNotification('error', "Erreur lors de l'envoi de l'email");
|
|
} finally {
|
|
setSendingId(null);
|
|
}
|
|
};
|
|
|
|
const handleEditClick = (p: Participation) => {
|
|
setEditingParticipation(p);
|
|
setOpenMenuId(null);
|
|
};
|
|
|
|
const handleEditSuccess = () => {
|
|
setEditingParticipation(null);
|
|
fetchParticipations();
|
|
showNotification('success', 'Participation mise à jour');
|
|
};
|
|
|
|
const handleDeleteClick = (id: string) => {
|
|
setParticipationToDelete(id);
|
|
setShowDeleteConfirm(true);
|
|
setOpenMenuId(null);
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
if (!participationToDelete) return;
|
|
setShowDeleteConfirm(false);
|
|
try {
|
|
const response = await fetch(`/api/participations/${participationToDelete}`, {
|
|
method: 'DELETE',
|
|
});
|
|
if (response.ok) {
|
|
showNotification('success', 'Participation supprimée');
|
|
fetchParticipations();
|
|
} else {
|
|
const data = await response.json();
|
|
showNotification('error', data.error || 'Erreur lors de la suppression');
|
|
}
|
|
} catch {
|
|
showNotification('error', 'Erreur lors de la suppression');
|
|
} finally {
|
|
setParticipationToDelete(null);
|
|
}
|
|
};
|
|
|
|
const handleStatutChange = async (id: string, newStatut: string) => {
|
|
setOpenMenuId(null);
|
|
setMenuAnchor(null);
|
|
try {
|
|
const response = await fetch(`/api/participations/${id}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ statut: newStatut }),
|
|
});
|
|
if (response.ok) {
|
|
fetchParticipations();
|
|
} else {
|
|
showNotification('error', 'Erreur lors du changement de statut');
|
|
}
|
|
} catch {
|
|
showNotification('error', 'Erreur lors du changement de statut');
|
|
}
|
|
};
|
|
|
|
const filteredParticipations = participations.filter((p) => {
|
|
const search = searchTerm.toLowerCase();
|
|
const ref = getParticipationRef(p.id).toLowerCase();
|
|
const chauffeurName = p.trajet.chauffeur
|
|
? `${p.trajet.chauffeur.prenom} ${p.trajet.chauffeur.nom}`.toLowerCase()
|
|
: '';
|
|
const matchesSearch =
|
|
p.adherent.nom.toLowerCase().includes(search) ||
|
|
p.adherent.prenom.toLowerCase().includes(search) ||
|
|
ref.includes(search) ||
|
|
chauffeurName.includes(search) ||
|
|
p.destinataireNom.toLowerCase().includes(search);
|
|
const matchesStatus = !statusFilter || p.statut === statusFilter;
|
|
return matchesSearch && matchesStatus;
|
|
});
|
|
|
|
const handleSelectAll = (checked: boolean) => {
|
|
if (checked) {
|
|
setSelectedIds(new Set(filteredParticipations.map((p) => p.id)));
|
|
} else {
|
|
setSelectedIds(new Set());
|
|
}
|
|
};
|
|
|
|
const handleSelectOne = (id: string, checked: boolean) => {
|
|
const next = new Set(selectedIds);
|
|
if (checked) next.add(id);
|
|
else next.delete(id);
|
|
setSelectedIds(next);
|
|
};
|
|
|
|
const handleBulkStatusChange = async (newStatut: string) => {
|
|
if (selectedIds.size === 0) return;
|
|
setBulkUpdating(true);
|
|
try {
|
|
const results = await Promise.allSettled(
|
|
Array.from(selectedIds).map((id) =>
|
|
fetch(`/api/participations/${id}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ statut: newStatut }),
|
|
})
|
|
)
|
|
);
|
|
const failed = results.filter((r) => r.status === 'rejected' || (r.status === 'fulfilled' && !r.value.ok));
|
|
if (failed.length === 0) {
|
|
showNotification('success', `${selectedIds.size} participation(s) mise(s) à jour`);
|
|
setSelectedIds(new Set());
|
|
fetchParticipations();
|
|
} else {
|
|
showNotification('error', `${failed.length} mise(s) à jour ont échoué`);
|
|
}
|
|
} catch {
|
|
showNotification('error', 'Erreur lors de la mise à jour en masse');
|
|
} finally {
|
|
setBulkUpdating(false);
|
|
}
|
|
};
|
|
|
|
const handleBulkSendEmail = async () => {
|
|
if (selectedIds.size === 0) return;
|
|
setBulkUpdating(true);
|
|
try {
|
|
const results = await Promise.allSettled(
|
|
Array.from(selectedIds).map((id) =>
|
|
fetch(`/api/participations/${id}/send`, { method: 'POST' })
|
|
)
|
|
);
|
|
const success = results.filter((r) => r.status === 'fulfilled' && r.value.ok).length;
|
|
const failed = results.filter((r) => r.status === 'rejected' || (r.status === 'fulfilled' && !r.value.ok));
|
|
if (failed.length === 0) {
|
|
showNotification('success', `${success} participation(s) envoyée(s) par email`);
|
|
setSelectedIds(new Set());
|
|
fetchParticipations();
|
|
} else {
|
|
showNotification(
|
|
failed.length === results.length ? 'error' : 'success',
|
|
success > 0 ? `${success} envoyée(s), ${failed.length} échec(s)` : `${failed.length} envoi(s) ont échoué`
|
|
);
|
|
if (success > 0) fetchParticipations();
|
|
}
|
|
} catch {
|
|
showNotification('error', 'Erreur lors de l\'envoi en masse');
|
|
} finally {
|
|
setBulkUpdating(false);
|
|
}
|
|
};
|
|
|
|
const handleBulkDeleteConfirm = async () => {
|
|
if (selectedIds.size === 0) return;
|
|
setShowBulkDeleteConfirm(false);
|
|
const idsToDelete = Array.from(selectedIds);
|
|
setBulkUpdating(true);
|
|
try {
|
|
const results = await Promise.allSettled(
|
|
idsToDelete.map((id) =>
|
|
fetch(`/api/participations/${id}`, { method: 'DELETE' })
|
|
)
|
|
);
|
|
const failed = results.filter((r) => r.status === 'rejected' || (r.status === 'fulfilled' && !r.value.ok));
|
|
if (failed.length === 0) {
|
|
showNotification('success', `${idsToDelete.length} participation(s) supprimée(s)`);
|
|
} else {
|
|
showNotification('error', `${failed.length} suppression(s) ont échoué`);
|
|
}
|
|
setSelectedIds(new Set());
|
|
fetchParticipations();
|
|
} catch {
|
|
showNotification('error', 'Erreur lors de la suppression en masse');
|
|
} finally {
|
|
setBulkUpdating(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{/* Barre de recherche - même design que Adhérents / Chauffeurs */}
|
|
<div className="bg-white rounded-lg shadow-sm p-3 sm:p-4 mb-4 sm:mb-6">
|
|
<div className="flex flex-col md:flex-row gap-4 items-center">
|
|
<div className="flex-1 w-full md:w-auto">
|
|
<div className="relative">
|
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
<svg className="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
</svg>
|
|
</div>
|
|
<input
|
|
type="text"
|
|
placeholder="Rechercher (adhérent, n° document, chauffeur...)"
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-lblue focus:border-transparent"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filtres statut + actions en masse */}
|
|
{(participations.length > 0 || selectedIds.size > 0) && (
|
|
<div className="bg-white rounded-lg shadow-sm p-3 sm:p-4 mb-3 sm:mb-4">
|
|
<div className="flex flex-col gap-3 sm:gap-4">
|
|
{/* Filtres par statut */}
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="text-xs font-medium text-gray-500 uppercase tracking-wider">Statut :</span>
|
|
{[
|
|
{ key: '', label: 'Tous' },
|
|
...Object.entries(STATUT_CONFIG).map(([key, config]) => ({ key, label: config.label })),
|
|
].map(({ key, label }) => (
|
|
<button
|
|
key={key || 'all'}
|
|
onClick={() => setStatusFilter(key)}
|
|
className={`px-3 py-1.5 text-xs font-medium rounded-full transition-colors ${
|
|
statusFilter === key
|
|
? key
|
|
? `${STATUT_CONFIG[key]?.className ?? 'bg-gray-200'} ring-2 ring-offset-1 ring-gray-300`
|
|
: 'bg-lblue text-white'
|
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
|
}`}
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Barre d'actions en masse - 2 menus déroulants */}
|
|
{selectedIds.size > 0 && (
|
|
<div className="flex flex-col sm:flex-row sm:flex-wrap items-stretch sm:items-center gap-3 pt-2 border-t border-gray-100 sm:border-t-0 sm:pt-0">
|
|
<span className="text-sm text-gray-600 order-first sm:order-none">
|
|
{selectedIds.size} sélectionné{selectedIds.size > 1 ? 's' : ''}
|
|
</span>
|
|
<div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto">
|
|
{/* Menu Changer le statut */}
|
|
<div className="relative w-full sm:w-auto">
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setOpenBulkDropdown(openBulkDropdown === 'statut' ? null : 'statut');
|
|
}}
|
|
disabled={bulkUpdating}
|
|
className="w-full sm:w-auto inline-flex items-center justify-center gap-2 px-4 py-2.5 sm:py-2 text-sm font-medium rounded-lg bg-lblue text-white hover:bg-dblue disabled:opacity-50 transition-colors"
|
|
>
|
|
Changer le statut
|
|
<svg className={`w-4 h-4 transition-transform flex-shrink-0 ${openBulkDropdown === 'statut' ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
</button>
|
|
{openBulkDropdown === 'statut' && (
|
|
<div
|
|
className="absolute left-0 right-0 sm:right-auto sm:w-52 top-full mt-1 z-50 w-full min-w-[12rem] bg-white rounded-lg shadow-lg border border-gray-200 py-1"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{Object.entries(STATUT_CONFIG).map(([key, config]) => (
|
|
<button
|
|
key={key}
|
|
onClick={() => { handleBulkStatusChange(key); setOpenBulkDropdown(null); }}
|
|
disabled={bulkUpdating}
|
|
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50"
|
|
>
|
|
<span className={`w-2.5 h-2.5 rounded-full ${config.dot}`} />
|
|
{config.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Menu Actions */}
|
|
<div className="relative w-full sm:w-auto">
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setOpenBulkDropdown(openBulkDropdown === 'actions' ? null : 'actions');
|
|
}}
|
|
disabled={bulkUpdating}
|
|
className="w-full sm:w-auto inline-flex items-center justify-center gap-2 px-4 py-2.5 sm:py-2 text-sm font-medium rounded-lg border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 disabled:opacity-50 transition-colors"
|
|
>
|
|
Actions
|
|
<svg className={`w-4 h-4 transition-transform flex-shrink-0 ${openBulkDropdown === 'actions' ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
</button>
|
|
{openBulkDropdown === 'actions' && (
|
|
<div
|
|
className="absolute left-0 right-0 sm:left-0 sm:right-auto sm:w-52 top-full mt-1 z-50 w-full min-w-[12rem] bg-white rounded-lg shadow-lg border border-gray-200 py-1"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<button
|
|
onClick={() => { handleBulkSendEmail(); setOpenBulkDropdown(null); }}
|
|
disabled={bulkUpdating}
|
|
className="w-full flex items-center gap-2.5 px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50"
|
|
>
|
|
<svg className="w-4 h-4 text-lgreen" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
|
</svg>
|
|
Envoyer par email
|
|
</button>
|
|
<button
|
|
onClick={() => { setShowBulkDeleteConfirm(true); setOpenBulkDropdown(null); }}
|
|
disabled={bulkUpdating}
|
|
className="w-full flex items-center gap-2.5 px-4 py-2.5 text-sm text-red-600 hover:bg-red-50 disabled:opacity-50"
|
|
>
|
|
<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>
|
|
Supprimer
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={() => setSelectedIds(new Set())}
|
|
className="text-xs text-gray-500 hover:text-gray-700 underline self-start sm:ml-auto"
|
|
>
|
|
Annuler
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Tableau - même structure que Adhérents / Chauffeurs */}
|
|
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
|
|
{loading ? (
|
|
<div className="p-6 sm:p-8 text-center text-sm text-gray-500">Chargement...</div>
|
|
) : filteredParticipations.length === 0 ? (
|
|
<div className="p-6 sm:p-8 text-center text-sm text-gray-500">
|
|
{searchTerm || statusFilter
|
|
? 'Aucune participation trouvée pour ces critères'
|
|
: "Aucune participation financière. Les documents sont créés automatiquement lorsqu'un trajet est terminé ou validé."}
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Vue desktop - Tableau */}
|
|
<div className="hidden md:block overflow-x-auto">
|
|
<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">
|
|
<input
|
|
type="checkbox"
|
|
checked={filteredParticipations.length > 0 && selectedIds.size === filteredParticipations.length}
|
|
onChange={(e) => handleSelectAll(e.target.checked)}
|
|
className="w-4 h-4 text-lblue border-gray-300 rounded focus:ring-lblue"
|
|
/>
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">ADHÉRENT</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">N° DOCUMENT</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">CHAUFFEUR</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">MONTANT</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-left text-xs font-medium text-gray-500 uppercase tracking-wider">ACTIONS</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="bg-white divide-y divide-gray-200">
|
|
{filteredParticipations.map((p) => {
|
|
const ref = getParticipationRef(p.id);
|
|
const chauffeur = p.trajet.chauffeur;
|
|
const montant = p.montant != null ? `${p.montant.toFixed(2).replace('.', ',')} €` : '—';
|
|
const statutConfig = STATUT_CONFIG[p.statut] || STATUT_CONFIG.en_attente;
|
|
|
|
return (
|
|
<tr key={p.id} className="hover:bg-gray-50">
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedIds.has(p.id)}
|
|
onChange={(e) => handleSelectOne(p.id, e.target.checked)}
|
|
className="w-4 h-4 text-lblue border-gray-300 rounded focus:ring-lblue"
|
|
/>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 rounded-full bg-lblue flex items-center justify-center text-white font-semibold">
|
|
{p.adherent.prenom.charAt(0)}{p.adherent.nom.charAt(0)}
|
|
</div>
|
|
<div>
|
|
<div className="text-sm font-semibold text-gray-900">
|
|
{p.adherent.prenom} {p.adherent.nom}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<span className="font-mono text-sm text-gray-900">{ref}</span>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
{chauffeur ? (
|
|
<span className="text-sm text-gray-900">
|
|
{chauffeur.prenom} {chauffeur.nom}
|
|
</span>
|
|
) : (
|
|
<span className="text-sm text-gray-400 italic">Non assigné</span>
|
|
)}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<span className="text-sm font-semibold text-gray-900">{montant}</span>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap overflow-visible">
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
if (openMenuId === p.id) {
|
|
setOpenMenuId(null);
|
|
setMenuAnchor(null);
|
|
} else {
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
setOpenMenuId(p.id);
|
|
setMenuAnchor({ top: rect.bottom + 4, left: rect.left });
|
|
}
|
|
}}
|
|
className={`px-3 py-1 inline-flex text-xs leading-5 font-semibold rounded-full cursor-pointer hover:opacity-90 transition-opacity ${statutConfig.className}`}
|
|
>
|
|
{statutConfig.label}
|
|
</button>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
onClick={() => handleViewPdf(p.id)}
|
|
className="text-lblue hover:text-dblue"
|
|
title="Voir le PDF"
|
|
>
|
|
<svg className="w-5 h-5" 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>
|
|
</button>
|
|
<button
|
|
onClick={() => handleSendEmail(p.id)}
|
|
disabled={sendingId === p.id}
|
|
className="text-lgreen hover:text-dgreen disabled:opacity-50"
|
|
title="Envoyer par email"
|
|
>
|
|
{sendingId === p.id ? (
|
|
<svg className="w-5 h-5 animate-spin" 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 12h4z" />
|
|
</svg>
|
|
) : (
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
|
</svg>
|
|
)}
|
|
</button>
|
|
<button
|
|
onClick={() => handleEditClick(p)}
|
|
className="text-lblue hover:text-dblue"
|
|
title="Modifier"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
|
</svg>
|
|
</button>
|
|
<button
|
|
onClick={() => handleDeleteClick(p.id)}
|
|
className="text-red-500 hover:text-red-700"
|
|
title="Supprimer"
|
|
>
|
|
<svg className="w-5 h-5" 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>
|
|
</div>
|
|
|
|
{/* Vue mobile - Cartes */}
|
|
<div className="md:hidden divide-y divide-gray-200">
|
|
{filteredParticipations.map((p) => {
|
|
const ref = getParticipationRef(p.id);
|
|
const chauffeur = p.trajet.chauffeur;
|
|
const montant = p.montant != null ? `${p.montant.toFixed(2).replace('.', ',')} €` : '—';
|
|
const statutConfig = STATUT_CONFIG[p.statut] || STATUT_CONFIG.en_attente;
|
|
|
|
return (
|
|
<div key={p.id} className="p-3 sm:p-4 hover:bg-gray-50">
|
|
<div className="flex items-start gap-3">
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedIds.has(p.id)}
|
|
onChange={(e) => handleSelectOne(p.id, e.target.checked)}
|
|
className="w-4 h-4 text-lblue border-gray-300 rounded focus:ring-lblue mt-3 flex-shrink-0"
|
|
/>
|
|
<div className="w-12 h-12 rounded-full bg-lblue flex items-center justify-center text-white font-semibold flex-shrink-0">
|
|
{p.adherent.prenom.charAt(0)}{p.adherent.nom.charAt(0)}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="mb-2">
|
|
<div className="text-base font-semibold text-gray-900">
|
|
{p.adherent.prenom} {p.adherent.nom}
|
|
</div>
|
|
<div className="font-mono text-xs text-gray-500">{ref}</div>
|
|
</div>
|
|
<div className="mb-2">
|
|
<div className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-0.5">Chauffeur</div>
|
|
{chauffeur ? (
|
|
<div className="text-sm text-gray-900">{chauffeur.prenom} {chauffeur.nom}</div>
|
|
) : (
|
|
<div className="text-sm text-gray-400 italic">Non assigné</div>
|
|
)}
|
|
</div>
|
|
<div className="mb-2">
|
|
<div className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-0.5">Montant</div>
|
|
<div className="text-sm font-semibold text-gray-900">{montant}</div>
|
|
</div>
|
|
<div className="mb-2">
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
if (openMenuId === p.id) {
|
|
setOpenMenuId(null);
|
|
setMenuAnchor(null);
|
|
} else {
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
setOpenMenuId(p.id);
|
|
setMenuAnchor({ top: rect.bottom + 4, left: rect.left });
|
|
}
|
|
}}
|
|
className={`px-2 py-1 inline-flex text-xs leading-4 font-semibold rounded-full cursor-pointer ${statutConfig.className}`}
|
|
>
|
|
{statutConfig.label}
|
|
</button>
|
|
</div>
|
|
<div className="grid grid-cols-2 sm:flex sm:flex-wrap gap-2 sm:gap-4 pt-2 border-t border-gray-200">
|
|
<button
|
|
onClick={() => handleViewPdf(p.id)}
|
|
className="flex items-center justify-center sm:justify-start gap-1.5 text-lblue hover:text-dblue text-xs sm:text-sm py-2 sm:py-0"
|
|
>
|
|
<svg className="w-4 h-4 flex-shrink-0" 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>
|
|
Voir
|
|
</button>
|
|
<button
|
|
onClick={() => handleSendEmail(p.id)}
|
|
disabled={sendingId === p.id}
|
|
className="flex items-center justify-center sm:justify-start gap-1.5 text-lgreen hover:text-dgreen text-xs sm:text-sm disabled:opacity-50 py-2 sm:py-0"
|
|
>
|
|
<svg className="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
|
</svg>
|
|
{sendingId === p.id ? 'Envoi...' : 'Envoyer'}
|
|
</button>
|
|
<button
|
|
onClick={() => handleEditClick(p)}
|
|
className="flex items-center justify-center sm:justify-start gap-1.5 text-lblue hover:text-dblue text-xs sm:text-sm py-2 sm:py-0"
|
|
>
|
|
<svg className="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
|
</svg>
|
|
Modifier
|
|
</button>
|
|
<button
|
|
onClick={() => handleDeleteClick(p.id)}
|
|
className="flex items-center justify-center sm:justify-start gap-1.5 text-red-500 hover:text-red-700 text-xs sm:text-sm py-2 sm:py-0"
|
|
>
|
|
<svg className="w-4 h-4 flex-shrink-0" 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>
|
|
Supprimer
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Menu statut en portail pour passer au-dessus du tableau */}
|
|
{openMenuId && menuAnchor && typeof document !== 'undefined' && (() => {
|
|
const p = filteredParticipations.find((x) => x.id === openMenuId);
|
|
if (!p) return null;
|
|
return createPortal(
|
|
<div
|
|
className="fixed z-[9999] w-48 bg-white rounded-lg shadow-lg border border-gray-200 py-1"
|
|
style={{ top: menuAnchor.top, left: menuAnchor.left }}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wider border-b border-gray-100">
|
|
Changer le statut
|
|
</div>
|
|
{Object.entries(STATUT_CONFIG).map(([key, config]) => (
|
|
<button
|
|
key={key}
|
|
onClick={() => handleStatutChange(p.id, key)}
|
|
disabled={key === p.statut}
|
|
className={`w-full text-left px-3 py-2 text-sm hover:bg-gray-50 flex items-center gap-2 ${
|
|
key === p.statut ? 'text-gray-400' : 'text-gray-700'
|
|
}`}
|
|
>
|
|
<span className={`w-2 h-2 rounded-full ${config.dot}`} />
|
|
{config.label}
|
|
{key === p.statut && (
|
|
<svg className="w-4 h-4 ml-auto text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>,
|
|
document.body
|
|
);
|
|
})()}
|
|
|
|
<ConfirmModal
|
|
isOpen={showDeleteConfirm}
|
|
title="Supprimer la participation"
|
|
message="Êtes-vous sûr de vouloir supprimer cette participation financière ? Le document PDF sera également supprimé."
|
|
confirmText="Supprimer"
|
|
cancelText="Annuler"
|
|
confirmColor="danger"
|
|
onConfirm={handleDelete}
|
|
onCancel={() => {
|
|
setShowDeleteConfirm(false);
|
|
setParticipationToDelete(null);
|
|
}}
|
|
/>
|
|
|
|
<ConfirmModal
|
|
isOpen={showBulkDeleteConfirm}
|
|
title="Supprimer les participations sélectionnées"
|
|
message={`Êtes-vous sûr de vouloir supprimer les ${selectedIds.size} participation(s) sélectionnée(s) ? Les documents PDF seront également supprimés.`}
|
|
confirmText="Supprimer tout"
|
|
cancelText="Annuler"
|
|
confirmColor="danger"
|
|
onConfirm={handleBulkDeleteConfirm}
|
|
onCancel={() => setShowBulkDeleteConfirm(false)}
|
|
/>
|
|
|
|
{editingParticipation && (
|
|
<ParticipationEditModal
|
|
participation={editingParticipation}
|
|
onClose={() => setEditingParticipation(null)}
|
|
onSuccess={handleEditSuccess}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|