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(null); const [deleteId, setDeleteId] = useState(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(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 (
{/* En-tête */}

Ventilation FreePro

Import et ventilation des factures Free Pro par structure

{/* Zone d'import */} Importer une facture FreePro {/* Sélecteur de mois */}
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}" /> Format : MM/YYYY
{/* Zone de dépôt */}
{ e.preventDefault(); setIsDragging(true); }} onDragLeave={() => setIsDragging(false)} onDrop={handleDrop} onClick={() => fileInputRef.current?.click()} > { const f = e.target.files?.[0]; if (f) handleFile(f); }} /> {importMutation.isPending ? (

Traitement en cours…

) : (

Glissez-déposez le fichier Excel FreePro ici

ou cliquez pour sélectionner (.xlsx, .xls)

)}
{/* Historique */} Historique des imports ({imports.length}) {loadingList ? (
) : imports.length === 0 ? (

Aucun import enregistré

) : ( Mois Réf. pièce Fichier Lignes Total TTC Importé le Actions {(imports as ImportRecord[]).map((imp) => ( { setSelectedImportId(imp.id); setView("detail"); }} > {imp.moisLabel} {imp.refPiece ?? "—"} {imp.fileName} {imp.nbLignes} {formatTotalTtc(imp.totalTtc)} {new Date(imp.createdAt).toLocaleDateString("fr-FR")}
e.stopPropagation()}>
))}
)} {/* Dialog de confirmation de suppression */} setDeleteId(null)}> Supprimer cet import ? Cette action est irréversible. Toutes les lignes de ventilation associées seront supprimées. Annuler { if (deleteId) deleteMutation.mutate({ id: deleteId }); setDeleteId(null); }} > Supprimer
); } // ── 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 (
{/* En-tête détail */}

Ventilation FreePro — {imp?.moisLabel ?? "…"}

{imp && ( )}
{loadingDetail ? (
) : imp ? ( <> {/* Métadonnées */}

Référence pièce

{imp.refPiece ?? "—"}

Mois

01/{imp.moisLabel}

Lignes traitées

{imp.nbLignes}

Total TTC

{formatTotalTtc(imp.totalTtc)}

{/* Tableau de ventilation */} Ventilation par structure et type structure Type Montant {lines.map((line) => ( {line.structure ?? (vide)} {line.type} {formatMontant(line.montantCentimes)} ))}
Total général {formatMontant(totalCentimes)}
) : (
Import introuvable.
)}
); } export default function VentilationFreePro() { return ( ); }