Added Budget Page
This commit is contained in:
161
app/api/budget/route.ts
Normal file
161
app/api/budget/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
31
app/dashboard/budget/page.tsx
Normal file
31
app/dashboard/budget/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 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>
|
||||
);
|
||||
}
|
||||
555
components/BudgetContent.tsx
Normal file
555
components/BudgetContent.tsx
Normal 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'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'ajustement du consommé en cas d'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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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({
|
||||
|
||||
BIN
prisma/dev.db
BIN
prisma/dev.db
Binary file not shown.
@@ -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
|
||||
|
||||
69
scripts/add-budget-permission.ts
Normal file
69
scripts/add-budget-permission.ts
Normal 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();
|
||||
});
|
||||
Reference in New Issue
Block a user