Added Budget Page

This commit is contained in:
2026-02-15 15:05:59 +01:00
parent 5185a41bb6
commit 5772a358f5
10 changed files with 905 additions and 11 deletions

161
app/api/budget/route.ts Normal file
View File

@@ -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<string, number>();
const historiqueMap = new Map<string, Array<{ id: string; date: string; adherentNom: string; montant: number }>>();
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 });
}
}

View File

@@ -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);

View File

@@ -0,0 +1,31 @@
import { getCurrentUser } from '@/lib/auth';
import { hasPageAccess } from '@/lib/permissions';
import { redirect } from 'next/navigation';
import DashboardLayout from '@/components/DashboardLayout';
import 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 (
<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">
Budget
</h1>
<p className="text-xs md:text-sm text-cgray mb-4 sm:mb-6 md:mb-8">
Gérer le budget alloué par prescripteur
</p>
<BudgetContent />
</div>
</DashboardLayout>
);
}

View File

@@ -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<BudgetData | null>(null);
const [loading, setLoading] = useState(true);
const [savingId, setSavingId] = useState<string | null>(null);
const [addModalPrescripteur, setAddModalPrescripteur] = useState<string | null>(null);
const [addAmount, setAddAmount] = useState('');
const [rectifierModalPrescripteur, setRectifierModalPrescripteur] = useState<PrescripteurBudget | null>(null);
const [rectifierBudget, setRectifierBudget] = useState('');
const [rectifierAjustement, setRectifierAjustement] = useState('');
const [expandedPrescripteur, setExpandedPrescripteur] = useState<string | null>(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 (
<div className="bg-white rounded-lg shadow-sm p-8">
<div className="text-center text-sm text-gray-500">Chargement...</div>
</div>
);
}
if (!data || data.prescripteurs.length === 0) {
return (
<div className="bg-white rounded-lg shadow-sm p-8">
<div className="text-center text-gray-500">
<p className="mb-4">Aucun prescripteur configuré.</p>
<p className="text-sm mb-6">
Ajoutez des prescripteurs dans la configuration pour pouvoir définir leurs budgets.
</p>
<Link
href="/dashboard/parametres/configuration"
className="inline-flex items-center gap-2 px-4 py-2 bg-lblue text-white rounded-lg hover:bg-dblue transition-colors text-sm font-medium"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Aller à la configuration
</Link>
</div>
</div>
);
}
const { global, prescripteurs } = data;
return (
<div className="space-y-6">
{/* Blocs budget global - style dashboard */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 sm:gap-5">
<div className="bg-white rounded-xl shadow-sm p-4 sm:p-6 border border-gray-100 hover:shadow-lg hover:border-dyellow/30 transition-all duration-300 group">
<div className="flex items-start justify-between mb-3 sm:mb-4">
<div className="w-10 h-10 sm:w-12 sm:h-12 rounded-xl bg-gradient-to-br from-dyellow/20 to-dyellow/10 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<svg className="w-5 h-5 sm:w-6 sm:h-6 text-dyellow" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
<p className="text-[10px] sm:text-xs text-gray-600 mb-1.5 sm:mb-2 font-medium uppercase tracking-wide">
Budget total alloué
</p>
<p className="text-2xl sm:text-3xl font-bold text-gray-900">{formatEuro(global.totalAlloue)}</p>
</div>
<div className="bg-white rounded-xl shadow-sm p-4 sm:p-6 border border-gray-100 hover:shadow-lg hover:border-dyellow/30 transition-all duration-300 group">
<div className="flex items-start justify-between mb-3 sm:mb-4">
<div className="w-10 h-10 sm:w-12 sm:h-12 rounded-xl bg-gradient-to-br from-orange-100 to-orange-50 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<svg className="w-5 h-5 sm:w-6 sm:h-6 text-orange-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
</div>
<p className="text-[10px] sm:text-xs text-gray-600 mb-1.5 sm:mb-2 font-medium uppercase tracking-wide">
Budget consommé
</p>
<p className="text-2xl sm:text-3xl font-bold text-orange-600">{formatEuro(global.totalConsomme)}</p>
</div>
<div className="bg-white rounded-xl shadow-sm p-4 sm:p-6 border border-gray-100 hover:shadow-lg hover:border-lgreen/30 transition-all duration-300 group">
<div className="flex items-start justify-between mb-3 sm:mb-4">
<div className="w-10 h-10 sm:w-12 sm:h-12 rounded-xl bg-gradient-to-br from-lgreen/20 to-lgreen/10 flex items-center justify-center flex-shrink-0 group-hover:scale-110 transition-transform duration-300">
<svg className="w-5 h-5 sm:w-6 sm:h-6 text-lgreen" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
<p className="text-[10px] sm:text-xs text-gray-600 mb-1.5 sm:mb-2 font-medium uppercase tracking-wide">
Budget restant
</p>
<p className="text-2xl sm:text-3xl font-bold text-lgreen">{formatEuro(global.totalRestant)}</p>
</div>
</div>
{/* Liste des budgets par prescripteur */}
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">Budget par prescripteur</h2>
<p className="text-sm text-gray-500 mt-1">
Cliquez sur un prescripteur pour voir l&apos;historique des participations. Utilisez « Ajouter » pour créditer le budget.
</p>
</div>
<div className="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">
Prescripteur
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Consommé
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Restant
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider w-56">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{prescripteurs.map((item) => (
<React.Fragment key={item.value}>
<tr
key={item.value}
className="hover:bg-gray-50 cursor-pointer"
onClick={() => setExpandedPrescripteur(expandedPrescripteur === item.value ? null : item.value)}
>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-900">{item.value}</span>
{item.historique.length > 0 && (
<span className="text-gray-400">
<svg
className={`w-4 h-4 transition-transform ${expandedPrescripteur === item.value ? 'rotate-90' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</span>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-orange-600 font-medium">
{formatEuro(item.consommé)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`text-sm font-medium ${
item.consommé > item.montant || (item.montant > 0 && item.restant < item.montant * 0.2)
? 'text-red-600'
: 'text-lgreen'
}`}
>
{formatEuro(item.restant)}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-2 justify-end">
<button
onClick={() => {
setAddModalPrescripteur(item.value);
setAddAmount('');
}}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-white bg-lgreen rounded-lg hover:bg-dgreen transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Ajouter
</button>
<button
onClick={() => {
setRectifierModalPrescripteur(item);
setRectifierBudget(item.montant.toString().replace('.', ','));
setRectifierAjustement((item.ajustementConsomme ?? 0).toString().replace('.', ','));
}}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-white bg-lblue rounded-lg hover:bg-dblue transition-colors"
>
<svg className="w-4 h-4" 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>
Rectifier
</button>
</div>
</td>
</tr>
{expandedPrescripteur === item.value && item.historique.length > 0 && (
<tr key={`${item.value}-hist`} className="bg-gray-50">
<td colSpan={4} className="px-6 py-4">
<div className="space-y-2">
<h4 className="text-xs font-semibold text-gray-700 uppercase tracking-wide">
Historique des participations
</h4>
<div className="rounded-lg border border-gray-200 overflow-hidden">
<table className="min-w-full text-sm">
<thead className="bg-gray-100">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500">Date</th>
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500">Adhérent</th>
<th className="px-4 py-2 text-right text-xs font-medium text-gray-500">Montant</th>
<th className="px-4 py-2 text-right text-xs font-medium text-gray-500 w-24">Lien</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 bg-white">
{item.historique.map((h) => (
<tr key={h.id}>
<td className="px-4 py-2 text-gray-700">{formatDate(h.date)}</td>
<td className="px-4 py-2 text-gray-700">{h.adherentNom}</td>
<td className="px-4 py-2 text-right text-orange-600 font-medium">
{formatEuro(h.montant)}
</td>
<td className="px-4 py-2 text-right">
<Link
href="/dashboard/factures"
className="text-lblue hover:underline text-xs"
>
Voir
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</td>
</tr>
)}
{expandedPrescripteur === item.value && item.historique.length === 0 && (
<tr className="bg-gray-50">
<td colSpan={4} className="px-6 py-4">
<p className="text-sm text-gray-500 italic">Aucune participation enregistrée pour ce prescripteur.</p>
</td>
</tr>
)}
</React.Fragment>
))}
</tbody>
</table>
</div>
<div className="px-6 py-3 bg-gray-50 border-t border-gray-200">
<p className="text-xs text-gray-500">
Les prescripteurs sont définis dans{' '}
<Link href="/dashboard/parametres/configuration" className="text-lblue hover:underline">
Paramètres Configuration
</Link>
.
</p>
</div>
</div>
{/* Modal Rectifier */}
{rectifierModalPrescripteur && (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50"
onClick={() => {
setRectifierModalPrescripteur(null);
setRectifierBudget('');
setRectifierAjustement('');
}}
>
<div
className="bg-white rounded-xl shadow-xl max-w-sm w-full p-6"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="rectifier-budget-title"
>
<h3 id="rectifier-budget-title" className="text-lg font-semibold text-gray-900 mb-2">
Rectifier {rectifierModalPrescripteur.value}
</h3>
<p className="text-sm text-gray-500 mb-4">
Corrigez le budget alloué et/ou l&apos;ajustement du consommé en cas d&apos;erreur.
</p>
<div className="space-y-4 mb-6">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1.5">Budget alloué ()</label>
<div className="flex items-center gap-2">
<input
type="text"
inputMode="decimal"
value={rectifierBudget}
onChange={(e) => 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"
/>
<span className="text-sm text-gray-500"></span>
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1.5">
Ajustement consommé ()
</label>
<p className="text-xs text-gray-500 mb-1">
Valeur à ajouter au consommé calculé. Négatif pour diminuer (ex: -6,80).
</p>
<div className="flex items-center gap-2">
<input
type="text"
inputMode="decimal"
value={rectifierAjustement}
onChange={(e) => 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"
/>
<span className="text-sm text-gray-500"></span>
</div>
</div>
</div>
<div className="flex gap-3 justify-end">
<button
onClick={() => {
setRectifierModalPrescripteur(null);
setRectifierBudget('');
setRectifierAjustement('');
}}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
>
Annuler
</button>
<button
onClick={handleRectifier}
disabled={savingId === rectifierModalPrescripteur.value}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-lblue rounded-lg hover:bg-dblue disabled:opacity-50 transition-colors"
>
{savingId === rectifierModalPrescripteur.value ? (
<>
<svg className="w-4 h-4 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>
Enregistrement...
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Enregistrer
</>
)}
</button>
</div>
</div>
</div>
)}
{/* Modal Ajouter de l'argent */}
{addModalPrescripteur && (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50"
onClick={() => {
setAddModalPrescripteur(null);
setAddAmount('');
}}
>
<div
className="bg-white rounded-xl shadow-xl max-w-sm w-full p-6"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="add-budget-title"
>
<h3 id="add-budget-title" className="text-lg font-semibold text-gray-900 mb-2">
Ajouter du budget {addModalPrescripteur}
</h3>
<p className="text-sm text-gray-500 mb-4">
Entrez le montant à ajouter au budget existant.
</p>
<div className="flex items-center gap-2 mb-6">
<input
type="text"
inputMode="decimal"
value={addAmount}
onChange={(e) => 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
/>
<span className="text-sm text-gray-500"></span>
</div>
<div className="flex gap-3 justify-end">
<button
onClick={() => {
setAddModalPrescripteur(null);
setAddAmount('');
}}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
>
Annuler
</button>
<button
onClick={handleAddMoney}
disabled={savingId === addModalPrescripteur}
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-lgreen rounded-lg hover:bg-dgreen disabled:opacity-50 transition-colors"
>
{savingId === addModalPrescripteur ? (
<>
<svg className="w-4 h-4 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>
Ajout...
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Ajouter
</>
)}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -165,6 +165,15 @@ export default function DashboardLayout({ user, children }: DashboardLayoutProps
</svg>
),
},
{
label: 'Budget',
href: '/dashboard/budget',
icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
},
{
label: 'Archives',
href: '/dashboard/archives',

View File

@@ -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',

View File

@@ -21,19 +21,37 @@ export async function createParticipationForTrajet(trajetId: string): Promise<bo
});
if (existing) return true;
// Destinataire: univers pro si lié, sinon adhérent
// Destinataire selon facturation de l'adhérent
// Si facturation = "Adhérent" (ou vide) → email adhérent
// Sinon → rechercher un Univers Pro dont nomEntreprise correspond à la facturation
let destinataireEmail: string;
let destinataireNom: string;
let destinataireType: 'adherent' | 'univers_pro';
if (trajet.universProId && trajet.universPro) {
const facturation = (trajet.adherent.facturation || '').trim();
const isAdherent = !facturation || /^adh[eé]rent$/i.test(facturation);
if (isAdherent) {
destinataireEmail = trajet.adherent.email;
destinataireNom = `${trajet.adherent.prenom} ${trajet.adherent.nom}`;
destinataireType = 'adherent';
} else 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 universPro = await prisma.universPro.findFirst({
where: { nomEntreprise: facturation },
});
if (universPro) {
destinataireEmail = universPro.email;
destinataireNom = `${universPro.prenom} ${universPro.nom} - ${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({

Binary file not shown.

View File

@@ -213,6 +213,15 @@ model AdherentOption {
@@index([type])
}
model PrescripteurBudget {
id String @id @default(cuid())
prescripteurValue String @unique // Valeur du prescripteur (ex: "MDPH", "CG")
montant Float @default(0) // Budget alloué en €
ajustementConsomme Float @default(0) // Ajustement manuel du consommé (ex: -6.80 pour corriger une erreur)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Notification {
id String @id @default(cuid())
userId String // Utilisateur destinataire de la notification

View File

@@ -0,0 +1,69 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const BUDGET_ROUTE = '/dashboard/budget';
async function main() {
console.log('🔧 Ajout de la permission Budget...');
// Créer la permission si elle n'existe pas
let permission = await prisma.permission.findUnique({
where: { name: BUDGET_ROUTE },
});
if (!permission) {
permission = await prisma.permission.create({
data: {
name: BUDGET_ROUTE,
description: 'Budget des prescripteurs',
},
});
console.log('✅ Permission Budget créée');
} else {
console.log(' Permission Budget existe déjà');
}
// Ajouter la permission à tous les rôles
const roles = await prisma.role.findMany();
let added = 0;
for (const role of roles) {
const existing = await prisma.rolePermission.findUnique({
where: {
roleId_permissionId: {
roleId: role.id,
permissionId: permission.id,
},
},
});
if (!existing) {
await prisma.rolePermission.create({
data: {
roleId: role.id,
permissionId: permission.id,
},
});
console.log(` → Ajouté au rôle "${role.name}"`);
added++;
}
}
if (added === 0 && roles.length > 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();
});