ValoPlex — moteur d'évaluation spécialisé pour les plex au Québec, petit frère de Vrai-Prix.
TypeScript 91.9%
Python 6%
CSS 2.1%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2/**3 * v3 — constructeur de rapports personnalisés ValoPlex (ka-stats SPEC §3bis,4 * port maison au style ValoPlex, bilingue fr/en). Compose un PDF bloc par5 * bloc : catalogue (GET /api/stats/catalog), rendu au choix, ordre libre,6 * modèles sauvegardés en localStorage (clé ka-stats-rapports).7 */8"use client";9import { useEffect, useState } from "react";10import { useLang } from "./LangContext";1112type CatalogBlock = {13 key: string; section: string; title: string;14 renders: string[]; default_render: string; count?: number;15};16type Sel = { key: string; render: string };17type Tpl = { name: string; title: string; blocks: Sel[] };1819const TPL_KEY = "ka-stats-rapports";20const RENDER_FR: Record<string, string> = {21 line: "Courbe", area: "Aire", bar: "Barres verticales", bars: "Barres horizontales",22 donut: "Anneau", cards: "Cartes", table: "Tableau",23};24const RENDER_EN: Record<string, string> = {25 line: "Line", area: "Area", bar: "Vertical bars", bars: "Horizontal bars",26 donut: "Donut", cards: "Cards", table: "Table",27};28const SECTION_FR: Record<string, string> = {29 kpis: "Indicateurs", series: "Évolution", breakdowns: "Répartitions",30 geo: "Géographie", tables: "Tableaux", records: "Records",31};32const SECTION_EN: Record<string, string> = {33 kpis: "Indicators", series: "Trends", breakdowns: "Breakdowns",34 geo: "Geography", tables: "Tables", records: "Records",35};3637const loadTpls = (): Tpl[] => {38 try { return JSON.parse(localStorage.getItem(TPL_KEY) ?? "[]"); } catch { return []; }39};40const saveTpls = (t: Tpl[]) => { try { localStorage.setItem(TPL_KEY, JSON.stringify(t)); } catch { /* privé */ } };4142export default function ReportBuilder() {43 const { lang } = useLang();44 const fr = lang === "fr";45 const RL = fr ? RENDER_FR : RENDER_EN;46 const SL = fr ? SECTION_FR : SECTION_EN;47 const [open, setOpen] = useState(false);48 const [cat, setCat] = useState<CatalogBlock[] | null>(null);49 const [sel, setSel] = useState<Sel[]>([]);50 const [title, setTitle] = useState("");51 const [busy, setBusy] = useState(false);52 const [err, setErr] = useState("");53 const [tpls, setTpls] = useState<Tpl[]>([]);5455 useEffect(() => {56 if (!open) return;57 setErr("");58 setTpls(loadTpls());59 fetch("/api/stats/catalog")60 .then((r) => (r.ok ? r.json() : Promise.reject(r.status)))61 .then((d) => setCat(d.blocks ?? []))62 .catch(() => setErr(fr ? "Catalogue indisponible — réessayez plus tard." : "Catalog unavailable — try again later."));63 const esc = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); };64 document.addEventListener("keydown", esc);65 const prev = document.body.style.overflow;66 document.body.style.overflow = "hidden";67 return () => { document.removeEventListener("keydown", esc); document.body.style.overflow = prev; };68 }, [open, fr]);6970 const generate = async () => {71 if (busy || !sel.length) return;72 setBusy(true); setErr("");73 try {74 const r = await fetch("/api/stats/report/custom", {75 method: "POST", headers: { "Content-Type": "application/json" },76 body: JSON.stringify({ title, blocks: sel }),77 });78 if (!r.ok) throw new Error(String(r.status));79 const blob = await r.blob();80 const m = (r.headers.get("Content-Disposition") ?? "").match(/filename="?([^";]+)/);81 const a = document.createElement("a");82 a.href = URL.createObjectURL(blob);83 a.download = m ? m[1] : "valoplex-rapport-personnalise.pdf";84 document.body.appendChild(a); a.click(); a.remove();85 setTimeout(() => URL.revokeObjectURL(a.href), 4000);86 } catch {87 setErr(fr ? "La génération a échoué — réessayez." : "Generation failed — try again.");88 }89 setBusy(false);90 };9192 const groups: [string, CatalogBlock[]][] = [];93 for (const b of cat ?? []) {94 const g = groups.find(([s]) => s === b.section);95 if (g) g[1].push(b); else groups.push([b.section, [b]]);96 }97 const selKeys = new Set(sel.map((s) => s.key));9899 return (100 <>101 <button type="button" className="btn btn-ghost mt-5 ml-0 sm:ml-3" onClick={() => setOpen(true)}>102 🛠 {fr ? "Rapport personnalisé" : "Custom report"}103 </button>104 {open && (105 <div role="dialog" aria-modal="true"106 onClick={(e) => { if (e.target === e.currentTarget) setOpen(false); }}107 className="fixed inset-0 z-[900] flex items-start justify-center overflow-auto bg-[rgba(20,24,20,0.5)] px-3 py-[4vh]">108 <div className="flex max-h-[92vh] w-full max-w-[960px] flex-col rounded-[10px] border-[1.5px] border-ink bg-paper text-left shadow-[10px_10px_0_rgba(20,24,20,0.25)]">109 <div className="flex items-center justify-between gap-3 border-b border-ink/20 px-5 py-4">110 <b className="vp-display text-lg">{fr ? "Rapport personnalisé" : "Custom report"}</b>111 <button type="button" className="btn btn-ghost" onClick={() => setOpen(false)}>✕ {fr ? "Fermer" : "Close"}</button>112 </div>113 <div className="grid flex-1 grid-cols-1 overflow-auto md:grid-cols-2">114 <div className="min-w-0 border-b border-ink/15 p-5 md:border-b-0 md:border-r">115 <h3 className="vp-mono mb-2 text-[10.5px] uppercase tracking-[0.08em] text-ink-3">116 {fr ? "Blocs disponibles" : "Available blocks"} ({cat?.length ?? "…"})117 </h3>118 {!cat && !err && <p className="text-sm text-ink-3">{fr ? "Chargement du catalogue…" : "Loading catalog…"}</p>}119 {groups.map(([secId, bs]) => (120 <div key={secId}>121 <p className="vp-mono mb-1.5 mt-3 text-[10px] font-bold uppercase tracking-[0.08em] text-ink-2">{SL[secId] ?? secId}</p>122 {bs.map((b) => (123 <div key={b.key}124 className={`mb-1.5 flex items-center justify-between gap-2 rounded-lg border border-ink/25 px-2.5 py-1.5 text-[13px] ${selKeys.has(b.key) ? "opacity-45" : ""}`}>125 <span className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap" title={b.title}>{b.title}</span>126 <button type="button" className="btn btn-ghost !min-h-0 !px-2.5 !py-1"127 onClick={() => setSel((s) => s.some((x) => x.key === b.key && x.render === b.default_render) ? s : [...s, { key: b.key, render: b.default_render }])}128 aria-label={`+ ${b.title}`}>+</button>129 </div>130 ))}131 </div>132 ))}133 </div>134 <div className="min-w-0 p-5">135 <h3 className="vp-mono mb-2 text-[10.5px] uppercase tracking-[0.08em] text-ink-3">136 {fr ? "Composition du rapport" : "Report composition"} ({sel.length})137 </h3>138 <label className="vp-mono text-[10px] uppercase tracking-[0.06em] text-ink-3" htmlFor="rb-title">139 {fr ? "Titre du rapport" : "Report title"}140 </label>141 <input id="rb-title" maxLength={80} value={title} onChange={(e) => setTitle(e.target.value)}142 placeholder={fr ? "Ex. : Revue du parc 2026" : "E.g.: 2026 portfolio review"}143 className="mb-3 mt-1 w-full rounded-lg border-[1.5px] border-ink bg-white px-3 py-2 text-sm" />144 {sel.length ? sel.map((s, i) => {145 const b = (cat ?? []).find((x) => x.key === s.key);146 return (147 <div key={`${s.key}:${s.render}:${i}`}148 className="mb-1.5 flex items-center gap-2 rounded-lg border-[1.5px] border-ink bg-white px-2.5 py-2 text-[13px]">149 <button type="button" disabled={i === 0} className="disabled:opacity-25"150 onClick={() => setSel((xs) => { const n = [...xs]; [n[i - 1], n[i]] = [n[i], n[i - 1]]; return n; })}151 aria-label={fr ? "Monter" : "Move up"}>▲</button>152 <button type="button" disabled={i === sel.length - 1} className="disabled:opacity-25"153 onClick={() => setSel((xs) => { const n = [...xs]; [n[i + 1], n[i]] = [n[i], n[i + 1]]; return n; })}154 aria-label={fr ? "Descendre" : "Move down"}>▼</button>155 <span className="min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap" title={b?.title ?? s.key}>156 <b>{i + 1}.</b> {b?.title ?? s.key}157 </span>158 {b && b.renders.length > 1 ? (159 <select value={s.render}160 onChange={(e) => setSel((xs) => xs.map((x, j) => (j === i ? { ...x, render: e.target.value } : x)))}161 className="max-w-[140px] rounded-md border border-ink/40 bg-paper px-1.5 py-1 text-xs"162 aria-label={fr ? "Rendu" : "Render"}>163 {b.renders.map((r) => <option key={r} value={r}>{RL[r] ?? r}</option>)}164 </select>165 ) : <span className="vp-mono text-[10px] uppercase text-ink-3">{RL[s.render] ?? s.render}</span>}166 <button type="button" onClick={() => setSel((xs) => xs.filter((_, j) => j !== i))}167 aria-label={fr ? "Retirer" : "Remove"}>✕</button>168 </div>169 );170 }) : (171 <div className="rounded-lg border border-dashed border-ink/40 p-4 text-center text-[13px] text-ink-3">172 {fr ? "Aucun bloc — ajoutez des blocs depuis la colonne de gauche, ou chargez un modèle ci-dessous."173 : "No blocks yet — add blocks from the left column, or load a template below."}174 </div>175 )}176 <p className="mt-2.5 flex gap-2">177 <button type="button" className="btn btn-ghost" disabled={!cat?.length}178 onClick={() => setSel((cat ?? []).map((b) => ({ key: b.key, render: b.default_render })))}>179 {fr ? "Tout ajouter" : "Add all"}180 </button>181 <button type="button" className="btn btn-ghost" disabled={!sel.length} onClick={() => setSel([])}>182 {fr ? "Vider" : "Clear"}183 </button>184 </p>185 {err && <p className="mt-1.5 text-[12.5px] text-[#b3423a]">{err}</p>}186 </div>187 </div>188 <div className="flex flex-wrap items-center justify-between gap-3 border-t border-ink/20 px-5 py-4">189 <span className="flex flex-wrap items-center gap-2">190 <select value="" aria-label={fr ? "Modèles sauvegardés" : "Saved templates"}191 className="max-w-[200px] rounded-lg border-[1.5px] border-ink bg-white px-2 py-1.5 text-sm"192 onChange={(e) => {193 const t = tpls[Number(e.target.value)];194 if (!t) return;195 setTitle(t.title || t.name);196 setSel((t.blocks ?? []).filter((x) => (cat ?? []).some((b) => b.key === x.key)).map((x) => ({ ...x })));197 }}>198 <option value="">{fr ? "Modèles" : "Templates"} ({tpls.length})…</option>199 {tpls.map((t, i) => <option key={t.name} value={i}>{t.name}</option>)}200 </select>201 <button type="button" className="btn btn-ghost" disabled={!sel.length}202 onClick={() => {203 const name = window.prompt(fr ? "Nom du modèle :" : "Template name:", title || (fr ? "Mon rapport" : "My report"));204 if (!name) return;205 const next = [...tpls.filter((t) => t.name !== name), { name, title, blocks: sel.map((x) => ({ ...x })) }];206 setTpls(next); saveTpls(next);207 }}>💾 {fr ? "Sauvegarder" : "Save"}</button>208 <button type="button" className="btn btn-ghost" disabled={!tpls.length}209 onClick={() => {210 const name = window.prompt((fr ? "Nom du modèle à supprimer :\n" : "Template to delete:\n") + tpls.map((t) => `· ${t.name}`).join("\n"));211 if (!name) return;212 const next = tpls.filter((t) => t.name !== name);213 setTpls(next); saveTpls(next);214 }}>🗑 {fr ? "Supprimer" : "Delete"}</button>215 </span>216 <button type="button" className="btn btn-primary" disabled={!sel.length || busy} onClick={generate}>217 {busy ? (fr ? "Génération…" : "Generating…") : `⬇ ${fr ? "Générer le PDF" : "Generate PDF"}`}218 </button>219 </div>220 </div>221 </div>222 )}223 </>224 );225}226