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>
|
||||
);
|
||||
}
|
||||
24
drizzle/0028_dusty_kat_farrell.sql
Normal file
24
drizzle/0028_dusty_kat_farrell.sql
Normal file
@@ -0,0 +1,24 @@
|
||||
CREATE TABLE `freeproImports` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`userId` int NOT NULL,
|
||||
`moisLabel` varchar(10) NOT NULL,
|
||||
`annee` int NOT NULL,
|
||||
`mois` int NOT NULL,
|
||||
`refPiece` varchar(50),
|
||||
`fileName` varchar(255) NOT NULL,
|
||||
`nbLignes` int NOT NULL DEFAULT 0,
|
||||
`totalTtc` varchar(30),
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `freeproImports_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `freeproVentilationLines` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`importId` int NOT NULL,
|
||||
`structure` varchar(100),
|
||||
`type` varchar(50) NOT NULL,
|
||||
`montantCentimes` int NOT NULL,
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
CONSTRAINT `freeproVentilationLines_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
1990
drizzle/meta/0028_snapshot.json
Normal file
1990
drizzle/meta/0028_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -197,6 +197,13 @@
|
||||
"when": 1780653000245,
|
||||
"tag": "0027_complex_ultimo",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 28,
|
||||
"version": "5",
|
||||
"when": 1780660548610,
|
||||
"tag": "0028_dusty_kat_farrell",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -421,3 +421,46 @@ export const invoiceLearnings = mysqlTable("invoiceLearnings", {
|
||||
|
||||
export type InvoiceLearning = typeof invoiceLearnings.$inferSelect;
|
||||
export type InsertInvoiceLearning = typeof invoiceLearnings.$inferInsert;
|
||||
|
||||
/**
|
||||
* FreePro ventilation imports — one record per imported monthly file
|
||||
*/
|
||||
export const freeproImports = mysqlTable("freeproImports", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
userId: int("userId").notNull(),
|
||||
/** Label du mois, ex: "06/2025" */
|
||||
moisLabel: varchar("moisLabel", { length: 10 }).notNull(),
|
||||
/** Année (ex: 2025) */
|
||||
annee: int("annee").notNull(),
|
||||
/** Mois numérique (1-12) */
|
||||
mois: int("mois").notNull(),
|
||||
/** Référence de la pièce comptable, ex: F202506006010 */
|
||||
refPiece: varchar("refPiece", { length: 50 }),
|
||||
/** Nom du fichier Excel importé */
|
||||
fileName: varchar("fileName", { length: 255 }).notNull(),
|
||||
/** Nombre de lignes traitées */
|
||||
nbLignes: int("nbLignes").default(0).notNull(),
|
||||
/** Total général TTC calculé */
|
||||
totalTtc: varchar("totalTtc", { length: 30 }),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
});
|
||||
export type FreeproImport = typeof freeproImports.$inferSelect;
|
||||
export type InsertFreeproImport = typeof freeproImports.$inferInsert;
|
||||
|
||||
/**
|
||||
* FreePro ventilation lines — aggregated result (structure + type → montant TTC)
|
||||
*/
|
||||
export const freeproVentilationLines = mysqlTable("freeproVentilationLines", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
importId: int("importId").notNull(), // FK to freeproImports.id
|
||||
/** Code structure, ex: "1083ADV" ou null pour (vide) */
|
||||
structure: varchar("structure", { length: 100 }),
|
||||
/** Type: "Lien fibre" | "Lien 5G" | "Tél. mobile" */
|
||||
type: varchar("type", { length: 50 }).notNull(),
|
||||
/** Montant TTC agrégé en centimes (pour éviter les flottants) */
|
||||
montantCentimes: int("montantCentimes").notNull(),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
});
|
||||
export type FreeproVentilationLine = typeof freeproVentilationLines.$inferSelect;
|
||||
export type InsertFreeproVentilationLine = typeof freeproVentilationLines.$inferInsert;
|
||||
|
||||
@@ -74,6 +74,8 @@
|
||||
"input-otp": "^1.4.2",
|
||||
"jose": "6.1.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"jspdf": "^4.2.1",
|
||||
"jspdf-autotable": "^5.0.8",
|
||||
"lucide-react": "^0.453.0",
|
||||
"mailparser": "^3.9.3",
|
||||
"mysql2": "^3.15.0",
|
||||
|
||||
182
pnpm-lock.yaml
generated
182
pnpm-lock.yaml
generated
@@ -199,6 +199,12 @@ importers:
|
||||
jsonwebtoken:
|
||||
specifier: ^9.0.3
|
||||
version: 9.0.3
|
||||
jspdf:
|
||||
specifier: ^4.2.1
|
||||
version: 4.2.1
|
||||
jspdf-autotable:
|
||||
specifier: ^5.0.8
|
||||
version: 5.0.8(jspdf@4.2.1)
|
||||
lucide-react:
|
||||
specifier: ^0.453.0
|
||||
version: 0.453.0(react@19.2.1)
|
||||
@@ -649,6 +655,10 @@ packages:
|
||||
resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/runtime@7.29.7':
|
||||
resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/template@7.27.2':
|
||||
resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -2752,9 +2762,15 @@ packages:
|
||||
'@types/node@24.7.0':
|
||||
resolution: {integrity: sha512-IbKooQVqUBrlzWTi79E8Fw78l8k1RNtlDDNWsFZs7XonuQSJ8oNYfEeclhprUldXISRMLzBpILuKgPlIxm+/Yw==}
|
||||
|
||||
'@types/pako@2.0.4':
|
||||
resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==}
|
||||
|
||||
'@types/qs@6.14.0':
|
||||
resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==}
|
||||
|
||||
'@types/raf@3.4.3':
|
||||
resolution: {integrity: sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==}
|
||||
|
||||
'@types/range-parser@1.2.7':
|
||||
resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
|
||||
|
||||
@@ -2975,6 +2991,10 @@ packages:
|
||||
bare-url@2.4.0:
|
||||
resolution: {integrity: sha512-NSTU5WN+fy/L0DDenfE8SXQna4voXuW0FHM7wH8i3/q9khUSchfPbPezO4zSFMnDGIf9YE+mt/RWhZgNRKRIXA==}
|
||||
|
||||
base64-arraybuffer@1.0.2:
|
||||
resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==}
|
||||
engines: {node: '>= 0.6.0'}
|
||||
|
||||
base64-js@1.5.1:
|
||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||
|
||||
@@ -3044,6 +3064,10 @@ packages:
|
||||
caniuse-lite@1.0.30001748:
|
||||
resolution: {integrity: sha512-5P5UgAr0+aBmNiplks08JLw+AW/XG/SurlgZLgB1dDLfAw7EfRGxIwzPHxdSCGY/BTKDqIVyJL87cCN6s0ZR0w==}
|
||||
|
||||
canvg@3.0.11:
|
||||
resolution: {integrity: sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
ccount@2.0.1:
|
||||
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
|
||||
|
||||
@@ -3166,6 +3190,9 @@ packages:
|
||||
resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==}
|
||||
engines: {node: '>=12.13'}
|
||||
|
||||
core-js@3.49.0:
|
||||
resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==}
|
||||
|
||||
core-util-is@1.0.3:
|
||||
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
||||
|
||||
@@ -3192,6 +3219,9 @@ packages:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
css-line-break@2.1.0:
|
||||
resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==}
|
||||
|
||||
cssesc@3.0.0:
|
||||
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -3457,6 +3487,9 @@ packages:
|
||||
dompurify@3.3.0:
|
||||
resolution: {integrity: sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==}
|
||||
|
||||
dompurify@3.4.8:
|
||||
resolution: {integrity: sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==}
|
||||
|
||||
domutils@3.2.2:
|
||||
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
|
||||
|
||||
@@ -3717,6 +3750,9 @@ packages:
|
||||
fast-fifo@1.3.2:
|
||||
resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==}
|
||||
|
||||
fast-png@6.4.0:
|
||||
resolution: {integrity: sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==}
|
||||
|
||||
fast-xml-parser@5.2.5:
|
||||
resolution: {integrity: sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==}
|
||||
hasBin: true
|
||||
@@ -3730,6 +3766,9 @@ packages:
|
||||
picomatch:
|
||||
optional: true
|
||||
|
||||
fflate@0.8.3:
|
||||
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
|
||||
|
||||
finalhandler@1.3.1:
|
||||
resolution: {integrity: sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -3894,6 +3933,10 @@ packages:
|
||||
html-void-elements@3.0.0:
|
||||
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
|
||||
|
||||
html2canvas@1.4.1:
|
||||
resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
htmlparser2@8.0.2:
|
||||
resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
|
||||
|
||||
@@ -3951,6 +3994,9 @@ packages:
|
||||
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
iobuffer@5.4.0:
|
||||
resolution: {integrity: sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==}
|
||||
|
||||
ipaddr.js@1.9.1:
|
||||
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
|
||||
engines: {node: '>= 0.10'}
|
||||
@@ -4036,6 +4082,14 @@ packages:
|
||||
resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==}
|
||||
engines: {node: '>=12', npm: '>=6'}
|
||||
|
||||
jspdf-autotable@5.0.8:
|
||||
resolution: {integrity: sha512-Hy05N86yBO7CXBrnSLOge7i1ZYpKH2DjQ94iybaP7vBhSInjvRBgDc99ngKzSbSO8Jc98ZCally8I6n0tj2RJQ==}
|
||||
peerDependencies:
|
||||
jspdf: ^2 || ^3 || ^4
|
||||
|
||||
jspdf@4.2.1:
|
||||
resolution: {integrity: sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==}
|
||||
|
||||
jwa@2.0.1:
|
||||
resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
|
||||
|
||||
@@ -4532,6 +4586,9 @@ packages:
|
||||
pako@1.0.11:
|
||||
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
|
||||
|
||||
pako@2.1.0:
|
||||
resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==}
|
||||
|
||||
parse-entities@4.0.2:
|
||||
resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
|
||||
|
||||
@@ -4594,6 +4651,9 @@ packages:
|
||||
peberminta@0.9.0:
|
||||
resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==}
|
||||
|
||||
performance-now@2.1.0:
|
||||
resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==}
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
@@ -4668,6 +4728,9 @@ packages:
|
||||
quansync@0.2.11:
|
||||
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
|
||||
|
||||
raf@3.4.1:
|
||||
resolution: {integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==}
|
||||
|
||||
range-parser@1.2.1:
|
||||
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -4802,6 +4865,9 @@ packages:
|
||||
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
regenerator-runtime@0.13.11:
|
||||
resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==}
|
||||
|
||||
regex-recursion@6.0.2:
|
||||
resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==}
|
||||
|
||||
@@ -4842,6 +4908,10 @@ packages:
|
||||
resolve-pkg-maps@1.0.0:
|
||||
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
|
||||
|
||||
rgbcolor@1.0.1:
|
||||
resolution: {integrity: sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==}
|
||||
engines: {node: '>= 0.8.15'}
|
||||
|
||||
robust-predicates@3.0.2:
|
||||
resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==}
|
||||
|
||||
@@ -4979,6 +5049,10 @@ packages:
|
||||
stackback@0.0.2:
|
||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||
|
||||
stackblur-canvas@2.7.0:
|
||||
resolution: {integrity: sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==}
|
||||
engines: {node: '>=0.1.14'}
|
||||
|
||||
statuses@2.0.1:
|
||||
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -5038,6 +5112,10 @@ packages:
|
||||
resolution: {integrity: sha512-mJiVjfd2vokfDxsQPOwJ/PtanO87LhpYY88ubI5dUB1Ab58Txbyje3+jpm+/83R/fevaq/107NNhtYBLuoTrFg==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
svg-pathdata@6.0.3:
|
||||
resolution: {integrity: sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
tailwind-merge@3.3.1:
|
||||
resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==}
|
||||
|
||||
@@ -5066,6 +5144,9 @@ packages:
|
||||
text-decoder@1.2.7:
|
||||
resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==}
|
||||
|
||||
text-segmentation@1.0.3:
|
||||
resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==}
|
||||
|
||||
tiny-invariant@1.3.3:
|
||||
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
|
||||
|
||||
@@ -5222,6 +5303,9 @@ packages:
|
||||
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
|
||||
engines: {node: '>= 0.4.0'}
|
||||
|
||||
utrie@1.0.2:
|
||||
resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==}
|
||||
|
||||
uuid@11.1.0:
|
||||
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
|
||||
hasBin: true
|
||||
@@ -6115,6 +6199,8 @@ snapshots:
|
||||
|
||||
'@babel/runtime@7.28.4': {}
|
||||
|
||||
'@babel/runtime@7.29.7': {}
|
||||
|
||||
'@babel/template@7.27.2':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.27.1
|
||||
@@ -8104,8 +8190,13 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 7.14.0
|
||||
|
||||
'@types/pako@2.0.4': {}
|
||||
|
||||
'@types/qs@6.14.0': {}
|
||||
|
||||
'@types/raf@3.4.3':
|
||||
optional: true
|
||||
|
||||
'@types/range-parser@1.2.7': {}
|
||||
|
||||
'@types/react-dom@19.2.1(@types/react@19.2.1)':
|
||||
@@ -8343,6 +8434,9 @@ snapshots:
|
||||
dependencies:
|
||||
bare-path: 3.0.0
|
||||
|
||||
base64-arraybuffer@1.0.2:
|
||||
optional: true
|
||||
|
||||
base64-js@1.5.1: {}
|
||||
|
||||
baseline-browser-mapping@2.8.12: {}
|
||||
@@ -8421,6 +8515,18 @@ snapshots:
|
||||
|
||||
caniuse-lite@1.0.30001748: {}
|
||||
|
||||
canvg@3.0.11:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.7
|
||||
'@types/raf': 3.4.3
|
||||
core-js: 3.49.0
|
||||
raf: 3.4.1
|
||||
regenerator-runtime: 0.13.11
|
||||
rgbcolor: 1.0.1
|
||||
stackblur-canvas: 2.7.0
|
||||
svg-pathdata: 6.0.3
|
||||
optional: true
|
||||
|
||||
ccount@2.0.1: {}
|
||||
|
||||
cfb@1.2.2:
|
||||
@@ -8539,6 +8645,9 @@ snapshots:
|
||||
dependencies:
|
||||
is-what: 4.1.16
|
||||
|
||||
core-js@3.49.0:
|
||||
optional: true
|
||||
|
||||
core-util-is@1.0.3: {}
|
||||
|
||||
cose-base@1.0.3:
|
||||
@@ -8568,6 +8677,11 @@ snapshots:
|
||||
shebang-command: 2.0.0
|
||||
which: 2.0.2
|
||||
|
||||
css-line-break@2.1.0:
|
||||
dependencies:
|
||||
utrie: 1.0.2
|
||||
optional: true
|
||||
|
||||
cssesc@3.0.0: {}
|
||||
|
||||
csstype@3.1.3: {}
|
||||
@@ -8832,6 +8946,11 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
|
||||
dompurify@3.4.8:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
optional: true
|
||||
|
||||
domutils@3.2.2:
|
||||
dependencies:
|
||||
dom-serializer: 2.0.0
|
||||
@@ -9078,6 +9197,12 @@ snapshots:
|
||||
|
||||
fast-fifo@1.3.2: {}
|
||||
|
||||
fast-png@6.4.0:
|
||||
dependencies:
|
||||
'@types/pako': 2.0.4
|
||||
iobuffer: 5.4.0
|
||||
pako: 2.1.0
|
||||
|
||||
fast-xml-parser@5.2.5:
|
||||
dependencies:
|
||||
strnum: 2.1.1
|
||||
@@ -9086,6 +9211,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.3
|
||||
|
||||
fflate@0.8.3: {}
|
||||
|
||||
finalhandler@1.3.1:
|
||||
dependencies:
|
||||
debug: 2.6.9
|
||||
@@ -9326,6 +9453,12 @@ snapshots:
|
||||
|
||||
html-void-elements@3.0.0: {}
|
||||
|
||||
html2canvas@1.4.1:
|
||||
dependencies:
|
||||
css-line-break: 2.1.0
|
||||
text-segmentation: 1.0.3
|
||||
optional: true
|
||||
|
||||
htmlparser2@8.0.2:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
@@ -9391,6 +9524,8 @@ snapshots:
|
||||
|
||||
internmap@2.0.3: {}
|
||||
|
||||
iobuffer@5.4.0: {}
|
||||
|
||||
ipaddr.js@1.9.1: {}
|
||||
|
||||
is-alphabetical@2.0.1: {}
|
||||
@@ -9459,6 +9594,21 @@ snapshots:
|
||||
ms: 2.1.3
|
||||
semver: 7.7.3
|
||||
|
||||
jspdf-autotable@5.0.8(jspdf@4.2.1):
|
||||
dependencies:
|
||||
jspdf: 4.2.1
|
||||
|
||||
jspdf@4.2.1:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.7
|
||||
fast-png: 6.4.0
|
||||
fflate: 0.8.3
|
||||
optionalDependencies:
|
||||
canvg: 3.0.11
|
||||
core-js: 3.49.0
|
||||
dompurify: 3.4.8
|
||||
html2canvas: 1.4.1
|
||||
|
||||
jwa@2.0.1:
|
||||
dependencies:
|
||||
buffer-equal-constant-time: 1.0.1
|
||||
@@ -10153,6 +10303,8 @@ snapshots:
|
||||
|
||||
pako@1.0.11: {}
|
||||
|
||||
pako@2.1.0: {}
|
||||
|
||||
parse-entities@4.0.2:
|
||||
dependencies:
|
||||
'@types/unist': 2.0.11
|
||||
@@ -10216,6 +10368,9 @@ snapshots:
|
||||
|
||||
peberminta@0.9.0: {}
|
||||
|
||||
performance-now@2.1.0:
|
||||
optional: true
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@4.0.3: {}
|
||||
@@ -10285,6 +10440,11 @@ snapshots:
|
||||
|
||||
quansync@0.2.11: {}
|
||||
|
||||
raf@3.4.1:
|
||||
dependencies:
|
||||
performance-now: 2.1.0
|
||||
optional: true
|
||||
|
||||
range-parser@1.2.1: {}
|
||||
|
||||
raw-body@2.5.2:
|
||||
@@ -10454,6 +10614,9 @@ snapshots:
|
||||
tiny-invariant: 1.3.3
|
||||
victory-vendor: 36.9.2
|
||||
|
||||
regenerator-runtime@0.13.11:
|
||||
optional: true
|
||||
|
||||
regex-recursion@6.0.2:
|
||||
dependencies:
|
||||
regex-utilities: 2.3.0
|
||||
@@ -10529,6 +10692,9 @@ snapshots:
|
||||
|
||||
resolve-pkg-maps@1.0.0: {}
|
||||
|
||||
rgbcolor@1.0.1:
|
||||
optional: true
|
||||
|
||||
robust-predicates@3.0.2: {}
|
||||
|
||||
rollup@4.52.4:
|
||||
@@ -10736,6 +10902,9 @@ snapshots:
|
||||
|
||||
stackback@0.0.2: {}
|
||||
|
||||
stackblur-canvas@2.7.0:
|
||||
optional: true
|
||||
|
||||
statuses@2.0.1: {}
|
||||
|
||||
std-env@3.9.0: {}
|
||||
@@ -10820,6 +10989,9 @@ snapshots:
|
||||
dependencies:
|
||||
copy-anything: 3.0.5
|
||||
|
||||
svg-pathdata@6.0.3:
|
||||
optional: true
|
||||
|
||||
tailwind-merge@3.3.1: {}
|
||||
|
||||
tailwindcss-animate@1.0.7(tailwindcss@4.1.14):
|
||||
@@ -10862,6 +11034,11 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- react-native-b4a
|
||||
|
||||
text-segmentation@1.0.3:
|
||||
dependencies:
|
||||
utrie: 1.0.2
|
||||
optional: true
|
||||
|
||||
tiny-invariant@1.3.3: {}
|
||||
|
||||
tinybench@2.9.0: {}
|
||||
@@ -11001,6 +11178,11 @@ snapshots:
|
||||
|
||||
utils-merge@1.0.1: {}
|
||||
|
||||
utrie@1.0.2:
|
||||
dependencies:
|
||||
base64-arraybuffer: 1.0.2
|
||||
optional: true
|
||||
|
||||
uuid@11.1.0: {}
|
||||
|
||||
uuid@8.3.2: {}
|
||||
|
||||
67
server/db.ts
67
server/db.ts
@@ -944,3 +944,70 @@ export async function updateBapHistoryPdfUrl(id: number, pdfUrl: string): Promis
|
||||
if (!db) return;
|
||||
await db.update(bapHistory).set({ pdfUrl }).where(eq(bapHistory.id, id));
|
||||
}
|
||||
|
||||
// ============= FREEPRO VENTILATION OPERATIONS =============
|
||||
|
||||
import {
|
||||
freeproImports,
|
||||
InsertFreeproImport,
|
||||
FreeproImport,
|
||||
freeproVentilationLines,
|
||||
InsertFreeproVentilationLine,
|
||||
FreeproVentilationLine,
|
||||
} from "../drizzle/schema";
|
||||
|
||||
/** Crée un import FreePro et ses lignes de ventilation */
|
||||
export async function createFreeproImport(
|
||||
data: InsertFreeproImport,
|
||||
lines: Omit<InsertFreeproVentilationLine, "importId">[]
|
||||
): Promise<number> {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
const result = await db.insert(freeproImports).values(data);
|
||||
const importId = (result as unknown as { insertId: number }).insertId;
|
||||
if (lines.length > 0) {
|
||||
await db.insert(freeproVentilationLines).values(
|
||||
lines.map((l) => ({ ...l, importId }))
|
||||
);
|
||||
}
|
||||
return importId;
|
||||
}
|
||||
|
||||
/** Récupère tous les imports FreePro d'un utilisateur (sans les lignes) */
|
||||
export async function getFreeproImportsByUser(userId: number): Promise<FreeproImport[]> {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
return db
|
||||
.select()
|
||||
.from(freeproImports)
|
||||
.where(eq(freeproImports.userId, userId))
|
||||
.orderBy(desc(freeproImports.annee), desc(freeproImports.mois));
|
||||
}
|
||||
|
||||
/** Récupère un import FreePro avec ses lignes */
|
||||
export async function getFreeproImportWithLines(
|
||||
importId: number
|
||||
): Promise<{ import: FreeproImport; lines: FreeproVentilationLine[] } | null> {
|
||||
const db = await getDb();
|
||||
if (!db) return null;
|
||||
const imports = await db
|
||||
.select()
|
||||
.from(freeproImports)
|
||||
.where(eq(freeproImports.id, importId))
|
||||
.limit(1);
|
||||
if (!imports[0]) return null;
|
||||
const lines = await db
|
||||
.select()
|
||||
.from(freeproVentilationLines)
|
||||
.where(eq(freeproVentilationLines.importId, importId))
|
||||
.orderBy(freeproVentilationLines.structure, freeproVentilationLines.type);
|
||||
return { import: imports[0], lines };
|
||||
}
|
||||
|
||||
/** Supprime un import FreePro et ses lignes */
|
||||
export async function deleteFreeproImport(importId: number): Promise<void> {
|
||||
const db = await getDb();
|
||||
if (!db) return;
|
||||
await db.delete(freeproVentilationLines).where(eq(freeproVentilationLines.importId, importId));
|
||||
await db.delete(freeproImports).where(eq(freeproImports.id, importId));
|
||||
}
|
||||
|
||||
167
server/freeproService.ts
Normal file
167
server/freeproService.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* FreePro Ventilation Service
|
||||
* Transforms a FreePro Excel invoice file into a ventilation summary (structure × type → TTC)
|
||||
*
|
||||
* Transformation rules (from Process Scripting facture freepro.txt):
|
||||
* 1. Colonne "script": =SI(OU(F2="Offre Freebox Pro";F2="Support Premium";ESTNUM(TROUVE("Remise première année";F2)));"Lien fibre";"Tél. mobile")
|
||||
* 2. Colonne "ttc": =ARRONDI(total_ht * 1.2, 2)
|
||||
* 3. Colonne "type": =SI(ESTNUM(TROUVE("Routeur 5G"; label_client)); "Lien 5G"; script)
|
||||
* 4. TCD: agrégation par (structure, type) → somme des TTC
|
||||
*/
|
||||
|
||||
import * as xlsx from "xlsx";
|
||||
|
||||
export interface FreeproSourceRow {
|
||||
ref_piece: string;
|
||||
date: Date | null;
|
||||
reference_service: string;
|
||||
label_client: string;
|
||||
structure: string;
|
||||
description: string;
|
||||
debut_factu: Date | null;
|
||||
fin_factu: Date | null;
|
||||
quantite: number;
|
||||
prix: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface FreeproVentilationResult {
|
||||
refPiece: string;
|
||||
moisLabel: string;
|
||||
annee: number;
|
||||
mois: number;
|
||||
nbLignes: number;
|
||||
totalTtc: number;
|
||||
lines: Array<{
|
||||
structure: string | null;
|
||||
type: string;
|
||||
montant: number; // TTC en euros
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Détermine la colonne "script" selon la description
|
||||
*/
|
||||
function getScript(description: string): string {
|
||||
if (!description) return "Tél. mobile";
|
||||
if (
|
||||
description === "Offre Freebox Pro" ||
|
||||
description === "Support Premium" ||
|
||||
description.includes("Remise première année")
|
||||
) {
|
||||
return "Lien fibre";
|
||||
}
|
||||
return "Tél. mobile";
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule le TTC : arrondi(total_ht * 1.2, 2)
|
||||
*/
|
||||
function getTtc(totalHt: number): number {
|
||||
return Math.round(totalHt * 1.2 * 100) / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Détermine le "type" final selon label_client et script
|
||||
*/
|
||||
function getType(labelClient: string, script: string): string {
|
||||
if (labelClient && labelClient.includes("Routeur 5G")) {
|
||||
return "Lien 5G";
|
||||
}
|
||||
return script;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse et transforme un fichier Excel FreePro (Buffer)
|
||||
* Retourne les données de ventilation agrégées
|
||||
*/
|
||||
export function processFreeproExcel(
|
||||
buffer: Buffer,
|
||||
moisLabel: string
|
||||
): FreeproVentilationResult {
|
||||
const wb = xlsx.read(buffer, { type: "buffer", cellDates: true });
|
||||
const ws = wb.Sheets[wb.SheetNames[0]];
|
||||
const rawRows = xlsx.utils.sheet_to_json<Record<string, unknown>>(ws, {
|
||||
header: [
|
||||
"ref_piece",
|
||||
"date",
|
||||
"reference_service",
|
||||
"label_client",
|
||||
"structure",
|
||||
"description",
|
||||
"debut_factu",
|
||||
"fin_factu",
|
||||
"quantite",
|
||||
"prix",
|
||||
"total",
|
||||
],
|
||||
range: 1, // skip header row
|
||||
defval: null,
|
||||
});
|
||||
|
||||
if (rawRows.length === 0) {
|
||||
throw new Error("Le fichier Excel est vide ou ne contient pas de données.");
|
||||
}
|
||||
|
||||
// Extraire ref_piece et date du mois depuis la première ligne
|
||||
const firstRow = rawRows[0];
|
||||
const refPiece = String(firstRow.ref_piece || "");
|
||||
|
||||
// Parser moisLabel "MM/YYYY"
|
||||
const [moisStr, anneeStr] = moisLabel.split("/");
|
||||
const mois = parseInt(moisStr, 10);
|
||||
const annee = parseInt(anneeStr, 10);
|
||||
|
||||
// Agrégation par (structure, type) → somme TTC en centimes (pour éviter les flottants)
|
||||
const aggregation = new Map<string, number>();
|
||||
|
||||
let nbLignes = 0;
|
||||
for (const row of rawRows) {
|
||||
if (!row.ref_piece) continue;
|
||||
nbLignes++;
|
||||
|
||||
const description = String(row.description || "");
|
||||
const labelClient = String(row.label_client || "");
|
||||
const structure = row.structure ? String(row.structure) : null;
|
||||
const totalHt = parseFloat(String(row.total || "0").replace(",", ".")) || 0;
|
||||
|
||||
const script = getScript(description);
|
||||
const ttc = getTtc(totalHt);
|
||||
const type = getType(labelClient, script);
|
||||
|
||||
const key = `${structure ?? ""}|||${type}`;
|
||||
aggregation.set(key, (aggregation.get(key) || 0) + Math.round(ttc * 100));
|
||||
}
|
||||
|
||||
// Construire les lignes de résultat
|
||||
const lines: FreeproVentilationResult["lines"] = [];
|
||||
for (const [key, centimes] of Array.from(aggregation.entries())) {
|
||||
const [structureRaw, type] = key.split("|||");
|
||||
lines.push({
|
||||
structure: structureRaw === "" ? null : structureRaw,
|
||||
type,
|
||||
montant: centimes / 100,
|
||||
});
|
||||
}
|
||||
|
||||
// Trier par structure (null en dernier) puis type
|
||||
lines.sort((a, b) => {
|
||||
const sa = a.structure ?? "\uFFFF"; // null → fin de liste
|
||||
const sb = b.structure ?? "\uFFFF";
|
||||
if (sa !== sb) return sa.localeCompare(sb);
|
||||
return a.type.localeCompare(b.type);
|
||||
});
|
||||
|
||||
const totalTtc =
|
||||
Math.round(lines.reduce((sum, l) => sum + l.montant, 0) * 100) / 100;
|
||||
|
||||
return {
|
||||
refPiece,
|
||||
moisLabel,
|
||||
annee,
|
||||
mois,
|
||||
nbLignes,
|
||||
totalTtc,
|
||||
lines,
|
||||
};
|
||||
}
|
||||
@@ -85,6 +85,13 @@ import { drawBapCartouche } from "./bapCartouche";
|
||||
import { startEmailImportService, stopEmailImportService, isEmailImportServiceRunning, triggerEmailCheck, testImapConnection } from "./emailImportService";
|
||||
import { startFolderImportService, stopFolderImportService, isFolderImportServiceRunning } from "./folderImportService";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { processFreeproExcel } from "./freeproService";
|
||||
import {
|
||||
createFreeproImport,
|
||||
getFreeproImportsByUser,
|
||||
getFreeproImportWithLines,
|
||||
deleteFreeproImport,
|
||||
} from "./db";
|
||||
|
||||
// Admin-only procedure
|
||||
const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
|
||||
@@ -2197,5 +2204,70 @@ export const appRouter = router({
|
||||
return { success: true };
|
||||
}),
|
||||
}),
|
||||
|
||||
// ============= FREEPRO VENTILATION =============
|
||||
freepro: router({
|
||||
/** Importe un fichier Excel FreePro et calcule la ventilation */
|
||||
import: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
moisLabel: z.string().regex(/^\d{2}\/\d{4}$/, "Format MM/YYYY requis"),
|
||||
fileName: z.string(),
|
||||
fileBase64: z.string(), // fichier Excel encodé en base64
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const buffer = Buffer.from(input.fileBase64, "base64");
|
||||
const result = processFreeproExcel(buffer, input.moisLabel);
|
||||
|
||||
const importId = await createFreeproImport(
|
||||
{
|
||||
userId: ctx.user.id,
|
||||
moisLabel: result.moisLabel,
|
||||
annee: result.annee,
|
||||
mois: result.mois,
|
||||
refPiece: result.refPiece || null,
|
||||
fileName: input.fileName,
|
||||
nbLignes: result.nbLignes,
|
||||
totalTtc: result.totalTtc.toFixed(2),
|
||||
},
|
||||
result.lines.map((l) => ({
|
||||
structure: l.structure ?? null,
|
||||
type: l.type,
|
||||
montantCentimes: Math.round(l.montant * 100),
|
||||
}))
|
||||
);
|
||||
|
||||
return { importId, ...result };
|
||||
}),
|
||||
|
||||
/** Liste tous les imports FreePro de l'utilisateur */
|
||||
list: protectedProcedure.query(async ({ ctx }) => {
|
||||
return getFreeproImportsByUser(ctx.user.id);
|
||||
}),
|
||||
|
||||
/** Récupère un import FreePro avec ses lignes de ventilation */
|
||||
getById: protectedProcedure
|
||||
.input(z.object({ id: z.number() }))
|
||||
.query(async ({ input, ctx }) => {
|
||||
const data = await getFreeproImportWithLines(input.id);
|
||||
if (!data) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
if (data.import.userId !== ctx.user.id)
|
||||
throw new TRPCError({ code: "FORBIDDEN" });
|
||||
return data;
|
||||
}),
|
||||
|
||||
/** Supprime un import FreePro */
|
||||
delete: protectedProcedure
|
||||
.input(z.object({ id: z.number() }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const data = await getFreeproImportWithLines(input.id);
|
||||
if (!data) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
if (data.import.userId !== ctx.user.id)
|
||||
throw new TRPCError({ code: "FORBIDDEN" });
|
||||
await deleteFreeproImport(input.id);
|
||||
return { success: true };
|
||||
}),
|
||||
}),
|
||||
});
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
17
todo.md
17
todo.md
@@ -642,5 +642,18 @@
|
||||
- [x] Ajouter emailImportAuthMode dans le schéma Zod de importSettings.update
|
||||
- [x] Ajouter sélecteur mode auth (basic/oauth2) dans ImportSettings.tsx
|
||||
- [x] Ajouter bouton "Tester la connexion IMAP" dans ImportSettings.tsx
|
||||
- [ ] Déployer sur recette et production
|
||||
- [ ] Appliquer migration emailImportAuthMode sur recette et production
|
||||
- [x] Déployer sur recette (git push + docker compose up --build)
|
||||
- [x] Appliquer migration emailImportAuthMode sur recette (colonne ajoutée en DB)
|
||||
- [ ] Déployer sur production (en attente de validation recette)
|
||||
|
||||
## Module Ventilation FreePro
|
||||
|
||||
- [x] Schéma DB : tables freeproImports et freeproVentilationLines
|
||||
- [x] Migration DB appliquée (pnpm db:push)
|
||||
- [x] Service freeproService.ts : transformation Excel → ventilation (script/TTC/type)
|
||||
- [x] Helpers DB dans db.ts : createFreeproImport, getFreeproImportsByUser, getFreeproImportWithLines, deleteFreeproImport
|
||||
- [x] Procédures tRPC : freepro.import, freepro.list, freepro.getById, freepro.delete
|
||||
- [x] Page VentilationFreePro.tsx : import drag&drop, tableau de ventilation, historique, export PDF
|
||||
- [x] Menu "Ventilations > FreePro" ajouté dans DashboardLayout
|
||||
- [x] Route /ventilation-freepro ajoutée dans App.tsx
|
||||
- [ ] Déploiement sur recette
|
||||
|
||||
Reference in New Issue
Block a user