2026-02-15 14:36:28 +01:00
'use client' ;
import { useState , useEffect } from 'react' ;
import { createPortal } from 'react-dom' ;
import { useNotification } from './NotificationProvider' ;
import ConfirmModal from './ConfirmModal' ;
import ParticipationEditModal from './ParticipationEditModal' ;
2026-02-16 14:43:02 +01:00
import { getParticipationRef } from '@/lib/participation-ref' ;
2026-02-15 14:36:28 +01:00
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 ( ) ;
2026-02-16 14:43:02 +01:00
const ref = getParticipationRef ( p . id ) . toLowerCase ( ) ;
2026-02-15 14:36:28 +01:00
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 ) = > {
2026-02-16 14:43:02 +01:00
const ref = getParticipationRef ( p . id ) ;
2026-02-15 14:36:28 +01:00
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 ) = > {
2026-02-16 14:43:02 +01:00
const ref = getParticipationRef ( p . id ) ;
2026-02-15 14:36:28 +01:00
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 }
/ >
) }
< / >
) ;
}