From 5772a358f5fa455d35c3c873b3abcf102324dd32 Mon Sep 17 00:00:00 2001 From: Pierre Date: Sun, 15 Feb 2026 15:05:59 +0100 Subject: [PATCH] Added Budget Page --- app/api/budget/route.ts | 161 +++++++ app/api/participations/[id]/send/route.ts | 49 +- app/dashboard/budget/page.tsx | 31 ++ components/BudgetContent.tsx | 555 ++++++++++++++++++++++ components/DashboardLayout.tsx | 9 + lib/pages.ts | 5 + lib/participation-financiere.ts | 28 +- prisma/dev.db | Bin 258048 -> 270336 bytes prisma/schema.prisma | 9 + scripts/add-budget-permission.ts | 69 +++ 10 files changed, 905 insertions(+), 11 deletions(-) create mode 100644 app/api/budget/route.ts create mode 100644 app/dashboard/budget/page.tsx create mode 100644 components/BudgetContent.tsx create mode 100644 scripts/add-budget-permission.ts diff --git a/app/api/budget/route.ts b/app/api/budget/route.ts new file mode 100644 index 0000000..6bd3df7 --- /dev/null +++ b/app/api/budget/route.ts @@ -0,0 +1,161 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/prisma'; +import { getCurrentUser } from '@/lib/auth'; + +// GET - Liste tous les prescripteurs avec budgets, consommé et restant +// PATCH - Met à jour le budget d'un prescripteur (body: { prescripteurValue, montant }) +export async function GET() { + try { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: 'Non autorisé' }, { status: 401 }); + } + + const [prescripteurs, budgets, participations] = await Promise.all([ + prisma.adherentOption.findMany({ + where: { type: 'prescripteur' }, + orderBy: [{ order: 'asc' }, { value: 'asc' }], + }), + prisma.prescripteurBudget.findMany(), + prisma.participationFinanciere.findMany({ + include: { + adherent: { select: { prescripteur: true, prenom: true, nom: true } }, + trajet: { select: { date: true } }, + }, + orderBy: { createdAt: 'desc' }, + }), + ]); + + const consomméMap = new Map(); + const historiqueMap = new Map>(); + + for (const p of participations) { + const prescripteur = p.adherent.prescripteur; + if (!prescripteur) continue; + const montant = p.montant ?? 0; + if (['envoye', 'paye'].includes(p.statut)) { + consomméMap.set(prescripteur, (consomméMap.get(prescripteur) ?? 0) + montant); + } + const hist = historiqueMap.get(prescripteur) ?? []; + hist.push({ + id: p.id, + date: p.trajet.date.toISOString(), + adherentNom: `${p.adherent.prenom} ${p.adherent.nom}`, + montant, + }); + historiqueMap.set(prescripteur, hist); + } + + const budgetMap = new Map(budgets.map((b) => [b.prescripteurValue, { montant: b.montant, ajustementConsomme: b.ajustementConsomme ?? 0 }])); + + const items = prescripteurs.map((p) => { + const budgetData = budgetMap.get(p.value); + const montant = budgetData?.montant ?? 0; + const ajustementConsomme = budgetData?.ajustementConsomme ?? 0; + const consomméCalculé = consomméMap.get(p.value) ?? 0; + const consommé = consomméCalculé + ajustementConsomme; + const restant = Math.max(0, montant - consommé); + const historique = (historiqueMap.get(p.value) ?? []).sort( + (a, b) => new Date(b.date).getTime() - new Date(a.date).getTime() + ); + return { + id: p.id, + value: p.value, + montant, + consommé, + restant, + ajustementConsomme, + historique, + }; + }); + + const totalAlloue = items.reduce((s, i) => s + i.montant, 0); + const totalConsomme = items.reduce((s, i) => s + i.consommé, 0); + const totalRestant = Math.max(0, totalAlloue - totalConsomme); + + return NextResponse.json({ + global: { totalAlloue, totalConsomme, totalRestant }, + prescripteurs: items, + }); + } catch (error) { + console.error('Erreur lors de la récupération des budgets:', error); + return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 }); + } +} + +export async function PATCH(request: NextRequest) { + try { + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: 'Non autorisé' }, { status: 401 }); + } + + const body = await request.json(); + const { prescripteurValue, montant: montantRaw, montantToAdd, rectifier, ajustementConsomme: ajRaw } = body; + + if (!prescripteurValue || typeof prescripteurValue !== 'string') { + return NextResponse.json({ error: 'Prescripteur requis' }, { status: 400 }); + } + + const exists = await prisma.adherentOption.findFirst({ + where: { type: 'prescripteur', value: prescripteurValue }, + }); + + if (!exists) { + return NextResponse.json({ error: 'Prescripteur non trouvé' }, { status: 404 }); + } + + const existing = await prisma.prescripteurBudget.findUnique({ + where: { prescripteurValue }, + }); + + let montantFinal: number; + let ajustementFinal: number | undefined; + + if (rectifier) { + const montant = typeof montantRaw === 'number' ? montantRaw : parseFloat(String(montantRaw ?? 0).replace(',', '.')); + if (isNaN(montant) || montant < 0) { + return NextResponse.json({ error: 'Budget invalide' }, { status: 400 }); + } + montantFinal = montant; + const aj = typeof ajRaw === 'number' ? ajRaw : parseFloat(String(ajRaw ?? 0).replace(',', '.')); + ajustementFinal = isNaN(aj) ? 0 : aj; + } else if (montantToAdd !== undefined && montantToAdd !== null) { + const toAdd = typeof montantToAdd === 'number' ? montantToAdd : parseFloat(String(montantToAdd).replace(',', '.')); + if (isNaN(toAdd) || toAdd <= 0) { + return NextResponse.json({ error: 'Montant à ajouter invalide' }, { status: 400 }); + } + montantFinal = (existing?.montant ?? 0) + toAdd; + } else { + const montant = typeof montantRaw === 'number' ? montantRaw : parseFloat(String(montantRaw ?? 0).replace(',', '.')); + if (isNaN(montant) || montant < 0) { + return NextResponse.json({ error: 'Montant invalide' }, { status: 400 }); + } + montantFinal = montant; + } + + const updateData: { montant: number; ajustementConsomme?: number } = { montant: montantFinal }; + if (ajustementFinal !== undefined) { + updateData.ajustementConsomme = ajustementFinal; + } + + const createData: { prescripteurValue: string; montant: number; ajustementConsomme?: number } = { + prescripteurValue, + montant: montantFinal, + }; + if (ajustementFinal !== undefined) { + createData.ajustementConsomme = ajustementFinal; + } + + const budget = await prisma.prescripteurBudget.upsert({ + where: { prescripteurValue }, + update: updateData, + create: createData, + }); + + return NextResponse.json(budget); + } catch (error) { + console.error('Erreur lors de la mise à jour du budget:', error); + return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 }); + } +} diff --git a/app/api/participations/[id]/send/route.ts b/app/api/participations/[id]/send/route.ts index c5bdced..28481f1 100644 --- a/app/api/participations/[id]/send/route.ts +++ b/app/api/participations/[id]/send/route.ts @@ -18,8 +18,12 @@ export async function POST( const participation = await prisma.participationFinanciere.findUnique({ where: { id: params.id }, include: { - adherent: { select: { prenom: true, nom: true } }, - trajet: { select: { date: true } }, + adherent: { + select: { prenom: true, nom: true, email: true, facturation: true, prescripteur: true }, + }, + trajet: { + select: { date: true, universProId: true, universPro: { select: { email: true, prenom: true, nom: true, nomEntreprise: true } } }, + }, }, }); @@ -27,6 +31,34 @@ export async function POST( return NextResponse.json({ error: 'Participation non trouvée' }, { status: 404 }); } + // Déterminer l'email selon la facturation de l'adhérent + const facturation = (participation.adherent.facturation || '').trim(); + const isAdherent = !facturation || /^adh[eé]rent$/i.test(facturation); + + let destinataireEmail: string; + let destinataireNom: string; + + if (isAdherent) { + destinataireEmail = participation.adherent.email; + destinataireNom = `${participation.adherent.prenom} ${participation.adherent.nom}`; + } else if (participation.trajet?.universProId && participation.trajet?.universPro) { + destinataireEmail = participation.trajet.universPro.email; + destinataireNom = `${participation.trajet.universPro.prenom} ${participation.trajet.universPro.nom} - ${participation.trajet.universPro.nomEntreprise}`; + } else { + const universPro = await prisma.universPro.findFirst({ + where: { nomEntreprise: facturation }, + }); + if (universPro) { + destinataireEmail = universPro.email; + destinataireNom = `${universPro.prenom} ${universPro.nom} - ${universPro.nomEntreprise}`; + } else { + return NextResponse.json( + { error: `Aucune fiche Univers Pro trouvée pour "${facturation}". Créez un contact avec ce nom d'entreprise ou vérifiez la facturation de l'adhérent.` }, + { status: 400 } + ); + } + } + const filePath = getParticipationStoragePath(participation.id); if (!fs.existsSync(filePath)) { return NextResponse.json( @@ -72,7 +104,7 @@ export async function POST( await transporter.sendMail({ from: process.env.SMTP_FROM || smtpUser, - to: participation.destinataireEmail, + to: 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: ` @@ -88,14 +120,19 @@ export async function POST( ], }); - // Mettre à jour le statut en "envoyé" + // Mettre à jour le statut en "envoyé" et le destinataire (au cas où il aurait changé) await prisma.participationFinanciere.update({ where: { id: params.id }, - data: { statut: 'envoye' }, + data: { + statut: 'envoye', + destinataireEmail, + destinataireNom, + destinataireType: isAdherent ? 'adherent' : 'univers_pro', + }, }); return NextResponse.json({ - message: `Participation envoyée à ${participation.destinataireEmail}`, + message: `Participation envoyée à ${destinataireEmail}. Le budget du prescripteur a été décrementé.`, }); } catch (error) { console.error('Erreur lors de l\'envoi de l\'email:', error); diff --git a/app/dashboard/budget/page.tsx b/app/dashboard/budget/page.tsx new file mode 100644 index 0000000..d6b66f4 --- /dev/null +++ b/app/dashboard/budget/page.tsx @@ -0,0 +1,31 @@ +import { getCurrentUser } from '@/lib/auth'; +import { hasPageAccess } from '@/lib/permissions'; +import { redirect } from 'next/navigation'; +import DashboardLayout from '@/components/DashboardLayout'; +import BudgetContent from '@/components/BudgetContent'; + +export default async function BudgetPage() { + const user = await getCurrentUser(); + if (!user) { + redirect('/login'); + } + + const hasAccess = await hasPageAccess(user.id, '/dashboard/budget'); + if (!hasAccess) { + redirect('/dashboard/parametres'); + } + + return ( + +
+

+ Budget +

+

+ Gérer le budget alloué par prescripteur +

+ +
+
+ ); +} diff --git a/components/BudgetContent.tsx b/components/BudgetContent.tsx new file mode 100644 index 0000000..8902d18 --- /dev/null +++ b/components/BudgetContent.tsx @@ -0,0 +1,555 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { useNotification } from './NotificationProvider'; +import Link from 'next/link'; + +interface HistoriqueItem { + id: string; + date: string; + adherentNom: string; + montant: number; +} + +interface PrescripteurBudget { + id: string; + value: string; + montant: number; + consommé: number; + restant: number; + ajustementConsomme: number; + historique: HistoriqueItem[]; +} + +interface BudgetData { + global: { + totalAlloue: number; + totalConsomme: number; + totalRestant: number; + }; + prescripteurs: PrescripteurBudget[]; +} + +export default function BudgetContent() { + const { showNotification } = useNotification(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [savingId, setSavingId] = useState(null); + const [addModalPrescripteur, setAddModalPrescripteur] = useState(null); + const [addAmount, setAddAmount] = useState(''); + const [rectifierModalPrescripteur, setRectifierModalPrescripteur] = useState(null); + const [rectifierBudget, setRectifierBudget] = useState(''); + const [rectifierAjustement, setRectifierAjustement] = useState(''); + const [expandedPrescripteur, setExpandedPrescripteur] = useState(null); + + useEffect(() => { + fetchBudgets(); + }, []); + + const fetchBudgets = async () => { + setLoading(true); + try { + const response = await fetch('/api/budget'); + if (response.ok) { + const res = await response.json(); + setData(res); + } else { + showNotification('error', 'Erreur lors du chargement des budgets'); + } + } catch { + showNotification('error', 'Erreur lors du chargement des budgets'); + } finally { + setLoading(false); + } + }; + + const handleAddMoney = async () => { + if (!addModalPrescripteur) return; + const raw = addAmount.replace(',', '.'); + const montantToAdd = parseFloat(raw); + if (isNaN(montantToAdd) || montantToAdd <= 0) { + showNotification('error', 'Montant invalide'); + return; + } + + setSavingId(addModalPrescripteur); + try { + const response = await fetch('/api/budget', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prescripteurValue: addModalPrescripteur, montantToAdd }), + }); + if (response.ok) { + showNotification('success', 'Budget mis à jour'); + setAddModalPrescripteur(null); + setAddAmount(''); + fetchBudgets(); + } else { + const res = await response.json(); + showNotification('error', res.error || "Erreur lors de l'ajout"); + } + } catch { + showNotification('error', "Erreur lors de l'ajout"); + } finally { + setSavingId(null); + } + }; + + const handleRectifier = async () => { + if (!rectifierModalPrescripteur) return; + const montant = parseFloat(rectifierBudget.replace(',', '.')); + const ajustement = parseFloat(rectifierAjustement.replace(',', '.')); + if (isNaN(montant) || montant < 0) { + showNotification('error', 'Budget invalide'); + return; + } + + setSavingId(rectifierModalPrescripteur.value); + try { + const response = await fetch('/api/budget', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + prescripteurValue: rectifierModalPrescripteur.value, + rectifier: true, + montant, + ajustementConsomme: isNaN(ajustement) ? 0 : ajustement, + }), + }); + if (response.ok) { + showNotification('success', 'Corrections enregistrées'); + setRectifierModalPrescripteur(null); + setRectifierBudget(''); + setRectifierAjustement(''); + fetchBudgets(); + } else { + const res = await response.json(); + showNotification('error', res.error || 'Erreur lors de la correction'); + } + } catch { + showNotification('error', 'Erreur lors de la correction'); + } finally { + setSavingId(null); + } + }; + + const formatEuro = (n: number) => n.toFixed(2).replace('.', ',') + ' €'; + + const formatDate = (dateStr: string) => { + return new Date(dateStr).toLocaleDateString('fr-FR', { + day: '2-digit', + month: 'short', + year: 'numeric', + }); + }; + + if (loading) { + return ( +
+
Chargement...
+
+ ); + } + + if (!data || data.prescripteurs.length === 0) { + return ( +
+
+

Aucun prescripteur configuré.

+

+ Ajoutez des prescripteurs dans la configuration pour pouvoir définir leurs budgets. +

+ + + + + + Aller à la configuration + +
+
+ ); + } + + const { global, prescripteurs } = data; + + return ( +
+ {/* Blocs budget global - style dashboard */} +
+
+
+
+ + + +
+
+

+ Budget total alloué +

+

{formatEuro(global.totalAlloue)}

+
+ +
+
+
+ + + +
+
+

+ Budget consommé +

+

{formatEuro(global.totalConsomme)}

+
+ +
+
+
+ + + +
+
+

+ Budget restant +

+

{formatEuro(global.totalRestant)}

+
+
+ + {/* Liste des budgets par prescripteur */} +
+
+

Budget par prescripteur

+

+ Cliquez sur un prescripteur pour voir l'historique des participations. Utilisez « Ajouter » pour créditer le budget. +

+
+
+ + + + + + + + + + + {prescripteurs.map((item) => ( + + setExpandedPrescripteur(expandedPrescripteur === item.value ? null : item.value)} + > + + + + + + {expandedPrescripteur === item.value && item.historique.length > 0 && ( + + + + )} + {expandedPrescripteur === item.value && item.historique.length === 0 && ( + + + + )} + + ))} + +
+ Prescripteur + + Consommé + + Restant + + Actions +
+
+ {item.value} + {item.historique.length > 0 && ( + + + + + + )} +
+
+ {formatEuro(item.consommé)} + + item.montant || (item.montant > 0 && item.restant < item.montant * 0.2) + ? 'text-red-600' + : 'text-lgreen' + }`} + > + {formatEuro(item.restant)} + + e.stopPropagation()}> +
+ + +
+
+
+

+ Historique des participations +

+
+ + + + + + + + + + + {item.historique.map((h) => ( + + + + + + + ))} + +
DateAdhérentMontantLien
{formatDate(h.date)}{h.adherentNom} + {formatEuro(h.montant)} + + + Voir + +
+
+
+
+

Aucune participation enregistrée pour ce prescripteur.

+
+
+ +
+

+ Les prescripteurs sont définis dans{' '} + + Paramètres → Configuration + + . +

+
+
+ + {/* Modal Rectifier */} + {rectifierModalPrescripteur && ( +
{ + setRectifierModalPrescripteur(null); + setRectifierBudget(''); + setRectifierAjustement(''); + }} + > +
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-labelledby="rectifier-budget-title" + > +

+ Rectifier — {rectifierModalPrescripteur.value} +

+

+ Corrigez le budget alloué et/ou l'ajustement du consommé en cas d'erreur. +

+
+
+ +
+ setRectifierBudget(e.target.value.replace(/[^0-9.,]/g, ''))} + placeholder="0,00" + className="flex-1 px-4 py-2.5 text-sm text-gray-900 border border-gray-300 rounded-lg focus:ring-2 focus:ring-lblue focus:border-transparent" + /> + +
+
+
+ +

+ Valeur à ajouter au consommé calculé. Négatif pour diminuer (ex: -6,80). +

+
+ setRectifierAjustement(e.target.value.replace(/[^0-9.,-]/g, ''))} + placeholder="0,00" + className="flex-1 px-4 py-2.5 text-sm text-gray-900 border border-gray-300 rounded-lg focus:ring-2 focus:ring-lblue focus:border-transparent" + /> + +
+
+
+
+ + +
+
+
+ )} + + {/* Modal Ajouter de l'argent */} + {addModalPrescripteur && ( +
{ + setAddModalPrescripteur(null); + setAddAmount(''); + }} + > +
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-labelledby="add-budget-title" + > +

+ Ajouter du budget — {addModalPrescripteur} +

+

+ Entrez le montant à ajouter au budget existant. +

+
+ setAddAmount(e.target.value.replace(/[^0-9.,]/g, ''))} + placeholder="0,00" + className="flex-1 px-4 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-lblue focus:border-transparent" + autoFocus + /> + +
+
+ + +
+
+
+ )} +
+ ); +} diff --git a/components/DashboardLayout.tsx b/components/DashboardLayout.tsx index 4696899..8058905 100644 --- a/components/DashboardLayout.tsx +++ b/components/DashboardLayout.tsx @@ -165,6 +165,15 @@ export default function DashboardLayout({ user, children }: DashboardLayoutProps ), }, + { + label: 'Budget', + href: '/dashboard/budget', + icon: ( + + + + ), + }, { label: 'Archives', href: '/dashboard/archives', diff --git a/lib/pages.ts b/lib/pages.ts index bdac435..f098932 100644 --- a/lib/pages.ts +++ b/lib/pages.ts @@ -35,6 +35,11 @@ export const AVAILABLE_PAGES = [ label: 'Participation financière', description: 'Documents de participation financière', }, + { + route: '/dashboard/budget', + label: 'Budget', + description: 'Budget des prescripteurs', + }, { route: '/dashboard/archives', label: 'Archives', diff --git a/lib/participation-financiere.ts b/lib/participation-financiere.ts index c96a849..92dd33a 100644 --- a/lib/participation-financiere.ts +++ b/lib/participation-financiere.ts @@ -21,19 +21,37 @@ export async function createParticipationForTrajet(trajetId: string): PromisecRs@Mv4yasu=%J!XRf$_sqe`?*pX5;WkaTQ^G|8H_hpzPR zVdmfO|Gt_3|8{Zow47#UT;@}5ln>GU^Ns_DDZkm#r zN`pcDVgi(@&QI9G$V-M}snBV$D?}0rqQ)n{1$JSidvy{NES7UT$KdPu2>KbRs7m-! zI4e}~8@$YOD>my-Twf1ol$^~5!$2e)RZ=7JXf1uBBZ|9WEIm{|DhG80+p;vpL86H9 z1W^VOp?H#{5(;=%zVMwp_txb7@IAJiLmGqc;Lj+58GI5S!%l2NJLqeq(c<$+J@yT2 zs>olKv$GOVe_4a&+D;3!0SC5hGI$Ahp+C?fdRh2Ym=jw0ZT=G9z-@8oxmwF6#k^q= z`>=r?)FVAfDyJ@^XDAeZQBK#VuXA%H3i9RZsh? z9iE;JvB%Tq?GPRLlGMNqblM)j{XRLIBA0Dku_|4Jg8I=xP(C4#q?9CykZ7_!7FA-A z2yuuM+AI1xdqiKa*DD_GI5OpyOeKC7m>LL02gzwY$$+j*;3P@w^O_#g?)4q*?(Gnd z`qT{x7+=$g&X4C1JAI->UQaPOVF;(eVnbv-(>LT zcorYWmFRo4jMB)1%)%}HH(_3og=+pLrF@xJtJk1dx`!KK_0~V^B2`|4FKE6sc;j1( zh4vx9cNu(l#U^gz*)GfT&#*P-BMCV=KtxaY%n1^TkVG!+4n0h!iGEQ+lfT0DXIaUd zbDm^uEMxx(cVaj`7EKQi#p^#@XKSsQHm47uafn9K!y*OGm5;wZ(J?+-=5xJ390f3A3U`Bi?JAK)I`#3jX!q^JkGwdVOC*&HV4cS*RrcmX)e2D-zn&{u zegkTW)^2ZtO{g`3hRwdnr?^HVG@=qBG%{G3AxfigU3}?>bfb(;kfG#2G!@Z=e!fKa zHdcvP0rlCM8(Qnd$2fTP`rx154BN-aOi~cre@N*4@I==3K$Di%TU_k$R#j2C7>A#_ z4N9v6DVso2dalK~(r|OfW~VQYcNIFkp%6{%74PFvL_^Zu3x}zA7os(yAOU;C+8xN0 zT0c+~*8fG2Er&|3~?b0>2mmKO2hI9f58WN4G-WdPGK8X zqQPFX5oWPGm9m40*=OLFC+0wYax#?w 0) { + console.log('ℹ️ Tous les rôles ont déjà la permission Budget'); + } else if (roles.length === 0) { + console.log('ℹ️ Aucun rôle existant (utilisateurs sans rôle ont accès à tout)'); + } + + console.log('\n✅ Terminé. Rechargez la page pour voir Budget dans la sidebar.'); +} + +main() + .catch((e) => { + console.error('❌ Erreur:', e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + });