SPB Git forge

spb/valoplex

Public

ValoPlex — moteur d'évaluation spécialisé pour les plex au Québec, petit frère de Vrai-Prix.

11commits 1branches 0releases
2.4 MBsize
maindefault branch
20 days agolast push
TypeScript 91.9% Python 6% CSS 2.1%
12.6 KB · 226 lines tsx
Raw Blame History
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