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