Added Participation Page
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -40,3 +40,6 @@ next-env.d.ts
|
||||
|
||||
# uploads
|
||||
/public/uploads
|
||||
|
||||
# participations PDF
|
||||
/data/participations
|
||||
|
||||
@@ -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({
|
||||
|
||||
57
app/api/participations/[id]/pdf/route.ts
Normal file
57
app/api/participations/[id]/pdf/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
203
app/api/participations/[id]/route.ts
Normal file
203
app/api/participations/[id]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
110
app/api/participations/[id]/send/route.ts
Normal file
110
app/api/participations/[id]/send/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
51
app/api/participations/route.ts
Normal file
51
app/api/participations/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
31
app/dashboard/factures/page.tsx
Normal file
31
app/dashboard/factures/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
180
components/ParticipationEditModal.tsx
Normal file
180
components/ParticipationEditModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
796
components/ParticipationFinanciereList.tsx
Normal file
796
components/ParticipationFinanciereList.tsx
Normal 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
73
lib/participation-financiere.ts
Normal file
73
lib/participation-financiere.ts
Normal 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
432
lib/participation-pdf.ts
Normal 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
585
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
BIN
prisma/dev.db
BIN
prisma/dev.db
Binary file not shown.
@@ -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
4
public/lgx-logo.svg
Normal 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 |
Reference in New Issue
Block a user