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:
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
543
client/src/pages/VentilationFreePro.tsx
Normal file
543
client/src/pages/VentilationFreePro.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user