// Auteur : Simon-Pierre Boucher — contact@spboucher.ai /** * v3 — constructeur de rapports personnalisés ValoPlex (ka-stats SPEC §3bis, * port maison au style ValoPlex, bilingue fr/en). Compose un PDF bloc par * bloc : catalogue (GET /api/stats/catalog), rendu au choix, ordre libre, * modèles sauvegardés en localStorage (clé ka-stats-rapports). */ "use client"; import { useEffect, useState } from "react"; import { useLang } from "./LangContext"; type CatalogBlock = { key: string; section: string; title: string; renders: string[]; default_render: string; count?: number; }; type Sel = { key: string; render: string }; type Tpl = { name: string; title: string; blocks: Sel[] }; const TPL_KEY = "ka-stats-rapports"; const RENDER_FR: Record = { line: "Courbe", area: "Aire", bar: "Barres verticales", bars: "Barres horizontales", donut: "Anneau", cards: "Cartes", table: "Tableau", }; const RENDER_EN: Record = { line: "Line", area: "Area", bar: "Vertical bars", bars: "Horizontal bars", donut: "Donut", cards: "Cards", table: "Table", }; const SECTION_FR: Record = { kpis: "Indicateurs", series: "Évolution", breakdowns: "Répartitions", geo: "Géographie", tables: "Tableaux", records: "Records", }; const SECTION_EN: Record = { kpis: "Indicators", series: "Trends", breakdowns: "Breakdowns", geo: "Geography", tables: "Tables", records: "Records", }; const loadTpls = (): Tpl[] => { try { return JSON.parse(localStorage.getItem(TPL_KEY) ?? "[]"); } catch { return []; } }; const saveTpls = (t: Tpl[]) => { try { localStorage.setItem(TPL_KEY, JSON.stringify(t)); } catch { /* privé */ } }; export default function ReportBuilder() { const { lang } = useLang(); const fr = lang === "fr"; const RL = fr ? RENDER_FR : RENDER_EN; const SL = fr ? SECTION_FR : SECTION_EN; const [open, setOpen] = useState(false); const [cat, setCat] = useState(null); const [sel, setSel] = useState([]); const [title, setTitle] = useState(""); const [busy, setBusy] = useState(false); const [err, setErr] = useState(""); const [tpls, setTpls] = useState([]); useEffect(() => { if (!open) return; setErr(""); setTpls(loadTpls()); fetch("/api/stats/catalog") .then((r) => (r.ok ? r.json() : Promise.reject(r.status))) .then((d) => setCat(d.blocks ?? [])) .catch(() => setErr(fr ? "Catalogue indisponible — réessayez plus tard." : "Catalog unavailable — try again later.")); const esc = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); }; document.addEventListener("keydown", esc); const prev = document.body.style.overflow; document.body.style.overflow = "hidden"; return () => { document.removeEventListener("keydown", esc); document.body.style.overflow = prev; }; }, [open, fr]); const generate = async () => { if (busy || !sel.length) return; setBusy(true); setErr(""); try { const r = await fetch("/api/stats/report/custom", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title, blocks: sel }), }); if (!r.ok) throw new Error(String(r.status)); const blob = await r.blob(); const m = (r.headers.get("Content-Disposition") ?? "").match(/filename="?([^";]+)/); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = m ? m[1] : "valoplex-rapport-personnalise.pdf"; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 4000); } catch { setErr(fr ? "La génération a échoué — réessayez." : "Generation failed — try again."); } setBusy(false); }; const groups: [string, CatalogBlock[]][] = []; for (const b of cat ?? []) { const g = groups.find(([s]) => s === b.section); if (g) g[1].push(b); else groups.push([b.section, [b]]); } const selKeys = new Set(sel.map((s) => s.key)); return ( <> {open && (
{ if (e.target === e.currentTarget) setOpen(false); }} className="fixed inset-0 z-[900] flex items-start justify-center overflow-auto bg-[rgba(20,24,20,0.5)] px-3 py-[4vh]">
{fr ? "Rapport personnalisé" : "Custom report"}

{fr ? "Blocs disponibles" : "Available blocks"} ({cat?.length ?? "…"})

{!cat && !err &&

{fr ? "Chargement du catalogue…" : "Loading catalog…"}

} {groups.map(([secId, bs]) => (

{SL[secId] ?? secId}

{bs.map((b) => (
{b.title}
))}
))}

{fr ? "Composition du rapport" : "Report composition"} ({sel.length})

setTitle(e.target.value)} placeholder={fr ? "Ex. : Revue du parc 2026" : "E.g.: 2026 portfolio review"} className="mb-3 mt-1 w-full rounded-lg border-[1.5px] border-ink bg-white px-3 py-2 text-sm" /> {sel.length ? sel.map((s, i) => { const b = (cat ?? []).find((x) => x.key === s.key); return (
{i + 1}. {b?.title ?? s.key} {b && b.renders.length > 1 ? ( ) : {RL[s.render] ?? s.render}}
); }) : (
{fr ? "Aucun bloc — ajoutez des blocs depuis la colonne de gauche, ou chargez un modèle ci-dessous." : "No blocks yet — add blocks from the left column, or load a template below."}
)}

{err &&

{err}

}
)} ); }