diff --git a/.gitignore b/.gitignore
index 2e51005..5783875 100644
--- a/.gitignore
+++ b/.gitignore
@@ -40,3 +40,6 @@ next-env.d.ts
# uploads
/public/uploads
+
+# participations PDF
+/data/participations
diff --git a/app/api/dashboard/stats/route.ts b/app/api/dashboard/stats/route.ts
index fcee248..8ae2085 100644
--- a/app/api/dashboard/stats/route.ts
+++ b/app/api/dashboard/stats/route.ts
@@ -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({
diff --git a/app/api/participations/[id]/pdf/route.ts b/app/api/participations/[id]/pdf/route.ts
new file mode 100644
index 0000000..aa3a328
--- /dev/null
+++ b/app/api/participations/[id]/pdf/route.ts
@@ -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 });
+ }
+}
diff --git a/app/api/participations/[id]/route.ts b/app/api/participations/[id]/route.ts
new file mode 100644
index 0000000..3760b71
--- /dev/null
+++ b/app/api/participations/[id]/route.ts
@@ -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 }
+ );
+ }
+}
diff --git a/app/api/participations/[id]/send/route.ts b/app/api/participations/[id]/send/route.ts
new file mode 100644
index 0000000..c5bdced
--- /dev/null
+++ b/app/api/participations/[id]/send/route.ts
@@ -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: `
+
Bonjour,
+ Veuillez trouver ci-joint la participation financière concernant le trajet du ${dateTrajet} pour ${participation.adherent.prenom} ${participation.adherent.nom} .
+ Cordialement
+ `,
+ 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 }
+ );
+ }
+}
diff --git a/app/api/participations/route.ts b/app/api/participations/route.ts
new file mode 100644
index 0000000..e2e880d
--- /dev/null
+++ b/app/api/participations/route.ts
@@ -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 }
+ );
+ }
+}
diff --git a/app/api/trajets/[id]/route.ts b/app/api/trajets/[id]/route.ts
index 5192245..a20b655 100644
--- a/app/api/trajets/[id]/route.ts
+++ b/app/api/trajets/[id]/route.ts
@@ -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);
}
}
}
diff --git a/app/api/trajets/[id]/validate/route.ts b/app/api/trajets/[id]/validate/route.ts
index b8b5abc..cd1d87e 100644
--- a/app/api/trajets/[id]/validate/route.ts
+++ b/app/api/trajets/[id]/validate/route.ts
@@ -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: {
diff --git a/app/dashboard/factures/page.tsx b/app/dashboard/factures/page.tsx
new file mode 100644
index 0000000..c3cc81d
--- /dev/null
+++ b/app/dashboard/factures/page.tsx
@@ -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 (
+
+
+
+ Participation financière
+
+
+ Documents de participation générés à la fin de chaque trajet
+
+
+
+
+ );
+}
diff --git a/app/globals.css b/app/globals.css
index bbc4cd1..ecdd64c 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -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;
+}
diff --git a/components/CalendrierTrajets.tsx b/components/CalendrierTrajets.tsx
index 7bc7ffe..f3c210b 100644
--- a/components/CalendrierTrajets.tsx
+++ b/components/CalendrierTrajets.tsx
@@ -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 (
- {stats ? `${stats.participationsMois.nombreFactures} ${stats.participationsMois.nombreFactures > 1 ? 'Factures' : 'Facture'}` : '0 Facture'}
+ {stats ? `${stats.participationsMois.nombreFactures} participation${stats.participationsMois.nombreFactures > 1 ? 's' : ''}` : '0 participation'}
@@ -303,7 +303,7 @@ export default function DashboardContent({ userName }: DashboardContentProps) {
- Nouvelle facture
+ Participation financière
diff --git a/components/DashboardLayout.tsx b/components/DashboardLayout.tsx
index f64d836..4696899 100644
--- a/components/DashboardLayout.tsx
+++ b/components/DashboardLayout.tsx
@@ -157,7 +157,7 @@ export default function DashboardLayout({ user, children }: DashboardLayoutProps
),
},
{
- label: 'Factures',
+ label: 'Participation financière',
href: '/dashboard/factures',
icon: (
diff --git a/components/ParticipationEditModal.tsx b/components/ParticipationEditModal.tsx
new file mode 100644
index 0000000..48e5a54
--- /dev/null
+++ b/components/ParticipationEditModal.tsx
@@ -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 (
+
+
e.stopPropagation()}
+ >
+
+
Modifier la participation
+
+ {participation.adherent.prenom} {participation.adherent.nom} -{' '}
+ {new Date(participation.trajet.date).toLocaleDateString('fr-FR')}
+
+
+
+
+
+
+ );
+}
diff --git a/components/ParticipationFinanciereList.tsx b/components/ParticipationFinanciereList.tsx
new file mode 100644
index 0000000..fbb2246
--- /dev/null
+++ b/components/ParticipationFinanciereList.tsx
@@ -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 = {
+ 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([]);
+ const [loading, setLoading] = useState(true);
+ const [searchTerm, setSearchTerm] = useState('');
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+ const [participationToDelete, setParticipationToDelete] = useState(null);
+ const [sendingId, setSendingId] = useState(null);
+ const [editingParticipation, setEditingParticipation] = useState(null);
+ const [openMenuId, setOpenMenuId] = useState(null);
+ const [menuAnchor, setMenuAnchor] = useState<{ top: number; left: number } | null>(null);
+ const [statusFilter, setStatusFilter] = useState('');
+ const [selectedIds, setSelectedIds] = useState>(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 */}
+
+
+
+
+
+
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"
+ />
+
+
+
+
+
+ {/* Filtres statut + actions en masse */}
+ {(participations.length > 0 || selectedIds.size > 0) && (
+
+
+ {/* Filtres par statut */}
+
+ Statut :
+ {[
+ { key: '', label: 'Tous' },
+ ...Object.entries(STATUT_CONFIG).map(([key, config]) => ({ key, label: config.label })),
+ ].map(({ key, label }) => (
+ 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}
+
+ ))}
+
+
+ {/* Barre d'actions en masse - 2 menus déroulants */}
+ {selectedIds.size > 0 && (
+
+
+ {selectedIds.size} sélectionné{selectedIds.size > 1 ? 's' : ''}
+
+
+ {/* Menu Changer le statut */}
+
+
{
+ 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
+
+
+
+
+ {openBulkDropdown === 'statut' && (
+
e.stopPropagation()}
+ >
+ {Object.entries(STATUT_CONFIG).map(([key, config]) => (
+ { 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"
+ >
+
+ {config.label}
+
+ ))}
+
+ )}
+
+
+ {/* Menu Actions */}
+
+
{
+ 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
+
+
+
+
+ {openBulkDropdown === 'actions' && (
+
e.stopPropagation()}
+ >
+
{ 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"
+ >
+
+
+
+ Envoyer par email
+
+
{ 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"
+ >
+
+
+
+ Supprimer
+
+
+ )}
+
+
+
setSelectedIds(new Set())}
+ className="text-xs text-gray-500 hover:text-gray-700 underline self-start sm:ml-auto"
+ >
+ Annuler
+
+
+ )}
+
+
+ )}
+
+ {/* Tableau - même structure que Adhérents / Chauffeurs */}
+
+ {loading ? (
+
Chargement...
+ ) : filteredParticipations.length === 0 ? (
+
+ {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é."}
+
+ ) : (
+ <>
+ {/* Vue desktop - Tableau */}
+
+
+
+
+
+ 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"
+ />
+
+ ADHÉRENT
+ N° DOCUMENT
+ CHAUFFEUR
+ MONTANT
+ STATUT
+ ACTIONS
+
+
+
+ {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 (
+
+
+ handleSelectOne(p.id, e.target.checked)}
+ className="w-4 h-4 text-lblue border-gray-300 rounded focus:ring-lblue"
+ />
+
+
+
+
+ {p.adherent.prenom.charAt(0)}{p.adherent.nom.charAt(0)}
+
+
+
+ {p.adherent.prenom} {p.adherent.nom}
+
+
+
+
+
+ {ref}
+
+
+ {chauffeur ? (
+
+ {chauffeur.prenom} {chauffeur.nom}
+
+ ) : (
+ Non assigné
+ )}
+
+
+ {montant}
+
+
+ {
+ 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}
+
+
+
+
+
handleViewPdf(p.id)}
+ className="text-lblue hover:text-dblue"
+ title="Voir le PDF"
+ >
+
+
+
+
+
+
handleSendEmail(p.id)}
+ disabled={sendingId === p.id}
+ className="text-lgreen hover:text-dgreen disabled:opacity-50"
+ title="Envoyer par email"
+ >
+ {sendingId === p.id ? (
+
+
+
+
+ ) : (
+
+
+
+ )}
+
+
handleEditClick(p)}
+ className="text-lblue hover:text-dblue"
+ title="Modifier"
+ >
+
+
+
+
+
handleDeleteClick(p.id)}
+ className="text-red-500 hover:text-red-700"
+ title="Supprimer"
+ >
+
+
+
+
+
+
+
+ );
+ })}
+
+
+
+
+ {/* Vue mobile - Cartes */}
+
+ {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 (
+
+
+
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"
+ />
+
+ {p.adherent.prenom.charAt(0)}{p.adherent.nom.charAt(0)}
+
+
+
+
+ {p.adherent.prenom} {p.adherent.nom}
+
+
{ref}
+
+
+
Chauffeur
+ {chauffeur ? (
+
{chauffeur.prenom} {chauffeur.nom}
+ ) : (
+
Non assigné
+ )}
+
+
+
+ {
+ 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}
+
+
+
+
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"
+ >
+
+
+
+
+ Voir
+
+
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"
+ >
+
+
+
+ {sendingId === p.id ? 'Envoi...' : 'Envoyer'}
+
+
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"
+ >
+
+
+
+ Modifier
+
+
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"
+ >
+
+
+
+ Supprimer
+
+
+
+
+
+ );
+ })}
+
+ >
+ )}
+
+
+ {/* 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(
+ e.stopPropagation()}
+ >
+
+ Changer le statut
+
+ {Object.entries(STATUT_CONFIG).map(([key, config]) => (
+
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'
+ }`}
+ >
+
+ {config.label}
+ {key === p.statut && (
+
+
+
+ )}
+
+ ))}
+
,
+ document.body
+ );
+ })()}
+
+ {
+ setShowDeleteConfirm(false);
+ setParticipationToDelete(null);
+ }}
+ />
+
+ setShowBulkDeleteConfirm(false)}
+ />
+
+ {editingParticipation && (
+ setEditingParticipation(null)}
+ onSuccess={handleEditSuccess}
+ />
+ )}
+ >
+ );
+}
diff --git a/lib/pages.ts b/lib/pages.ts
index c3eb23f..bdac435 100644
--- a/lib/pages.ts
+++ b/lib/pages.ts
@@ -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',
diff --git a/lib/participation-financiere.ts b/lib/participation-financiere.ts
new file mode 100644
index 0000000..c96a849
--- /dev/null
+++ b/lib/participation-financiere.ts
@@ -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 {
+ 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;
+}
diff --git a/lib/participation-pdf.ts b/lib/participation-pdf.ts
new file mode 100644
index 0000000..cf8de86
--- /dev/null
+++ b/lib/participation-pdf.ts
@@ -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 {
+ 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 {
+ 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`);
+}
diff --git a/package-lock.json b/package-lock.json
index 012f737..e00cee5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index f971066..0a45372 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/prisma/dev.db b/prisma/dev.db
index d6657c3..377af74 100644
Binary files a/prisma/dev.db and b/prisma/dev.db differ
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 224aaf4..b2df7f2 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -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
}
diff --git a/public/lgx-logo.svg b/public/lgx-logo.svg
new file mode 100644
index 0000000..dfc88c3
--- /dev/null
+++ b/public/lgx-logo.svg
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file