Files
demat-facturation/client/src/pages/VentilationFreePro.tsx

572 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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[]
) {
// A4 paysage : 297mm × 210mm
const doc = new jsPDF({ orientation: "landscape", unit: "mm", format: "a4" });
const pageW = 297;
const pageH = 210;
const margin = 14;
// En-tête
doc.setFontSize(14);
doc.setFont("helvetica", "bold");
doc.text("Ventilation facture FREE PRO", pageW / 2, 14, { align: "center" });
// Date du mois
const [moisStr, anneeStr] = importRecord.moisLabel.split("/");
const dateLabel = `01/${moisStr}/${anneeStr}`;
doc.setFontSize(11);
doc.text(dateLabel, pageW / 2, 21, { 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(8);
doc.setFont("helvetica", "normal");
doc.text(editDate, pageW - margin, 9, { align: "right" });
// Référence pièce
doc.setFontSize(9);
doc.setFont("helvetica", "bold");
doc.text("ref_piece :", margin, 29);
doc.setFont("helvetica", "normal");
doc.text(importRecord.refPiece ?? "", margin + 25, 29);
// 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);
// Calcul dynamique des largeurs pour tenir sur 1 page
const usableW = pageW - margin * 2; // ~269mm
const colStructure = usableW * 0.50; // ~134mm
const colType = usableW * 0.30; // ~81mm
const colMontant = usableW * 0.20; // ~54mm
// Calcul de la taille de police pour tenir sur 1 page
// A4 paysage : ~170mm de hauteur utile (210 - 35 header - 10 footer)
const nbRows = lines.length + 2; // +1 header +1 footer
const availH = pageH - 38 - 12; // zone tableau
const rowH = Math.min(8, Math.floor(availH / nbRows));
const fontSize = rowH >= 7 ? 9 : rowH >= 6 ? 8 : 7;
autoTable(doc, {
startY: 33,
head: [["Structure", "Type", "Montant TTC"]],
body: tableData,
foot: [["Total général", "", formatMontant(totalCentimes)]],
styles: { fontSize, cellPadding: rowH >= 7 ? 2 : 1.5, overflow: "linebreak" },
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: colStructure },
1: { cellWidth: colType },
2: { cellWidth: colMontant, halign: "right" },
},
alternateRowStyles: { fillColor: [248, 248, 248] },
// Forcer tout sur 1 page
pageBreak: "avoid",
rowPageBreak: "avoid",
margin: { left: margin, right: margin },
tableWidth: usableW,
});
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>
);
}