Checkpoint: Module Ventilation FreePro complet : import Excel FreePro, transformation automatique (script/TTC/type XOAUTH2), tableau de ventilation par structure+type, historique par mois, export PDF. Menu Ventilations ajouté dans la navigation.

This commit is contained in:
Manus
2026-06-05 08:02:29 -04:00
parent 4ef130f21d
commit 7f56fa7045
13 changed files with 3123 additions and 3 deletions

View File

@@ -20,6 +20,7 @@ import AutomationRules from "./pages/AutomationRules";
import BapHistory from "./pages/BapHistory";
import ImportReport from "./pages/ImportReport";
import LearningSettings from "./pages/LearningSettings";
import VentilationFreePro from "./pages/VentilationFreePro";
function Router() {
return (
@@ -40,6 +41,7 @@ function Router() {
<Route path="/bap-history" component={BapHistory} />
<Route path="/import-report" component={ImportReport} />
<Route path="/learning-settings" component={LearningSettings} />
<Route path="/ventilation-freepro" component={VentilationFreePro} />
<Route path="/404" component={NotFound} />
<Route component={NotFound} />
</Switch>

View File

@@ -25,7 +25,7 @@ import {
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { getLoginUrl } from "@/const";
import { useIsMobile } from "@/hooks/useMobile";
import { LayoutDashboard, LogOut, PanelLeft, Users, FileText, Upload, History, Settings, Download, List, Zap, ChevronDown, Receipt, Cog, ClipboardList, CheckSquare, Brain, BarChart2 } from "lucide-react";
import { LayoutDashboard, LogOut, PanelLeft, Users, FileText, Upload, History, Settings, Download, List, Zap, ChevronDown, Receipt, Cog, ClipboardList, CheckSquare, Brain, BarChart2, BarChart3 } from "lucide-react";
import { CSSProperties, useEffect, useRef, useState } from "react";
import { useLocation } from "wouter";
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
@@ -57,6 +57,14 @@ const menuStructure: MenuItem[] = [
{ icon: FileText, label: "Factures BAP", path: "/invoices-bap" },
],
},
{
icon: BarChart3,
label: "Ventilations",
color: "from-teal-500 to-cyan-500",
children: [
{ icon: BarChart3, label: "FreePro", path: "/ventilation-freepro" },
],
},
{
icon: Cog,
label: "Configuration",

View File

@@ -0,0 +1,543 @@
import { useState, useRef, useCallback } from "react";
import { trpc } from "@/lib/trpc";
import DashboardLayout from "@/components/DashboardLayout";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { toast } from "sonner";
import {
Upload,
FileSpreadsheet,
Trash2,
Download,
ChevronRight,
ArrowLeft,
Calendar,
Hash,
BarChart3,
Euro,
} from "lucide-react";
import jsPDF from "jspdf";
import autoTable from "jspdf-autotable";
// ── Types ──────────────────────────────────────────────────────────────────
interface VentilationLine {
id: number;
structure: string | null;
type: string;
montantCentimes: number;
}
interface ImportRecord {
id: number;
moisLabel: string;
annee: number;
mois: number;
refPiece: string | null;
fileName: string;
nbLignes: number;
totalTtc: string | null;
createdAt: Date;
}
// ── Helpers ────────────────────────────────────────────────────────────────
function formatMontant(centimes: number): string {
return (centimes / 100).toLocaleString("fr-FR", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}) + " €";
}
function formatTotalTtc(ttc: string | null | number): string {
const val = typeof ttc === "string" ? parseFloat(ttc) : (ttc ?? 0);
return val.toLocaleString("fr-FR", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}) + " €";
}
function typeBadgeColor(type: string): string {
if (type === "Lien fibre") return "bg-blue-100 text-blue-800 border-blue-200";
if (type === "Lien 5G") return "bg-purple-100 text-purple-800 border-purple-200";
return "bg-orange-100 text-orange-800 border-orange-200";
}
// ── Export PDF ─────────────────────────────────────────────────────────────
function exportToPdf(
importRecord: ImportRecord,
lines: VentilationLine[]
) {
const doc = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" });
// En-tête
doc.setFontSize(14);
doc.setFont("helvetica", "bold");
doc.text("Ventiltion facture FREE PRO", 105, 15, { align: "center" });
// Date du mois
const [moisStr, anneeStr] = importRecord.moisLabel.split("/");
const dateLabel = `01/${moisStr}/${anneeStr}`;
doc.setFontSize(12);
doc.text(dateLabel, 105, 22, { align: "center" });
// Date d'édition (haut droite)
const now = new Date();
const editDate = `Edité le ${now.toLocaleDateString("fr-FR")} - ${now.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })}`;
doc.setFontSize(9);
doc.setFont("helvetica", "normal");
doc.text(editDate, 200, 10, { align: "right" });
// Référence pièce
doc.setFontSize(10);
doc.setFont("helvetica", "bold");
doc.text("ref_piece", 14, 32);
doc.setFont("helvetica", "normal");
doc.text(importRecord.refPiece ?? "", 55, 32);
// Tableau principal
const tableData = lines.map((l) => [
l.structure ?? "(vide)",
l.type,
formatMontant(l.montantCentimes),
]);
// Total général
const totalCentimes = lines.reduce((s, l) => s + l.montantCentimes, 0);
autoTable(doc, {
startY: 38,
head: [["structure", "Type", "Montant"]],
body: tableData,
foot: [["Total général", "", formatMontant(totalCentimes)]],
styles: { fontSize: 9, cellPadding: 2 },
headStyles: { fillColor: [255, 255, 255], textColor: [0, 0, 0], fontStyle: "bold", lineWidth: 0.3, lineColor: [0, 0, 0] },
footStyles: { fillColor: [255, 255, 255], textColor: [0, 0, 0], fontStyle: "bold", lineWidth: 0.3, lineColor: [0, 0, 0] },
bodyStyles: { lineWidth: 0.1, lineColor: [180, 180, 180] },
columnStyles: {
0: { cellWidth: 80 },
1: { cellWidth: 50 },
2: { cellWidth: 50, halign: "right" },
},
alternateRowStyles: { fillColor: [255, 255, 255] },
});
const moisPad = moisStr.padStart(2, "0");
const anneeCourt = anneeStr.slice(2);
doc.save(`Ventilation FreePro ${moisPad}.${anneeCourt}.pdf`);
}
// ── Composant principal ────────────────────────────────────────────────────
function VentilationFreeProContent() {
const utils = trpc.useUtils();
// Vue : "list" | "detail"
const [view, setView] = useState<"list" | "detail">("list");
const [selectedImportId, setSelectedImportId] = useState<number | null>(null);
const [deleteId, setDeleteId] = useState<number | null>(null);
// Import state
const [moisLabel, setMoisLabel] = useState(() => {
const now = new Date();
const m = String(now.getMonth() + 1).padStart(2, "0");
const y = String(now.getFullYear());
return `${m}/${y}`;
});
const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Queries
const { data: imports = [], isLoading: loadingList } = trpc.freepro.list.useQuery();
const { data: detail, isLoading: loadingDetail } = trpc.freepro.getById.useQuery(
{ id: selectedImportId! },
{ enabled: !!selectedImportId && view === "detail" }
);
// Mutations
const importMutation = trpc.freepro.import.useMutation({
onSuccess: (data) => {
utils.freepro.list.invalidate();
toast.success(`Import réussi — ${data.nbLignes} lignes traitées — Total TTC : ${formatTotalTtc(data.totalTtc)}`);
setSelectedImportId(data.importId);
setView("detail");
},
onError: (err) => {
toast.error(`Erreur d'import : ${err.message}`);
},
});
const deleteMutation = trpc.freepro.delete.useMutation({
onSuccess: () => {
utils.freepro.list.invalidate();
if (view === "detail") setView("list");
toast.success("Import supprimé");
},
});
// Gestion du fichier
const handleFile = useCallback(
(file: File) => {
if (!file.name.match(/\.(xlsx|xls)$/i)) {
toast.error("Format invalide : veuillez sélectionner un fichier Excel (.xlsx ou .xls)");
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const base64 = (e.target?.result as string).split(",")[1];
importMutation.mutate({ moisLabel, fileName: file.name, fileBase64: base64 });
};
reader.readAsDataURL(file);
},
[moisLabel, importMutation, toast]
);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer.files[0];
if (file) handleFile(file);
},
[handleFile]
);
// ── Rendu liste ──────────────────────────────────────────────────────────
if (view === "list") {
return (
<div className="space-y-6">
{/* En-tête */}
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-blue-50 border border-blue-200">
<BarChart3 className="h-6 w-6 text-blue-600" />
</div>
<div>
<h1 className="text-2xl font-bold text-foreground">Ventilation FreePro</h1>
<p className="text-sm text-muted-foreground">Import et ventilation des factures Free Pro par structure</p>
</div>
</div>
{/* Zone d'import */}
<Card className="border-2 border-dashed border-blue-200 bg-blue-50/30">
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Upload className="h-4 w-4 text-blue-600" />
Importer une facture FreePro
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Sélecteur de mois */}
<div className="flex items-center gap-3">
<label className="text-sm font-medium text-foreground w-28">Mois de facturation</label>
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-muted-foreground" />
<input
type="text"
value={moisLabel}
onChange={(e) => setMoisLabel(e.target.value)}
placeholder="MM/YYYY"
className="border rounded px-3 py-1.5 text-sm w-28 focus:outline-none focus:ring-2 focus:ring-blue-400"
pattern="\d{2}/\d{4}"
/>
<span className="text-xs text-muted-foreground">Format : MM/YYYY</span>
</div>
</div>
{/* Zone de dépôt */}
<div
className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-all ${
isDragging ? "border-blue-500 bg-blue-100" : "border-blue-300 hover:border-blue-400 hover:bg-blue-50"
} ${importMutation.isPending ? "opacity-50 pointer-events-none" : ""}`}
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
accept=".xlsx,.xls"
className="hidden"
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFile(f); }}
/>
{importMutation.isPending ? (
<div className="flex flex-col items-center gap-2">
<div className="h-8 w-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
<p className="text-sm text-blue-600 font-medium">Traitement en cours</p>
</div>
) : (
<div className="flex flex-col items-center gap-2">
<FileSpreadsheet className="h-10 w-10 text-blue-400" />
<p className="text-sm font-medium text-foreground">
Glissez-déposez le fichier Excel FreePro ici
</p>
<p className="text-xs text-muted-foreground">ou cliquez pour sélectionner (.xlsx, .xls)</p>
</div>
)}
</div>
</CardContent>
</Card>
{/* Historique */}
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Calendar className="h-4 w-4 text-muted-foreground" />
Historique des imports ({imports.length})
</CardTitle>
</CardHeader>
<CardContent>
{loadingList ? (
<div className="flex justify-center py-8">
<div className="h-6 w-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
</div>
) : imports.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<FileSpreadsheet className="h-10 w-10 mx-auto mb-2 opacity-30" />
<p className="text-sm">Aucun import enregistré</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Mois</TableHead>
<TableHead>Réf. pièce</TableHead>
<TableHead>Fichier</TableHead>
<TableHead className="text-right">Lignes</TableHead>
<TableHead className="text-right">Total TTC</TableHead>
<TableHead>Importé le</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(imports as ImportRecord[]).map((imp) => (
<TableRow
key={imp.id}
className="cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => { setSelectedImportId(imp.id); setView("detail"); }}
>
<TableCell>
<Badge variant="outline" className="font-mono text-blue-700 border-blue-300 bg-blue-50">
{imp.moisLabel}
</Badge>
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">{imp.refPiece ?? "—"}</TableCell>
<TableCell className="max-w-[180px] truncate text-sm">{imp.fileName}</TableCell>
<TableCell className="text-right">
<span className="flex items-center justify-end gap-1 text-sm">
<Hash className="h-3 w-3 text-muted-foreground" />
{imp.nbLignes}
</span>
</TableCell>
<TableCell className="text-right font-semibold text-sm">
{formatTotalTtc(imp.totalTtc)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{new Date(imp.createdAt).toLocaleDateString("fr-FR")}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1" onClick={(e) => e.stopPropagation()}>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 text-blue-600 hover:text-blue-800 hover:bg-blue-50"
onClick={() => { setSelectedImportId(imp.id); setView("detail"); }}
>
<ChevronRight className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 text-red-500 hover:text-red-700 hover:bg-red-50"
onClick={() => setDeleteId(imp.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* Dialog de confirmation de suppression */}
<AlertDialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Supprimer cet import ?</AlertDialogTitle>
<AlertDialogDescription>
Cette action est irréversible. Toutes les lignes de ventilation associées seront supprimées.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction
className="bg-red-600 hover:bg-red-700"
onClick={() => { if (deleteId) deleteMutation.mutate({ id: deleteId }); setDeleteId(null); }}
>
Supprimer
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
// ── Rendu détail ─────────────────────────────────────────────────────────
// (suite ci-dessous)
const imp = detail?.import as ImportRecord | undefined;
const lines = (detail?.lines ?? []) as VentilationLine[];
const totalCentimes = lines.reduce((s, l) => s + l.montantCentimes, 0);
return (
<div className="space-y-6">
{/* En-tête détail */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="sm"
onClick={() => setView("list")}
className="flex items-center gap-1 text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" />
Retour
</Button>
<div className="h-4 w-px bg-border" />
<div className="flex items-center gap-2">
<BarChart3 className="h-5 w-5 text-blue-600" />
<h1 className="text-xl font-bold">
Ventilation FreePro {imp?.moisLabel ?? "…"}
</h1>
</div>
</div>
{imp && (
<Button
onClick={() => exportToPdf(imp, lines)}
className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white"
>
<Download className="h-4 w-4" />
Exporter PDF
</Button>
)}
</div>
{loadingDetail ? (
<div className="flex justify-center py-12">
<div className="h-8 w-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
</div>
) : imp ? (
<>
{/* Métadonnées */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card className="border-blue-200 bg-blue-50/30">
<CardContent className="pt-4 pb-3">
<p className="text-xs text-muted-foreground mb-1">Référence pièce</p>
<p className="font-mono font-semibold text-sm">{imp.refPiece ?? "—"}</p>
</CardContent>
</Card>
<Card className="border-blue-200 bg-blue-50/30">
<CardContent className="pt-4 pb-3">
<p className="text-xs text-muted-foreground mb-1">Mois</p>
<p className="font-semibold text-sm">01/{imp.moisLabel}</p>
</CardContent>
</Card>
<Card className="border-blue-200 bg-blue-50/30">
<CardContent className="pt-4 pb-3">
<p className="text-xs text-muted-foreground mb-1">Lignes traitées</p>
<p className="font-semibold text-sm">{imp.nbLignes}</p>
</CardContent>
</Card>
<Card className="border-green-200 bg-green-50/30">
<CardContent className="pt-4 pb-3">
<p className="text-xs text-muted-foreground mb-1 flex items-center gap-1">
<Euro className="h-3 w-3" /> Total TTC
</p>
<p className="font-bold text-base text-green-700">{formatTotalTtc(imp.totalTtc)}</p>
</CardContent>
</Card>
</div>
{/* Tableau de ventilation */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Ventilation par structure et type</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow className="bg-muted/30">
<TableHead className="font-bold">structure</TableHead>
<TableHead className="font-bold">Type</TableHead>
<TableHead className="text-right font-bold">Montant</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{lines.map((line) => (
<TableRow key={line.id} className="hover:bg-muted/20">
<TableCell className="font-medium">
{line.structure ?? <span className="text-muted-foreground italic">(vide)</span>}
</TableCell>
<TableCell>
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium border ${typeBadgeColor(line.type)}`}>
{line.type}
</span>
</TableCell>
<TableCell className="text-right font-mono text-sm">
{formatMontant(line.montantCentimes)}
</TableCell>
</TableRow>
))}
</TableBody>
<tfoot>
<tr className="border-t-2 border-foreground/20 bg-muted/20">
<td colSpan={2} className="px-4 py-3 font-bold text-sm">Total général</td>
<td className="px-4 py-3 text-right font-bold text-base text-green-700">
{formatMontant(totalCentimes)}
</td>
</tr>
</tfoot>
</Table>
</CardContent>
</Card>
</>
) : (
<div className="text-center py-12 text-muted-foreground">Import introuvable.</div>
)}
</div>
);
}
export default function VentilationFreePro() {
return (
<DashboardLayout>
<VentilationFreeProContent />
</DashboardLayout>
);
}

View File

@@ -0,0 +1,24 @@
CREATE TABLE `freeproImports` (
`id` int AUTO_INCREMENT NOT NULL,
`userId` int NOT NULL,
`moisLabel` varchar(10) NOT NULL,
`annee` int NOT NULL,
`mois` int NOT NULL,
`refPiece` varchar(50),
`fileName` varchar(255) NOT NULL,
`nbLignes` int NOT NULL DEFAULT 0,
`totalTtc` varchar(30),
`createdAt` timestamp NOT NULL DEFAULT (now()),
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `freeproImports_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `freeproVentilationLines` (
`id` int AUTO_INCREMENT NOT NULL,
`importId` int NOT NULL,
`structure` varchar(100),
`type` varchar(50) NOT NULL,
`montantCentimes` int NOT NULL,
`createdAt` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `freeproVentilationLines_id` PRIMARY KEY(`id`)
);

File diff suppressed because it is too large Load Diff

View File

@@ -197,6 +197,13 @@
"when": 1780653000245,
"tag": "0027_complex_ultimo",
"breakpoints": true
},
{
"idx": 28,
"version": "5",
"when": 1780660548610,
"tag": "0028_dusty_kat_farrell",
"breakpoints": true
}
]
}

View File

@@ -421,3 +421,46 @@ export const invoiceLearnings = mysqlTable("invoiceLearnings", {
export type InvoiceLearning = typeof invoiceLearnings.$inferSelect;
export type InsertInvoiceLearning = typeof invoiceLearnings.$inferInsert;
/**
* FreePro ventilation imports — one record per imported monthly file
*/
export const freeproImports = mysqlTable("freeproImports", {
id: int("id").autoincrement().primaryKey(),
userId: int("userId").notNull(),
/** Label du mois, ex: "06/2025" */
moisLabel: varchar("moisLabel", { length: 10 }).notNull(),
/** Année (ex: 2025) */
annee: int("annee").notNull(),
/** Mois numérique (1-12) */
mois: int("mois").notNull(),
/** Référence de la pièce comptable, ex: F202506006010 */
refPiece: varchar("refPiece", { length: 50 }),
/** Nom du fichier Excel importé */
fileName: varchar("fileName", { length: 255 }).notNull(),
/** Nombre de lignes traitées */
nbLignes: int("nbLignes").default(0).notNull(),
/** Total général TTC calculé */
totalTtc: varchar("totalTtc", { length: 30 }),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
});
export type FreeproImport = typeof freeproImports.$inferSelect;
export type InsertFreeproImport = typeof freeproImports.$inferInsert;
/**
* FreePro ventilation lines — aggregated result (structure + type → montant TTC)
*/
export const freeproVentilationLines = mysqlTable("freeproVentilationLines", {
id: int("id").autoincrement().primaryKey(),
importId: int("importId").notNull(), // FK to freeproImports.id
/** Code structure, ex: "1083ADV" ou null pour (vide) */
structure: varchar("structure", { length: 100 }),
/** Type: "Lien fibre" | "Lien 5G" | "Tél. mobile" */
type: varchar("type", { length: 50 }).notNull(),
/** Montant TTC agrégé en centimes (pour éviter les flottants) */
montantCentimes: int("montantCentimes").notNull(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
});
export type FreeproVentilationLine = typeof freeproVentilationLines.$inferSelect;
export type InsertFreeproVentilationLine = typeof freeproVentilationLines.$inferInsert;

View File

@@ -74,6 +74,8 @@
"input-otp": "^1.4.2",
"jose": "6.1.0",
"jsonwebtoken": "^9.0.3",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.8",
"lucide-react": "^0.453.0",
"mailparser": "^3.9.3",
"mysql2": "^3.15.0",

182
pnpm-lock.yaml generated
View File

@@ -199,6 +199,12 @@ importers:
jsonwebtoken:
specifier: ^9.0.3
version: 9.0.3
jspdf:
specifier: ^4.2.1
version: 4.2.1
jspdf-autotable:
specifier: ^5.0.8
version: 5.0.8(jspdf@4.2.1)
lucide-react:
specifier: ^0.453.0
version: 0.453.0(react@19.2.1)
@@ -649,6 +655,10 @@ packages:
resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==}
engines: {node: '>=6.9.0'}
'@babel/runtime@7.29.7':
resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
engines: {node: '>=6.9.0'}
'@babel/template@7.27.2':
resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==}
engines: {node: '>=6.9.0'}
@@ -2752,9 +2762,15 @@ packages:
'@types/node@24.7.0':
resolution: {integrity: sha512-IbKooQVqUBrlzWTi79E8Fw78l8k1RNtlDDNWsFZs7XonuQSJ8oNYfEeclhprUldXISRMLzBpILuKgPlIxm+/Yw==}
'@types/pako@2.0.4':
resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==}
'@types/qs@6.14.0':
resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==}
'@types/raf@3.4.3':
resolution: {integrity: sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==}
'@types/range-parser@1.2.7':
resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
@@ -2975,6 +2991,10 @@ packages:
bare-url@2.4.0:
resolution: {integrity: sha512-NSTU5WN+fy/L0DDenfE8SXQna4voXuW0FHM7wH8i3/q9khUSchfPbPezO4zSFMnDGIf9YE+mt/RWhZgNRKRIXA==}
base64-arraybuffer@1.0.2:
resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==}
engines: {node: '>= 0.6.0'}
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
@@ -3044,6 +3064,10 @@ packages:
caniuse-lite@1.0.30001748:
resolution: {integrity: sha512-5P5UgAr0+aBmNiplks08JLw+AW/XG/SurlgZLgB1dDLfAw7EfRGxIwzPHxdSCGY/BTKDqIVyJL87cCN6s0ZR0w==}
canvg@3.0.11:
resolution: {integrity: sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==}
engines: {node: '>=10.0.0'}
ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
@@ -3166,6 +3190,9 @@ packages:
resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==}
engines: {node: '>=12.13'}
core-js@3.49.0:
resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==}
core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
@@ -3192,6 +3219,9 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
css-line-break@2.1.0:
resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==}
cssesc@3.0.0:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
engines: {node: '>=4'}
@@ -3457,6 +3487,9 @@ packages:
dompurify@3.3.0:
resolution: {integrity: sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==}
dompurify@3.4.8:
resolution: {integrity: sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==}
domutils@3.2.2:
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
@@ -3717,6 +3750,9 @@ packages:
fast-fifo@1.3.2:
resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==}
fast-png@6.4.0:
resolution: {integrity: sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==}
fast-xml-parser@5.2.5:
resolution: {integrity: sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==}
hasBin: true
@@ -3730,6 +3766,9 @@ packages:
picomatch:
optional: true
fflate@0.8.3:
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
finalhandler@1.3.1:
resolution: {integrity: sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==}
engines: {node: '>= 0.8'}
@@ -3894,6 +3933,10 @@ packages:
html-void-elements@3.0.0:
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
html2canvas@1.4.1:
resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
engines: {node: '>=8.0.0'}
htmlparser2@8.0.2:
resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
@@ -3951,6 +3994,9 @@ packages:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'}
iobuffer@5.4.0:
resolution: {integrity: sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==}
ipaddr.js@1.9.1:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'}
@@ -4036,6 +4082,14 @@ packages:
resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==}
engines: {node: '>=12', npm: '>=6'}
jspdf-autotable@5.0.8:
resolution: {integrity: sha512-Hy05N86yBO7CXBrnSLOge7i1ZYpKH2DjQ94iybaP7vBhSInjvRBgDc99ngKzSbSO8Jc98ZCally8I6n0tj2RJQ==}
peerDependencies:
jspdf: ^2 || ^3 || ^4
jspdf@4.2.1:
resolution: {integrity: sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==}
jwa@2.0.1:
resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
@@ -4532,6 +4586,9 @@ packages:
pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
pako@2.1.0:
resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==}
parse-entities@4.0.2:
resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
@@ -4594,6 +4651,9 @@ packages:
peberminta@0.9.0:
resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==}
performance-now@2.1.0:
resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -4668,6 +4728,9 @@ packages:
quansync@0.2.11:
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
raf@3.4.1:
resolution: {integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==}
range-parser@1.2.1:
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
engines: {node: '>= 0.6'}
@@ -4802,6 +4865,9 @@ packages:
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
regenerator-runtime@0.13.11:
resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==}
regex-recursion@6.0.2:
resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==}
@@ -4842,6 +4908,10 @@ packages:
resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
rgbcolor@1.0.1:
resolution: {integrity: sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==}
engines: {node: '>= 0.8.15'}
robust-predicates@3.0.2:
resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==}
@@ -4979,6 +5049,10 @@ packages:
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
stackblur-canvas@2.7.0:
resolution: {integrity: sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==}
engines: {node: '>=0.1.14'}
statuses@2.0.1:
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
engines: {node: '>= 0.8'}
@@ -5038,6 +5112,10 @@ packages:
resolution: {integrity: sha512-mJiVjfd2vokfDxsQPOwJ/PtanO87LhpYY88ubI5dUB1Ab58Txbyje3+jpm+/83R/fevaq/107NNhtYBLuoTrFg==}
engines: {node: '>=10'}
svg-pathdata@6.0.3:
resolution: {integrity: sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==}
engines: {node: '>=12.0.0'}
tailwind-merge@3.3.1:
resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==}
@@ -5066,6 +5144,9 @@ packages:
text-decoder@1.2.7:
resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==}
text-segmentation@1.0.3:
resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==}
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
@@ -5222,6 +5303,9 @@ packages:
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
engines: {node: '>= 0.4.0'}
utrie@1.0.2:
resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==}
uuid@11.1.0:
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
hasBin: true
@@ -6115,6 +6199,8 @@ snapshots:
'@babel/runtime@7.28.4': {}
'@babel/runtime@7.29.7': {}
'@babel/template@7.27.2':
dependencies:
'@babel/code-frame': 7.27.1
@@ -8104,8 +8190,13 @@ snapshots:
dependencies:
undici-types: 7.14.0
'@types/pako@2.0.4': {}
'@types/qs@6.14.0': {}
'@types/raf@3.4.3':
optional: true
'@types/range-parser@1.2.7': {}
'@types/react-dom@19.2.1(@types/react@19.2.1)':
@@ -8343,6 +8434,9 @@ snapshots:
dependencies:
bare-path: 3.0.0
base64-arraybuffer@1.0.2:
optional: true
base64-js@1.5.1: {}
baseline-browser-mapping@2.8.12: {}
@@ -8421,6 +8515,18 @@ snapshots:
caniuse-lite@1.0.30001748: {}
canvg@3.0.11:
dependencies:
'@babel/runtime': 7.29.7
'@types/raf': 3.4.3
core-js: 3.49.0
raf: 3.4.1
regenerator-runtime: 0.13.11
rgbcolor: 1.0.1
stackblur-canvas: 2.7.0
svg-pathdata: 6.0.3
optional: true
ccount@2.0.1: {}
cfb@1.2.2:
@@ -8539,6 +8645,9 @@ snapshots:
dependencies:
is-what: 4.1.16
core-js@3.49.0:
optional: true
core-util-is@1.0.3: {}
cose-base@1.0.3:
@@ -8568,6 +8677,11 @@ snapshots:
shebang-command: 2.0.0
which: 2.0.2
css-line-break@2.1.0:
dependencies:
utrie: 1.0.2
optional: true
cssesc@3.0.0: {}
csstype@3.1.3: {}
@@ -8832,6 +8946,11 @@ snapshots:
optionalDependencies:
'@types/trusted-types': 2.0.7
dompurify@3.4.8:
optionalDependencies:
'@types/trusted-types': 2.0.7
optional: true
domutils@3.2.2:
dependencies:
dom-serializer: 2.0.0
@@ -9078,6 +9197,12 @@ snapshots:
fast-fifo@1.3.2: {}
fast-png@6.4.0:
dependencies:
'@types/pako': 2.0.4
iobuffer: 5.4.0
pako: 2.1.0
fast-xml-parser@5.2.5:
dependencies:
strnum: 2.1.1
@@ -9086,6 +9211,8 @@ snapshots:
optionalDependencies:
picomatch: 4.0.3
fflate@0.8.3: {}
finalhandler@1.3.1:
dependencies:
debug: 2.6.9
@@ -9326,6 +9453,12 @@ snapshots:
html-void-elements@3.0.0: {}
html2canvas@1.4.1:
dependencies:
css-line-break: 2.1.0
text-segmentation: 1.0.3
optional: true
htmlparser2@8.0.2:
dependencies:
domelementtype: 2.3.0
@@ -9391,6 +9524,8 @@ snapshots:
internmap@2.0.3: {}
iobuffer@5.4.0: {}
ipaddr.js@1.9.1: {}
is-alphabetical@2.0.1: {}
@@ -9459,6 +9594,21 @@ snapshots:
ms: 2.1.3
semver: 7.7.3
jspdf-autotable@5.0.8(jspdf@4.2.1):
dependencies:
jspdf: 4.2.1
jspdf@4.2.1:
dependencies:
'@babel/runtime': 7.29.7
fast-png: 6.4.0
fflate: 0.8.3
optionalDependencies:
canvg: 3.0.11
core-js: 3.49.0
dompurify: 3.4.8
html2canvas: 1.4.1
jwa@2.0.1:
dependencies:
buffer-equal-constant-time: 1.0.1
@@ -10153,6 +10303,8 @@ snapshots:
pako@1.0.11: {}
pako@2.1.0: {}
parse-entities@4.0.2:
dependencies:
'@types/unist': 2.0.11
@@ -10216,6 +10368,9 @@ snapshots:
peberminta@0.9.0: {}
performance-now@2.1.0:
optional: true
picocolors@1.1.1: {}
picomatch@4.0.3: {}
@@ -10285,6 +10440,11 @@ snapshots:
quansync@0.2.11: {}
raf@3.4.1:
dependencies:
performance-now: 2.1.0
optional: true
range-parser@1.2.1: {}
raw-body@2.5.2:
@@ -10454,6 +10614,9 @@ snapshots:
tiny-invariant: 1.3.3
victory-vendor: 36.9.2
regenerator-runtime@0.13.11:
optional: true
regex-recursion@6.0.2:
dependencies:
regex-utilities: 2.3.0
@@ -10529,6 +10692,9 @@ snapshots:
resolve-pkg-maps@1.0.0: {}
rgbcolor@1.0.1:
optional: true
robust-predicates@3.0.2: {}
rollup@4.52.4:
@@ -10736,6 +10902,9 @@ snapshots:
stackback@0.0.2: {}
stackblur-canvas@2.7.0:
optional: true
statuses@2.0.1: {}
std-env@3.9.0: {}
@@ -10820,6 +10989,9 @@ snapshots:
dependencies:
copy-anything: 3.0.5
svg-pathdata@6.0.3:
optional: true
tailwind-merge@3.3.1: {}
tailwindcss-animate@1.0.7(tailwindcss@4.1.14):
@@ -10862,6 +11034,11 @@ snapshots:
transitivePeerDependencies:
- react-native-b4a
text-segmentation@1.0.3:
dependencies:
utrie: 1.0.2
optional: true
tiny-invariant@1.3.3: {}
tinybench@2.9.0: {}
@@ -11001,6 +11178,11 @@ snapshots:
utils-merge@1.0.1: {}
utrie@1.0.2:
dependencies:
base64-arraybuffer: 1.0.2
optional: true
uuid@11.1.0: {}
uuid@8.3.2: {}

View File

@@ -944,3 +944,70 @@ export async function updateBapHistoryPdfUrl(id: number, pdfUrl: string): Promis
if (!db) return;
await db.update(bapHistory).set({ pdfUrl }).where(eq(bapHistory.id, id));
}
// ============= FREEPRO VENTILATION OPERATIONS =============
import {
freeproImports,
InsertFreeproImport,
FreeproImport,
freeproVentilationLines,
InsertFreeproVentilationLine,
FreeproVentilationLine,
} from "../drizzle/schema";
/** Crée un import FreePro et ses lignes de ventilation */
export async function createFreeproImport(
data: InsertFreeproImport,
lines: Omit<InsertFreeproVentilationLine, "importId">[]
): Promise<number> {
const db = await getDb();
if (!db) throw new Error("Database not available");
const result = await db.insert(freeproImports).values(data);
const importId = (result as unknown as { insertId: number }).insertId;
if (lines.length > 0) {
await db.insert(freeproVentilationLines).values(
lines.map((l) => ({ ...l, importId }))
);
}
return importId;
}
/** Récupère tous les imports FreePro d'un utilisateur (sans les lignes) */
export async function getFreeproImportsByUser(userId: number): Promise<FreeproImport[]> {
const db = await getDb();
if (!db) return [];
return db
.select()
.from(freeproImports)
.where(eq(freeproImports.userId, userId))
.orderBy(desc(freeproImports.annee), desc(freeproImports.mois));
}
/** Récupère un import FreePro avec ses lignes */
export async function getFreeproImportWithLines(
importId: number
): Promise<{ import: FreeproImport; lines: FreeproVentilationLine[] } | null> {
const db = await getDb();
if (!db) return null;
const imports = await db
.select()
.from(freeproImports)
.where(eq(freeproImports.id, importId))
.limit(1);
if (!imports[0]) return null;
const lines = await db
.select()
.from(freeproVentilationLines)
.where(eq(freeproVentilationLines.importId, importId))
.orderBy(freeproVentilationLines.structure, freeproVentilationLines.type);
return { import: imports[0], lines };
}
/** Supprime un import FreePro et ses lignes */
export async function deleteFreeproImport(importId: number): Promise<void> {
const db = await getDb();
if (!db) return;
await db.delete(freeproVentilationLines).where(eq(freeproVentilationLines.importId, importId));
await db.delete(freeproImports).where(eq(freeproImports.id, importId));
}

167
server/freeproService.ts Normal file
View File

@@ -0,0 +1,167 @@
/**
* FreePro Ventilation Service
* Transforms a FreePro Excel invoice file into a ventilation summary (structure × type → TTC)
*
* Transformation rules (from Process Scripting facture freepro.txt):
* 1. Colonne "script": =SI(OU(F2="Offre Freebox Pro";F2="Support Premium";ESTNUM(TROUVE("Remise première année";F2)));"Lien fibre";"Tél. mobile")
* 2. Colonne "ttc": =ARRONDI(total_ht * 1.2, 2)
* 3. Colonne "type": =SI(ESTNUM(TROUVE("Routeur 5G"; label_client)); "Lien 5G"; script)
* 4. TCD: agrégation par (structure, type) → somme des TTC
*/
import * as xlsx from "xlsx";
export interface FreeproSourceRow {
ref_piece: string;
date: Date | null;
reference_service: string;
label_client: string;
structure: string;
description: string;
debut_factu: Date | null;
fin_factu: Date | null;
quantite: number;
prix: number;
total: number;
}
export interface FreeproVentilationResult {
refPiece: string;
moisLabel: string;
annee: number;
mois: number;
nbLignes: number;
totalTtc: number;
lines: Array<{
structure: string | null;
type: string;
montant: number; // TTC en euros
}>;
}
/**
* Détermine la colonne "script" selon la description
*/
function getScript(description: string): string {
if (!description) return "Tél. mobile";
if (
description === "Offre Freebox Pro" ||
description === "Support Premium" ||
description.includes("Remise première année")
) {
return "Lien fibre";
}
return "Tél. mobile";
}
/**
* Calcule le TTC : arrondi(total_ht * 1.2, 2)
*/
function getTtc(totalHt: number): number {
return Math.round(totalHt * 1.2 * 100) / 100;
}
/**
* Détermine le "type" final selon label_client et script
*/
function getType(labelClient: string, script: string): string {
if (labelClient && labelClient.includes("Routeur 5G")) {
return "Lien 5G";
}
return script;
}
/**
* Parse et transforme un fichier Excel FreePro (Buffer)
* Retourne les données de ventilation agrégées
*/
export function processFreeproExcel(
buffer: Buffer,
moisLabel: string
): FreeproVentilationResult {
const wb = xlsx.read(buffer, { type: "buffer", cellDates: true });
const ws = wb.Sheets[wb.SheetNames[0]];
const rawRows = xlsx.utils.sheet_to_json<Record<string, unknown>>(ws, {
header: [
"ref_piece",
"date",
"reference_service",
"label_client",
"structure",
"description",
"debut_factu",
"fin_factu",
"quantite",
"prix",
"total",
],
range: 1, // skip header row
defval: null,
});
if (rawRows.length === 0) {
throw new Error("Le fichier Excel est vide ou ne contient pas de données.");
}
// Extraire ref_piece et date du mois depuis la première ligne
const firstRow = rawRows[0];
const refPiece = String(firstRow.ref_piece || "");
// Parser moisLabel "MM/YYYY"
const [moisStr, anneeStr] = moisLabel.split("/");
const mois = parseInt(moisStr, 10);
const annee = parseInt(anneeStr, 10);
// Agrégation par (structure, type) → somme TTC en centimes (pour éviter les flottants)
const aggregation = new Map<string, number>();
let nbLignes = 0;
for (const row of rawRows) {
if (!row.ref_piece) continue;
nbLignes++;
const description = String(row.description || "");
const labelClient = String(row.label_client || "");
const structure = row.structure ? String(row.structure) : null;
const totalHt = parseFloat(String(row.total || "0").replace(",", ".")) || 0;
const script = getScript(description);
const ttc = getTtc(totalHt);
const type = getType(labelClient, script);
const key = `${structure ?? ""}|||${type}`;
aggregation.set(key, (aggregation.get(key) || 0) + Math.round(ttc * 100));
}
// Construire les lignes de résultat
const lines: FreeproVentilationResult["lines"] = [];
for (const [key, centimes] of Array.from(aggregation.entries())) {
const [structureRaw, type] = key.split("|||");
lines.push({
structure: structureRaw === "" ? null : structureRaw,
type,
montant: centimes / 100,
});
}
// Trier par structure (null en dernier) puis type
lines.sort((a, b) => {
const sa = a.structure ?? "\uFFFF"; // null → fin de liste
const sb = b.structure ?? "\uFFFF";
if (sa !== sb) return sa.localeCompare(sb);
return a.type.localeCompare(b.type);
});
const totalTtc =
Math.round(lines.reduce((sum, l) => sum + l.montant, 0) * 100) / 100;
return {
refPiece,
moisLabel,
annee,
mois,
nbLignes,
totalTtc,
lines,
};
}

View File

@@ -85,6 +85,13 @@ import { drawBapCartouche } from "./bapCartouche";
import { startEmailImportService, stopEmailImportService, isEmailImportServiceRunning, triggerEmailCheck, testImapConnection } from "./emailImportService";
import { startFolderImportService, stopFolderImportService, isFolderImportServiceRunning } from "./folderImportService";
import { TRPCError } from "@trpc/server";
import { processFreeproExcel } from "./freeproService";
import {
createFreeproImport,
getFreeproImportsByUser,
getFreeproImportWithLines,
deleteFreeproImport,
} from "./db";
// Admin-only procedure
const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
@@ -2197,5 +2204,70 @@ export const appRouter = router({
return { success: true };
}),
}),
// ============= FREEPRO VENTILATION =============
freepro: router({
/** Importe un fichier Excel FreePro et calcule la ventilation */
import: protectedProcedure
.input(
z.object({
moisLabel: z.string().regex(/^\d{2}\/\d{4}$/, "Format MM/YYYY requis"),
fileName: z.string(),
fileBase64: z.string(), // fichier Excel encodé en base64
})
)
.mutation(async ({ input, ctx }) => {
const buffer = Buffer.from(input.fileBase64, "base64");
const result = processFreeproExcel(buffer, input.moisLabel);
const importId = await createFreeproImport(
{
userId: ctx.user.id,
moisLabel: result.moisLabel,
annee: result.annee,
mois: result.mois,
refPiece: result.refPiece || null,
fileName: input.fileName,
nbLignes: result.nbLignes,
totalTtc: result.totalTtc.toFixed(2),
},
result.lines.map((l) => ({
structure: l.structure ?? null,
type: l.type,
montantCentimes: Math.round(l.montant * 100),
}))
);
return { importId, ...result };
}),
/** Liste tous les imports FreePro de l'utilisateur */
list: protectedProcedure.query(async ({ ctx }) => {
return getFreeproImportsByUser(ctx.user.id);
}),
/** Récupère un import FreePro avec ses lignes de ventilation */
getById: protectedProcedure
.input(z.object({ id: z.number() }))
.query(async ({ input, ctx }) => {
const data = await getFreeproImportWithLines(input.id);
if (!data) throw new TRPCError({ code: "NOT_FOUND" });
if (data.import.userId !== ctx.user.id)
throw new TRPCError({ code: "FORBIDDEN" });
return data;
}),
/** Supprime un import FreePro */
delete: protectedProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input, ctx }) => {
const data = await getFreeproImportWithLines(input.id);
if (!data) throw new TRPCError({ code: "NOT_FOUND" });
if (data.import.userId !== ctx.user.id)
throw new TRPCError({ code: "FORBIDDEN" });
await deleteFreeproImport(input.id);
return { success: true };
}),
}),
});
export type AppRouter = typeof appRouter;

17
todo.md
View File

@@ -642,5 +642,18 @@
- [x] Ajouter emailImportAuthMode dans le schéma Zod de importSettings.update
- [x] Ajouter sélecteur mode auth (basic/oauth2) dans ImportSettings.tsx
- [x] Ajouter bouton "Tester la connexion IMAP" dans ImportSettings.tsx
- [ ] Déployer sur recette et production
- [ ] Appliquer migration emailImportAuthMode sur recette et production
- [x] Déployer sur recette (git push + docker compose up --build)
- [x] Appliquer migration emailImportAuthMode sur recette (colonne ajoutée en DB)
- [ ] Déployer sur production (en attente de validation recette)
## Module Ventilation FreePro
- [x] Schéma DB : tables freeproImports et freeproVentilationLines
- [x] Migration DB appliquée (pnpm db:push)
- [x] Service freeproService.ts : transformation Excel → ventilation (script/TTC/type)
- [x] Helpers DB dans db.ts : createFreeproImport, getFreeproImportsByUser, getFreeproImportWithLines, deleteFreeproImport
- [x] Procédures tRPC : freepro.import, freepro.list, freepro.getById, freepro.delete
- [x] Page VentilationFreePro.tsx : import drag&drop, tableau de ventilation, historique, export PDF
- [x] Menu "Ventilations > FreePro" ajouté dans DashboardLayout
- [x] Route /ventilation-freepro ajoutée dans App.tsx
- [ ] Déploiement sur recette