572 lines
22 KiB
TypeScript
572 lines
22 KiB
TypeScript
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>
|
||
);
|
||
}
|