Added Participation Page

This commit is contained in:
2026-02-15 14:36:28 +01:00
parent da2e32d004
commit 5185a41bb6
23 changed files with 2643 additions and 67 deletions

3
.gitignore vendored
View File

@@ -40,3 +40,6 @@ next-env.d.ts
# uploads
/public/uploads
# participations PDF
/data/participations

View File

@@ -23,25 +23,26 @@ export async function GET(request: NextRequest) {
const startOfYesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
const endOfYesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 23, 59, 59, 999);
// 1. Participations du mois (montant total des trajets validés/terminés + nombre de factures)
// Pour l'instant, on considère qu'un trajet terminé = une facture
// Montant estimé : 6.80€ par trajet (valeur moyenne basée sur l'image)
const trajetsMois = await prisma.trajet.findMany({
// 1. Participations du mois (trajets validés/terminés ce mois)
const participationsMoisData = await prisma.participationFinanciere.findMany({
where: {
archived: false,
statut: {
in: ['Terminé', 'Validé'],
},
date: {
gte: startOfMonth,
lte: endOfMonth,
trajet: {
date: {
gte: startOfMonth,
lte: endOfMonth,
},
},
},
include: { trajet: { select: { date: true } } },
});
const montantMoyenParTrajet = 6.80; // Montant moyen par trajet en euros
const participationsMois = trajetsMois.length * montantMoyenParTrajet;
const nombreFactures = trajetsMois.length;
const participationsCeMois = participationsMoisData;
const montantMoyenParTrajet = 6.80;
const participationsMois = participationsCeMois.reduce(
(sum, p) => sum + (p.montant ?? montantMoyenParTrajet),
0
);
const nombreFactures = participationsCeMois.length;
// 2. Trajets aujourd'hui
const trajetsAujourdhui = await prisma.trajet.count({

View File

@@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { getCurrentUser } from '@/lib/auth';
import { generateParticipationPDF, getParticipationStoragePath } from '@/lib/participation-pdf';
// GET - Récupérer le PDF d'une participation (régénéré à chaque vue pour le design à jour)
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getCurrentUser();
if (!user) {
return new NextResponse('Non autorisé', { status: 401 });
}
const participation = await prisma.participationFinanciere.findUnique({
where: { id: params.id },
include: {
adherent: true,
trajet: true,
},
});
if (!participation) {
return new NextResponse('Participation non trouvée', { status: 404 });
}
const filePath = getParticipationStoragePath(participation.id);
const pdfBuffer = await generateParticipationPDF(
{
adherentNom: participation.adherent.nom,
adherentPrenom: participation.adherent.prenom,
adherentAdresse: participation.adherent.adresse,
destinataireEmail: participation.destinataireEmail,
destinataireNom: participation.destinataireNom,
dateTrajet: participation.trajet.date,
adresseDepart: participation.trajet.adresseDepart,
adresseArrivee: participation.trajet.adresseArrivee,
montant: participation.montant ?? undefined,
complement: participation.complement ?? undefined,
participationId: participation.id,
},
filePath
);
return new NextResponse(pdfBuffer, {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `inline; filename="participation-${params.id}.pdf"`,
},
});
} catch (error) {
console.error('Erreur lors de la récupération du PDF:', error);
return new NextResponse('Erreur serveur', { status: 500 });
}
}

View File

@@ -0,0 +1,203 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { getCurrentUser } from '@/lib/auth';
import { generateParticipationPDF, getParticipationStoragePath } from '@/lib/participation-pdf';
// GET - Récupérer une participation
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 });
}
const participation = await prisma.participationFinanciere.findUnique({
where: { id: params.id },
include: {
adherent: true,
trajet: {
include: {
chauffeur: { select: { nom: true, prenom: true } },
universPro: { select: { nom: true, prenom: true, nomEntreprise: true, email: true } },
},
},
},
});
if (!participation) {
return NextResponse.json({ error: 'Participation non trouvée' }, { status: 404 });
}
return NextResponse.json(participation);
} catch (error) {
console.error('Erreur lors de la récupération de la participation:', error);
return NextResponse.json(
{ error: 'Erreur serveur' },
{ status: 500 }
);
}
}
// PUT - Modifier une participation
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 });
}
const participation = await prisma.participationFinanciere.findUnique({
where: { id: params.id },
include: {
adherent: true,
trajet: { include: { adherent: true } },
},
});
if (!participation) {
return NextResponse.json({ error: 'Participation non trouvée' }, { status: 404 });
}
const body = await request.json();
const { destinataireEmail, destinataireNom, destinataireType, montant, complement } = body;
const updated = await prisma.participationFinanciere.update({
where: { id: params.id },
data: {
...(destinataireEmail && { destinataireEmail }),
...(destinataireNom && { destinataireNom }),
...(destinataireType && { destinataireType }),
...(montant !== undefined && { montant }),
...(complement !== undefined && { complement }),
},
});
// Régénérer le PDF si les données ont changé
const dataToUpdate =
destinataireEmail || destinataireNom || montant !== undefined || complement !== undefined;
if (dataToUpdate) {
const filePath = getParticipationStoragePath(participation.id);
await generateParticipationPDF(
{
adherentNom: participation.adherent.nom,
adherentPrenom: participation.adherent.prenom,
adherentAdresse: participation.adherent.adresse,
destinataireEmail: updated.destinataireEmail,
destinataireNom: updated.destinataireNom,
dateTrajet: participation.trajet.date,
adresseDepart: participation.trajet.adresseDepart,
adresseArrivee: participation.trajet.adresseArrivee,
montant: updated.montant ?? undefined,
complement: updated.complement ?? undefined,
participationId: participation.id,
},
filePath
);
await prisma.participationFinanciere.update({
where: { id: params.id },
data: { filePath },
});
}
const fullUpdated = await prisma.participationFinanciere.findUnique({
where: { id: params.id },
include: {
adherent: { select: { id: true, nom: true, prenom: true, email: true } },
trajet: { select: { id: true, date: true, adresseDepart: true, adresseArrivee: true, statut: true } },
},
});
return NextResponse.json(fullUpdated);
} catch (error) {
console.error('Erreur lors de la mise à jour de la participation:', error);
return NextResponse.json(
{ error: 'Erreur serveur' },
{ status: 500 }
);
}
}
// PATCH - Changer le statut d'une participation
export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 });
}
const body = await request.json();
const { statut } = body;
const validStatuts = ['en_attente', 'envoye', 'paye', 'archive'];
if (!statut || !validStatuts.includes(statut)) {
return NextResponse.json({ error: 'Statut invalide' }, { status: 400 });
}
const participation = await prisma.participationFinanciere.findUnique({
where: { id: params.id },
});
if (!participation) {
return NextResponse.json({ error: 'Participation non trouvée' }, { status: 404 });
}
const updated = await prisma.participationFinanciere.update({
where: { id: params.id },
data: { statut },
});
return NextResponse.json(updated);
} catch (error) {
console.error('Erreur lors du changement de statut:', error);
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 });
}
}
// DELETE - Supprimer une participation
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 });
}
const participation = await prisma.participationFinanciere.findUnique({
where: { id: params.id },
});
if (!participation) {
return NextResponse.json({ error: 'Participation non trouvée' }, { status: 404 });
}
const fs = await import('fs');
const path = await import('path');
const filePath = getParticipationStoragePath(participation.id);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
await prisma.participationFinanciere.delete({
where: { id: params.id },
});
return NextResponse.json({ message: 'Participation supprimée' });
} catch (error) {
console.error('Erreur lors de la suppression de la participation:', error);
return NextResponse.json(
{ error: 'Erreur serveur' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,110 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { getCurrentUser } from '@/lib/auth';
import { getParticipationStoragePath } from '@/lib/participation-pdf';
import fs from 'fs';
// POST - Envoyer la participation par email
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 });
}
const participation = await prisma.participationFinanciere.findUnique({
where: { id: params.id },
include: {
adherent: { select: { prenom: true, nom: true } },
trajet: { select: { date: true } },
},
});
if (!participation) {
return NextResponse.json({ error: 'Participation non trouvée' }, { status: 404 });
}
const filePath = getParticipationStoragePath(participation.id);
if (!fs.existsSync(filePath)) {
return NextResponse.json(
{ error: 'Le document PDF n\'a pas été généré' },
{ status: 400 }
);
}
// Vérifier la configuration email
const smtpHost = process.env.SMTP_HOST;
const smtpUser = process.env.SMTP_USER;
const smtpPass = process.env.SMTP_PASS;
if (!smtpHost || !smtpUser || !smtpPass) {
return NextResponse.json(
{
error:
"L'envoi par email n'est pas configuré. Configurez SMTP_HOST, SMTP_USER et SMTP_PASS dans les variables d'environnement.",
},
{ status: 503 }
);
}
const nodemailer = await import('nodemailer');
const transporter = nodemailer.default.createTransport({
host: smtpHost,
port: parseInt(process.env.SMTP_PORT || '587'),
secure: process.env.SMTP_SECURE === 'true',
auth: {
user: smtpUser,
pass: smtpPass,
},
});
const pdfBuffer = fs.readFileSync(filePath);
const dateTrajet = participation.trajet?.date
? new Date(participation.trajet.date).toLocaleDateString('fr-FR', {
day: '2-digit',
month: 'long',
year: 'numeric',
})
: '';
await transporter.sendMail({
from: process.env.SMTP_FROM || smtpUser,
to: participation.destinataireEmail,
subject: `Participation financière - ${participation.adherent.prenom} ${participation.adherent.nom} - ${dateTrajet}`,
text: `Bonjour,\n\nVeuillez trouver ci-joint la participation financière concernant le trajet du ${dateTrajet} pour ${participation.adherent.prenom} ${participation.adherent.nom}.\n\nCordialement`,
html: `
<p>Bonjour,</p>
<p>Veuillez trouver ci-joint la participation financière concernant le trajet du <strong>${dateTrajet}</strong> pour <strong>${participation.adherent.prenom} ${participation.adherent.nom}</strong>.</p>
<p>Cordialement</p>
`,
attachments: [
{
filename: `participation-financiere-${params.id}.pdf`,
content: pdfBuffer,
},
],
});
// Mettre à jour le statut en "envoyé"
await prisma.participationFinanciere.update({
where: { id: params.id },
data: { statut: 'envoye' },
});
return NextResponse.json({
message: `Participation envoyée à ${participation.destinataireEmail}`,
});
} catch (error) {
console.error('Erreur lors de l\'envoi de l\'email:', error);
return NextResponse.json(
{
error:
error instanceof Error ? error.message : 'Erreur lors de l\'envoi de l\'email',
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { getCurrentUser } from '@/lib/auth';
// GET - Liste toutes les participations financières
export async function GET(request: NextRequest) {
try {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 });
}
const participations = await prisma.participationFinanciere.findMany({
orderBy: { createdAt: 'desc' },
include: {
adherent: {
select: {
id: true,
nom: true,
prenom: true,
email: true,
},
},
trajet: {
select: {
id: true,
date: true,
adresseDepart: true,
adresseArrivee: true,
statut: true,
chauffeur: {
select: {
id: true,
nom: true,
prenom: true,
},
},
},
},
},
});
return NextResponse.json(participations);
} catch (error) {
console.error('Erreur lors de la récupération des participations:', error);
return NextResponse.json(
{ error: 'Erreur serveur' },
{ status: 500 }
);
}
}

View File

@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { getCurrentUser } from '@/lib/auth';
import { createNotificationForAllUsers } from '@/lib/notifications';
import { createParticipationForTrajet } from '@/lib/participation-financiere';
// GET - Récupérer un trajet spécifique
export async function GET(
@@ -69,6 +70,13 @@ export async function PUT(
const body = await request.json();
const { date, adresseDepart, adresseArrivee, commentaire, instructions, statut, adherentId, chauffeurId } = body;
const previousTrajet = statut
? await prisma.trajet.findUnique({
where: { id: params.id },
select: { statut: true },
})
: null;
const trajet = await prisma.trajet.update({
where: { id: params.id },
data: {
@@ -80,6 +88,7 @@ export async function PUT(
...(statut && { statut }),
...(adherentId && { adherentId }),
...(chauffeurId !== undefined && { chauffeurId }),
...(body.universProId !== undefined && { universProId: body.universProId || null }),
},
include: {
adherent: {
@@ -105,52 +114,47 @@ export async function PUT(
});
// Créer une notification si le statut a changé
if (statut) {
const oldTrajet = await prisma.trajet.findUnique({
where: { id: params.id },
include: {
adherent: {
select: {
nom: true,
prenom: true,
},
},
},
if (statut && previousTrajet && previousTrajet.statut !== statut) {
const dateFormatted = new Date(trajet.date).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
if (oldTrajet && oldTrajet.statut !== statut) {
const dateFormatted = new Date(trajet.date).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
let notificationType: 'trajet_cancelled' | 'trajet_completed' | null = null;
let notificationTitle = '';
let notificationMessage = '';
let notificationType: 'trajet_cancelled' | 'trajet_completed' | null = null;
let notificationTitle = '';
let notificationMessage = '';
if (statut === 'Annulé') {
notificationType = 'trajet_cancelled';
notificationTitle = 'Trajet annulé';
notificationMessage = `Le trajet pour ${trajet.adherent.prenom} ${trajet.adherent.nom} du ${dateFormatted} a été annulé`;
} else if (statut === 'Terminé') {
notificationType = 'trajet_completed';
notificationTitle = 'Trajet terminé';
notificationMessage = `Le trajet pour ${trajet.adherent.prenom} ${trajet.adherent.nom} du ${dateFormatted} est terminé`;
}
if (statut === 'Annulé') {
notificationType = 'trajet_cancelled';
notificationTitle = 'Trajet annulé';
notificationMessage = `Le trajet pour ${trajet.adherent.prenom} ${trajet.adherent.nom} du ${dateFormatted} a été annulé`;
} else if (statut === 'Terminé') {
notificationType = 'trajet_completed';
notificationTitle = 'Trajet terminé';
notificationMessage = `Le trajet pour ${trajet.adherent.prenom} ${trajet.adherent.nom} du ${dateFormatted} est terminé`;
}
if (notificationType) {
await createNotificationForAllUsers(
{
type: notificationType,
title: notificationTitle,
message: notificationMessage,
link: '/dashboard/calendrier',
},
user.id
);
}
if (notificationType) {
await createNotificationForAllUsers(
{
type: notificationType,
title: notificationTitle,
message: notificationMessage,
link: '/dashboard/calendrier',
},
user.id
);
// Créer la participation financière quand le trajet est terminé ou validé
if (statut === 'Terminé' || statut === 'Validé') {
try {
await createParticipationForTrajet(params.id);
} catch (err) {
console.error('Erreur création participation:', err);
}
}
}

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { getCurrentUser } from '@/lib/auth';
import { createParticipationForTrajet } from '@/lib/participation-financiere';
// POST - Valider un trajet et déduire les heures du chauffeur
export async function POST(
@@ -23,11 +24,12 @@ export async function POST(
);
}
// Récupérer le trajet avec le chauffeur
// Récupérer le trajet avec le chauffeur et univers pro
const trajet = await prisma.trajet.findUnique({
where: { id: params.id },
include: {
chauffeur: true,
universPro: true,
},
});
@@ -97,6 +99,13 @@ export async function POST(
}),
]);
// Créer la participation financière (document)
try {
await createParticipationForTrajet(params.id);
} catch (err) {
console.error('Erreur création participation:', err);
}
return NextResponse.json({
trajet: trajetUpdated,
chauffeur: {

View File

@@ -0,0 +1,31 @@
import { getCurrentUser } from '@/lib/auth';
import { hasPageAccess } from '@/lib/permissions';
import { redirect } from 'next/navigation';
import DashboardLayout from '@/components/DashboardLayout';
import ParticipationFinanciereList from '@/components/ParticipationFinanciereList';
export default async function ParticipationFinancierePage() {
const user = await getCurrentUser();
if (!user) {
redirect('/login');
}
const hasAccess = await hasPageAccess(user.id, '/dashboard/factures');
if (!hasAccess) {
redirect('/dashboard/parametres');
}
return (
<DashboardLayout user={user}>
<div className="p-3 sm:p-4 md:p-6 lg:p-8">
<h1 className="text-xl sm:text-2xl md:text-3xl font-semibold text-cblack mb-1 sm:mb-2">
Participation financière
</h1>
<p className="text-xs md:text-sm text-cgray mb-4 sm:mb-6 md:mb-8">
Documents de participation générés à la fin de chaque trajet
</p>
<ParticipationFinanciereList />
</div>
</DashboardLayout>
);
}

View File

@@ -121,3 +121,17 @@ body {
.animate-slideInRight {
animation: slideInRight 0.3s ease-out;
}
/* Sélection lisible dans les modales de formulaire */
.participation-form input,
.participation-form select,
.participation-form textarea {
color: #181818;
color-scheme: light;
}
.participation-form input::selection,
.participation-form select::selection,
.participation-form textarea::selection {
background-color: #17B6C4;
color: white;
}

View File

@@ -428,11 +428,12 @@ export default function CalendrierTrajets({ refreshTrigger }: CalendrierTrajetsP
date.getDate() === new Date().getDate() &&
date.getMonth() === new Date().getMonth() &&
date.getFullYear() === new Date().getFullYear();
const isSelected =
const isSelected = !!(
selectedDate &&
date.getDate() === selectedDate.getDate() &&
date.getMonth() === selectedDate.getMonth() &&
date.getFullYear() === selectedDate.getFullYear();
date.getFullYear() === selectedDate.getFullYear()
);
return (
<DroppableDayCell

View File

@@ -184,7 +184,7 @@ export default function DashboardContent({ userName }: DashboardContentProps) {
{stats ? `${stats.participationsMois.montant.toFixed(2).replace('.', ',')}` : '0,00€'}
</p>
<p className="text-[10px] sm:text-xs text-gray-500 font-medium">
{stats ? `${stats.participationsMois.nombreFactures} ${stats.participationsMois.nombreFactures > 1 ? 'Factures' : 'Facture'}` : '0 Facture'}
{stats ? `${stats.participationsMois.nombreFactures} participation${stats.participationsMois.nombreFactures > 1 ? 's' : ''}` : '0 participation'}
</p>
</div>
</div>
@@ -303,7 +303,7 @@ export default function DashboardContent({ userName }: DashboardContentProps) {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<h3 className="text-xs sm:text-sm font-bold text-gray-900 group-hover:text-lblue transition-colors">Nouvelle facture</h3>
<h3 className="text-xs sm:text-sm font-bold text-gray-900 group-hover:text-lblue transition-colors">Participation financière</h3>
</div>
</button>

View File

@@ -157,7 +157,7 @@ export default function DashboardLayout({ user, children }: DashboardLayoutProps
),
},
{
label: 'Factures',
label: 'Participation financière',
href: '/dashboard/factures',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">

View File

@@ -0,0 +1,180 @@
'use client';
import { useState, useEffect } from 'react';
import { useNotification } from './NotificationProvider';
interface Participation {
id: string;
destinataireEmail: string;
destinataireNom: string;
destinataireType: string;
montant: number | null;
complement: string | null;
adherent: { id: string; nom: string; prenom: string; email: string };
trajet: { id: string; date: string; adresseDepart: string; adresseArrivee: string };
}
interface ParticipationEditModalProps {
participation: Participation;
onClose: () => void;
onSuccess: () => void;
}
export default function ParticipationEditModal({
participation,
onClose,
onSuccess,
}: ParticipationEditModalProps) {
const { showNotification } = useNotification();
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState({
destinataireEmail: '',
destinataireNom: '',
destinataireType: 'adherent',
montant: '',
complement: '',
});
useEffect(() => {
setFormData({
destinataireEmail: participation.destinataireEmail,
destinataireNom: participation.destinataireNom,
destinataireType: participation.destinataireType || 'adherent',
montant: participation.montant != null ? String(participation.montant) : '',
complement: participation.complement || '',
});
}, [participation]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const response = await fetch(`/api/participations/${participation.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
destinataireEmail: formData.destinataireEmail,
destinataireNom: formData.destinataireNom,
destinataireType: formData.destinataireType,
montant: formData.montant ? parseFloat(formData.montant) : null,
complement: formData.complement || null,
}),
});
if (response.ok) {
onSuccess();
onClose();
} else {
const data = await response.json();
showNotification('error', data.error || 'Erreur lors de la mise à jour');
}
} catch (error) {
showNotification('error', 'Erreur lors de la mise à jour');
} finally {
setLoading(false);
}
};
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) onClose();
};
const inputBaseClass =
'w-full px-3 py-2 border border-gray-300 rounded-lg bg-white text-gray-900 placeholder-gray-400 focus:ring-2 focus:ring-lblue focus:border-transparent selection:bg-lblue selection:text-white';
return (
<div
role="dialog"
aria-modal="true"
className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4 animate-fadeIn"
onClick={handleBackdropClick}
>
<div
className="bg-white rounded-lg shadow-xl max-w-lg w-full animate-slideUp border border-gray-200 max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="border-b border-gray-200 px-6 py-4">
<h2 className="text-xl font-semibold text-gray-900">Modifier la participation</h2>
<p className="text-sm text-gray-500 mt-1">
{participation.adherent.prenom} {participation.adherent.nom} -{' '}
{new Date(participation.trajet.date).toLocaleDateString('fr-FR')}
</p>
</div>
<form onSubmit={handleSubmit} className="participation-form px-6 py-4 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Email destinataire</label>
<input
type="email"
value={formData.destinataireEmail}
onChange={(e) => setFormData({ ...formData, destinataireEmail: e.target.value })}
className={inputBaseClass}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Nom destinataire</label>
<input
type="text"
value={formData.destinataireNom}
onChange={(e) => setFormData({ ...formData, destinataireNom: e.target.value })}
className={inputBaseClass}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Type destinataire</label>
<select
value={formData.destinataireType}
onChange={(e) => setFormData({ ...formData, destinataireType: e.target.value })}
className={inputBaseClass}
>
<option value="adherent">Adhérent</option>
<option value="univers_pro">Univers Pro</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Montant ()</label>
<input
type="number"
step="0.01"
min="0"
value={formData.montant}
onChange={(e) => setFormData({ ...formData, montant: e.target.value })}
className={inputBaseClass}
placeholder="6.80"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Informations complémentaires
</label>
<textarea
value={formData.complement}
onChange={(e) => setFormData({ ...formData, complement: e.target.value })}
rows={3}
className={`${inputBaseClass} resize-none`}
placeholder="Notes, commentaires..."
/>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900"
>
Annuler
</button>
<button
type="submit"
disabled={loading}
className="px-6 py-2 text-sm font-medium bg-lblue hover:bg-dblue text-white rounded-lg disabled:opacity-50"
>
{loading ? 'Enregistrement...' : 'Enregistrer'}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,796 @@
'use client';
import { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useNotification } from './NotificationProvider';
import ConfirmModal from './ConfirmModal';
import ParticipationEditModal from './ParticipationEditModal';
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',
},
};
function getRefNum(id: string) {
return `PART-${id.slice(-8).toUpperCase()}`;
}
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 = getRefNum(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 = getRefNum(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 = getRefNum(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}
/>
)}
</>
);
}

View File

@@ -32,8 +32,8 @@ export const AVAILABLE_PAGES = [
},
{
route: '/dashboard/factures',
label: 'Factures',
description: 'Gestion des factures',
label: 'Participation financière',
description: 'Documents de participation financière',
},
{
route: '/dashboard/archives',

View File

@@ -0,0 +1,73 @@
import { prisma } from '@/lib/prisma';
import { generateParticipationPDF, getParticipationStoragePath } from './participation-pdf';
const MONTANT_MOYEN = 6.8;
export async function createParticipationForTrajet(trajetId: string): Promise<boolean> {
const trajet = await prisma.trajet.findUnique({
where: { id: trajetId },
include: {
adherent: true,
universPro: true,
},
});
if (!trajet || !['Terminé', 'Validé'].includes(trajet.statut)) {
return false;
}
const existing = await prisma.participationFinanciere.findUnique({
where: { trajetId },
});
if (existing) return true;
// Destinataire: univers pro si lié, sinon adhérent
let destinataireEmail: string;
let destinataireNom: string;
let destinataireType: 'adherent' | 'univers_pro';
if (trajet.universProId && trajet.universPro) {
destinataireEmail = trajet.universPro.email;
destinataireNom = `${trajet.universPro.prenom} ${trajet.universPro.nom} - ${trajet.universPro.nomEntreprise}`;
destinataireType = 'univers_pro';
} else {
destinataireEmail = trajet.adherent.email;
destinataireNom = `${trajet.adherent.prenom} ${trajet.adherent.nom}`;
destinataireType = 'adherent';
}
const participation = await prisma.participationFinanciere.create({
data: {
trajetId,
adherentId: trajet.adherentId,
destinataireEmail,
destinataireNom,
destinataireType,
montant: MONTANT_MOYEN,
},
});
const filePath = getParticipationStoragePath(participation.id);
await generateParticipationPDF(
{
adherentNom: trajet.adherent.nom,
adherentPrenom: trajet.adherent.prenom,
adherentAdresse: trajet.adherent.adresse,
destinataireEmail,
destinataireNom,
dateTrajet: trajet.date,
adresseDepart: trajet.adresseDepart,
adresseArrivee: trajet.adresseArrivee,
montant: MONTANT_MOYEN,
participationId: participation.id,
},
filePath
);
await prisma.participationFinanciere.update({
where: { id: participation.id },
data: { filePath },
});
return true;
}

432
lib/participation-pdf.ts Normal file
View File

@@ -0,0 +1,432 @@
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib';
import path from 'path';
import fs from 'fs';
const MONTANT_MOYEN = 6.8;
// ─── Palette ───────────────────────────────────────────────
const TEAL = { r: 43 / 255, g: 147 / 255, b: 157 / 255 };
const DARK = { r: 0.2, g: 0.2, b: 0.2 };
const MID = { r: 0.5, g: 0.5, b: 0.5 };
const LINE = { r: 0.82, g: 0.82, b: 0.82 };
const LIGHT_BG = { r: 0.96, g: 0.96, b: 0.96 };
const TEAL_BG = { r: 235 / 255, g: 248 / 255, b: 249 / 255 };
export interface ParticipationData {
adherentNom: string;
adherentPrenom: string;
adherentAdresse: string;
destinataireEmail: string;
destinataireNom: string;
dateTrajet: Date;
adresseDepart: string;
adresseArrivee: string;
montant?: number;
complement?: string;
participationId?: string;
}
// ─── Helpers ───────────────────────────────────────────────
async function loadLogoAsPng(logoName: string, maxSize: number): Promise<Buffer | null> {
try {
const sharp = (await import('sharp')).default;
const logoPath = path.join(process.cwd(), 'public', logoName);
if (!fs.existsSync(logoPath)) return null;
return sharp(logoPath)
.resize(maxSize, maxSize, { fit: 'inside' })
.png()
.toBuffer();
} catch {
return null;
}
}
function wrapText(text: string, maxWidth: number, measureFn: (t: string) => number): string[] {
const words = text.split(/\s+/);
const lines: string[] = [];
let currentLine = '';
for (const word of words) {
const testLine = currentLine ? `${currentLine} ${word}` : word;
if (measureFn(testLine) <= maxWidth) {
currentLine = testLine;
} else {
if (currentLine) lines.push(currentLine);
currentLine = word;
}
}
if (currentLine) lines.push(currentLine);
return lines;
}
// ─── Génération du PDF ─────────────────────────────────────
export async function generateParticipationPDF(
data: ParticipationData,
outputPath: string
): Promise<Buffer> {
const pdfDoc = await PDFDocument.create();
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
const page = pdfDoc.addPage([595, 842]); // A4
const { width } = page.getSize();
const M = 50; // marge
const R = width - M; // bord droit (545)
const CW = width - 2 * M; // largeur utile (495)
const teal = rgb(TEAL.r, TEAL.g, TEAL.b);
const dark = rgb(DARK.r, DARK.g, DARK.b);
const mid = rgb(MID.r, MID.g, MID.b);
// Raccourcis
const measure = (text: string, size: number, f = font) => f.widthOfTextAtSize(text, size);
const drawRight = (
text: string, x: number, y: number, size: number,
f = font, color = dark
) => {
page.drawText(text, { x: x - f.widthOfTextAtSize(text, size), y, size, font: f, color });
};
const hLine = (y: number, x = M, w = CW) => {
page.drawRectangle({
x, y, width: w, height: 1,
color: rgb(LINE.r, LINE.g, LINE.b),
});
};
// Données formatées
const montant = data.montant ?? MONTANT_MOYEN;
const montantStr = montant.toFixed(2).replace('.', ',') + ' \u20AC';
const refNum = data.participationId
? `PART-${data.participationId.slice(-8).toUpperCase()}`
: 'PART-XXXXXXXX';
const dateEmission = data.dateTrajet.toLocaleDateString('fr-FR', {
day: '2-digit', month: '2-digit', year: 'numeric',
});
const dateTrajetLong = data.dateTrajet.toLocaleDateString('fr-FR', {
weekday: 'long', day: '2-digit', month: 'long', year: 'numeric',
});
let y = 800;
// ════════════════════════════════════════════════════════
// EN-TÊTE
// ════════════════════════════════════════════════════════
// Logo
const logoPng = await loadLogoAsPng('logo.svg', 48);
const logoS = 48;
if (logoPng) {
try {
const img = await pdfDoc.embedPng(logoPng);
const w = img.width * (logoS / img.height);
page.drawImage(img, {
x: M, y: y - logoS,
width: Math.min(w, logoS), height: logoS,
});
} catch { /* fallback silencieux */ }
}
// Infos association (à droite du logo)
const hx = M + logoS + 14;
page.drawText('Association MAD', {
x: hx, y: y - 15, size: 13, font: fontBold, color: teal,
});
page.drawText('Adresse postale', {
x: hx, y: y - 29, size: 9, font, color: mid,
});
page.drawText('Ville, Code postal', {
x: hx, y: y - 41, size: 9, font, color: mid,
});
// Référence document (aligné à droite)
drawRight('Participation financière', R, y - 12, 9, font, mid);
drawRight(refNum, R, y - 28, 11, fontBold, teal);
drawRight(`\u00C9mise le ${dateEmission}`, R, y - 44, 9, font, mid);
// Séparateur
y -= 62;
hLine(y);
// ════════════════════════════════════════════════════════
// TITRE
// ════════════════════════════════════════════════════════
y -= 30;
page.drawText('Participation financière au transport', {
x: M, y, size: 17, font: fontBold, color: teal,
});
y -= 16;
page.drawText(
"Document établi dans le cadre de l'accompagnement au transport par l'Association MAD.",
{ x: M, y, size: 9, font, color: mid },
);
// ════════════════════════════════════════════════════════
// BLOCS D'INFORMATION
// ════════════════════════════════════════════════════════
y -= 56;
const gap = 24;
const bw = (CW - gap) / 2; // ~235px par bloc
const bx2 = M + bw + gap; // x du bloc droit
// Helper : titre de section avec soulignement teal
const sectionTitle = (label: string, x: number, yy: number) => {
page.drawText(label, { x, y: yy, size: 8, font: fontBold, color: teal });
page.drawRectangle({
x, y: yy - 3,
width: fontBold.widthOfTextAtSize(label, 8), height: 1.5,
color: teal,
});
};
// ── Bloc gauche : FACTURÉ À ──
const blockTopY = y;
sectionTitle('FACTURÉ À', M, blockTopY);
let ly = blockTopY - 20;
const destLines = wrapText(data.destinataireNom, bw, (t) => measure(t, 10, fontBold));
for (const line of destLines.slice(0, 2)) {
page.drawText(line, { x: M, y: ly, size: 10, font: fontBold, color: dark });
ly -= 14;
}
page.drawText(data.destinataireEmail, {
x: M, y: ly, size: 9, font, color: mid,
});
ly -= 14;
// ── Bloc droit : DÉTAILS DU TRAJET ──
sectionTitle('DÉTAILS DU TRAJET', bx2, blockTopY);
let ry = blockTopY - 20;
const infos: { label: string; value: string }[] = [
{ label: 'Date', value: dateTrajetLong },
{ label: 'Adhérent', value: `${data.adherentPrenom} ${data.adherentNom}` },
];
for (const item of infos) {
const prefix = `${item.label} : `;
const prefixW = measure(prefix, 8, fontBold);
page.drawText(prefix, {
x: bx2, y: ry, size: 8, font: fontBold, color: dark,
});
const valLines = wrapText(item.value, bw - prefixW, (t) => measure(t, 9));
for (let i = 0; i < Math.min(valLines.length, 2); i++) {
page.drawText(valLines[i], {
x: bx2 + prefixW, y: ry, size: 9, font, color: dark,
});
if (i < valLines.length - 1) ry -= 12;
}
ry -= 16;
}
// ════════════════════════════════════════════════════════
// TABLEAU
// ════════════════════════════════════════════════════════
y = Math.min(ly, ry) - 56;
// Positions des colonnes
const cDesig = M + 10;
const cQte = M + 290;
const cPu = M + 370;
// cMontant : aligné à droite sur R - 8
// ── En-tête du tableau ──
const thH = 28;
// Ligne d'accent teal en haut
page.drawRectangle({
x: M, y: y + thH - 2, width: CW, height: 2, color: teal,
});
// Fond gris clair
page.drawRectangle({
x: M, y: y, width: CW, height: thH - 2,
color: rgb(LIGHT_BG.r, LIGHT_BG.g, LIGHT_BG.b),
});
const thY = y + 8;
page.drawText('DÉSIGNATION', {
x: cDesig, y: thY, size: 8, font: fontBold, color: dark,
});
// QTÉ centré
const qteLabel = 'QTÉ';
const qteLabelW = measure(qteLabel, 8, fontBold);
page.drawText(qteLabel, {
x: cQte + 20 - qteLabelW / 2, y: thY, size: 8, font: fontBold, color: dark,
});
page.drawText('P.U.', {
x: cPu, y: thY, size: 8, font: fontBold, color: dark,
});
drawRight('MONTANT', R - 8, thY, 8, fontBold, dark);
// Ligne sous en-tête
hLine(y);
// ── Ligne article ──
y -= 18;
const articleTitleY = y;
// Titre de l'article
page.drawText('Participation au transport', {
x: cDesig, y: articleTitleY, size: 10, font: fontBold, color: dark,
});
// Adresses complètes (wrappées, sans troncature)
const maxDesigW = cQte - cDesig - 20;
const depLines = wrapText(
`De : ${data.adresseDepart}`, maxDesigW, (t) => measure(t, 8),
);
const arrLines = wrapText(
`Vers : ${data.adresseArrivee}`, maxDesigW, (t) => measure(t, 8),
);
let addrY = articleTitleY - 16;
for (const line of depLines.slice(0, 3)) {
page.drawText(line, { x: cDesig, y: addrY, size: 8, font, color: mid });
addrY -= 11;
}
addrY -= 2; // petit espace entre départ et arrivée
for (const line of arrLines.slice(0, 3)) {
page.drawText(line, { x: cDesig, y: addrY, size: 8, font, color: mid });
addrY -= 11;
}
// Valeurs numériques (centrées verticalement avec le titre)
const numY = articleTitleY - 6;
// QTÉ centré
const qteVal = '1';
const qteValW = measure(qteVal, 10);
page.drawText(qteVal, {
x: cQte + 20 - qteValW / 2, y: numY, size: 10, font, color: dark,
});
page.drawText(montantStr, {
x: cPu, y: numY, size: 10, font, color: dark,
});
drawRight(montantStr, R - 8, numY, 10, fontBold, dark);
// Ligne de fin d'article
const rowBottom = Math.min(addrY, numY - 16) - 6;
hLine(rowBottom);
// ════════════════════════════════════════════════════════
// TOTAUX
// ════════════════════════════════════════════════════════
y = rowBottom - 20;
const tLabelX = R - 160;
const tValX = R - 8;
// Sous-total
page.drawText('Sous-total', {
x: tLabelX, y, size: 10, font, color: dark,
});
drawRight(montantStr, tValX, y, 10, font, dark);
// Séparateur
y -= 20;
hLine(y + 8, tLabelX, R - tLabelX);
// Total dû (surligné)
y -= 4;
page.drawRectangle({
x: tLabelX - 10, y: y - 6,
width: R - tLabelX + 18, height: 26,
color: rgb(TEAL_BG.r, TEAL_BG.g, TEAL_BG.b),
});
page.drawText('Total dû', {
x: tLabelX, y: y + 2, size: 13, font: fontBold, color: teal,
});
drawRight(montantStr, tValX, y + 2, 13, fontBold, teal);
// Échéance
y -= 32;
page.drawText(`Échéance de paiement : ${dateEmission}`, {
x: tLabelX, y, size: 8, font, color: mid,
});
// ════════════════════════════════════════════════════════
// COMPLÉMENT (optionnel)
// ════════════════════════════════════════════════════════
if (data.complement) {
y -= 34;
page.drawText('Note :', {
x: M, y, size: 9, font: fontBold, color: dark,
});
y -= 14;
const compLines = wrapText(data.complement, CW - 20, (t) => measure(t, 9));
for (const line of compLines.slice(0, 4)) {
page.drawText(line, { x: M, y, size: 9, font, color: dark });
y -= 13;
}
}
// ════════════════════════════════════════════════════════
// PIED DE PAGE
// ════════════════════════════════════════════════════════
const footerY = 55;
page.drawRectangle({
x: 0, y: 0, width, height: footerY + 2,
color: rgb(0.98, 0.98, 0.98),
});
hLine(footerY);
page.drawText(
'Ce document fait office de participation financière au transport. Établi par Association MAD.',
{ x: M, y: 35, size: 8, font, color: mid },
);
// Propulsé par LGX + logo
const propText = 'Propulsé par LGX';
const lgxLogoPng = await loadLogoAsPng('lgx-logo.svg', 28);
if (lgxLogoPng) {
try {
const lgxImg = await pdfDoc.embedPng(lgxLogoPng);
const lgxH = 16;
const lgxW = lgxImg.width * (lgxH / lgxImg.height);
const blockW = fontBold.widthOfTextAtSize(propText, 9) + lgxW + 8;
const lgxX = R - blockW;
page.drawText(propText, {
x: lgxX, y: 35, size: 9, font: fontBold, color: mid,
});
page.drawImage(lgxImg, {
x: lgxX + fontBold.widthOfTextAtSize(propText, 9) + 6,
y: 29, width: lgxW, height: lgxH,
});
} catch {
page.drawText(propText, {
x: R - fontBold.widthOfTextAtSize(propText, 9),
y: 35, size: 9, font: fontBold, color: teal,
});
}
} else {
page.drawText(propText, {
x: R - fontBold.widthOfTextAtSize(propText, 9),
y: 35, size: 9, font: fontBold, color: teal,
});
}
// ════════════════════════════════════════════════════════
// SAUVEGARDE
// ════════════════════════════════════════════════════════
const pdfBytes = await pdfDoc.save();
const dir = path.dirname(outputPath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(outputPath, pdfBytes);
return Buffer.from(pdfBytes);
}
export function getParticipationStoragePath(participationId: string): string {
return path.join(process.cwd(), 'data', 'participations', `${participationId}.pdf`);
}

585
package-lock.json generated
View File

@@ -17,14 +17,18 @@
"leaflet": "^1.9.4",
"next": "14.2.5",
"next-auth": "^4.24.7",
"nodemailer": "^7.0.13",
"pdf-lib": "^1.17.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-leaflet": "^4.2.1",
"sharp": "^0.34.5",
"swr": "^2.3.8"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20.14.12",
"@types/nodemailer": "^7.0.9",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.19",
@@ -128,7 +132,6 @@
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
"integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
@@ -689,6 +692,471 @@
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@img/colour": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
"integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.2.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
"cpu": [
"arm"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
"cpu": [
"ppc64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
"cpu": [
"riscv64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
"cpu": [
"s390x"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
"cpu": [
"arm"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.2.4"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
"cpu": [
"ppc64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
"cpu": [
"riscv64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
"cpu": [
"s390x"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.2.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.7.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -1005,6 +1473,24 @@
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/@pdf-lib/standard-fonts": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
"integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==",
"license": "MIT",
"dependencies": {
"pako": "^1.0.6"
}
},
"node_modules/@pdf-lib/upng": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz",
"integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==",
"license": "MIT",
"dependencies": {
"pako": "^1.0.10"
}
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -1175,6 +1661,16 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/nodemailer": {
"version": "7.0.9",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.9.tgz",
"integrity": "sha512-vI8oF1M+8JvQhsId0Pc38BdUP2evenIIys7c7p+9OZXSPOH5c1dyINP1jT8xQ2xPuBUXmIC87s+91IZMDjH8Ow==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@@ -2452,6 +2948,15 @@
"node": ">=6"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/didyoumean": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
@@ -4776,6 +5281,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/nodemailer": {
"version": "7.0.13",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
"integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@@ -5026,6 +5540,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -5110,6 +5630,24 @@
"node": ">=8"
}
},
"node_modules/pdf-lib": {
"version": "1.17.1",
"resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz",
"integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==",
"license": "MIT",
"dependencies": {
"@pdf-lib/standard-fonts": "^1.0.0",
"@pdf-lib/upng": "^1.0.1",
"pako": "^1.0.11",
"tslib": "^1.11.1"
}
},
"node_modules/pdf-lib/node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"license": "0BSD"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -5719,7 +6257,6 @@
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@@ -5777,6 +6314,50 @@
"node": ">= 0.4"
}
},
"node_modules/sharp": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
"semver": "^7.7.3"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.34.5",
"@img/sharp-darwin-x64": "0.34.5",
"@img/sharp-libvips-darwin-arm64": "1.2.4",
"@img/sharp-libvips-darwin-x64": "1.2.4",
"@img/sharp-libvips-linux-arm": "1.2.4",
"@img/sharp-libvips-linux-arm64": "1.2.4",
"@img/sharp-libvips-linux-ppc64": "1.2.4",
"@img/sharp-libvips-linux-riscv64": "1.2.4",
"@img/sharp-libvips-linux-s390x": "1.2.4",
"@img/sharp-libvips-linux-x64": "1.2.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
"@img/sharp-linux-arm": "0.34.5",
"@img/sharp-linux-arm64": "0.34.5",
"@img/sharp-linux-ppc64": "0.34.5",
"@img/sharp-linux-riscv64": "0.34.5",
"@img/sharp-linux-s390x": "0.34.5",
"@img/sharp-linux-x64": "0.34.5",
"@img/sharp-linuxmusl-arm64": "0.34.5",
"@img/sharp-linuxmusl-x64": "0.34.5",
"@img/sharp-wasm32": "0.34.5",
"@img/sharp-win32-arm64": "0.34.5",
"@img/sharp-win32-ia32": "0.34.5",
"@img/sharp-win32-x64": "0.34.5"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",

View File

@@ -22,14 +22,18 @@
"leaflet": "^1.9.4",
"next": "14.2.5",
"next-auth": "^4.24.7",
"nodemailer": "^7.0.13",
"pdf-lib": "^1.17.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-leaflet": "^4.2.1",
"sharp": "^0.34.5",
"swr": "^2.3.8"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20.14.12",
"@types/nodemailer": "^7.0.9",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.19",

Binary file not shown.

View File

@@ -72,7 +72,7 @@ model Chauffeur {
status String @default("Disponible") // Disponible, Vacances, Arrêt Maladie
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
trajets Trajet[] // Relation avec les trajets
trajets Trajet[]
}
model UniversPro {
@@ -85,6 +85,7 @@ model UniversPro {
nomEntreprise String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
trajets Trajet[]
}
model Adherent {
@@ -105,7 +106,8 @@ model Adherent {
instructions String? // Instructions
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
trajets Trajet[] // Relation avec les trajets
trajets Trajet[]
participations ParticipationFinanciere[]
}
model Trajet {
@@ -121,6 +123,26 @@ model Trajet {
adherent Adherent @relation(fields: [adherentId], references: [id])
chauffeurId String? // Référence au chauffeur (optionnel)
chauffeur Chauffeur? @relation(fields: [chauffeurId], references: [id])
universProId String? // Référence à l'univers pro (optionnel - pour facturation entreprise)
universPro UniversPro? @relation(fields: [universProId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
participations ParticipationFinanciere[]
}
model ParticipationFinanciere {
id String @id @default(cuid())
trajetId String @unique
trajet Trajet @relation(fields: [trajetId], references: [id], onDelete: Cascade)
adherentId String
adherent Adherent @relation(fields: [adherentId], references: [id], onDelete: Cascade)
destinataireEmail String // Email du destinataire du paiement (adhérent ou univers pro)
destinataireNom String // Nom affiché du destinataire
destinataireType String // "adherent" | "univers_pro"
montant Float? // Montant de la participation (optionnel)
filePath String? // Chemin vers le PDF généré
complement String? // Informations complémentaires (éditable)
statut String @default("en_attente") // en_attente, envoye, paye, archive
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

4
public/lgx-logo.svg Normal file
View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Calque_1" data-name="Calque 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 730.05 604.82">
<path d="M635.89,475.43c-21.05-21-42.09-42.01-63.14-63.01-1.81-1.73-3.62-3.46-5.43-5.19,54.24-54.53,108.48-109.07,162.72-163.6-66.07.2-119.84.83-185.91,1.04-7.84.03-29.1-.1-37.08-.08-43.46.11-91.71-6.65-132.91,10.56-65.61,27.41-98.93,98.45-87.58,167.59l-1.34,1.94-200.24.94c-21.71-1.76-36.85-23.61-26.99-44.23,4.96-10.36,26.33-28.35,35.18-37.48,83.72-86.27,170.11-170.23,254.71-255.75,59.84-54.2,161.48-43.04,204.13,26.77,14.15,23.16,19.02,49.24,18.01,76.26h55.18c1.14-37.41-7.89-75.96-28.13-107.53-62.54-97.58-204.08-111.15-288.27-33.66L21.83,336.96c-48.26,56.84-11.61,137.77,62.07,142.16l219.17-.35c5.76,9.73,10.38,20.04,16.4,29.66,68.14,108.88,207.37,127.97,304.83,44.7,1.5-1.28,4.64-4,7.96-7.02l.21-.18,4.5-4.2c1.51-1.45,2.91-2.83,4.03-4.03l28.59-28.59-33.69-33.69ZM345.09,425.79l-2.28-.48c-12.01-44.28,3.5-95.14,45.4-117.46,13.75-7.32,26.37-9.72,41.82-10.61,56.37-3.25,115.14,1.9,171.76-.31,1.34-.17,3.53.68,3.17,2.33l-101.15,102.12c-42.87,38.9-106.07,19.77-158.72,24.41ZM565.9,529.69c-67.9,42.58-153.54,20.56-198.05-43.88-.67-.98-4.52-6.14-3.84-6.72,38.59-2.02,80.18,5.09,117.51-6.2,18.84-5.7,35.3-16.41,51.13-27.74l60.41,61.99c0,3.29-23.03,19.97-27.15,22.55Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB