SPB Git forge

spb/ka-ui

Public
30commits 1branches 0releases
145.7 MBsize
maindefault branch
27 days agolast push
Python 33.5% JavaScript 30.1% TypeScript 25% CSS 10% Shell 1.4%
52.1 KB · 965 lines tsx
Raw Blame History
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// ka-ui/stats/kacharts.tsx — kit de graphiques SVG du module Stats commun3// Groupe KA (zéro dépendance, React 18+). Style : design system ka-ui4// (bordures encre, accent de la plateforme via var(--accent)).5// v2 — composants : KpiCard (+sparkline), PeriodSelector, LineChart (ligne/6// aire, infobulle + légende cliquable + comparaison N-1), MultiLineChart7// (≤4 séries, pointillés distincts = jamais la couleur seule), VBarChart8// (barres verticales / histogrammes), StackedBarChart, BarChart (horizontal,9// deltas), Donut, GaugeCard, CalendarHeatmap, HourHeatmap (7×24),10// StatSummary (min/max/moy/méd/σ), DataTable (tri/recherche/pagination),11// RecordCard, PdfButton (menu de rapports), EmptyBlock, Fraicheur.12import { useEffect, useMemo, useRef, useState } from "react";1314/* ---------- types (contrat SPEC.md v2) ---------- */15export type Kpi = {16  id: string; label: string; value: number | string; unit?: string;17  delta_pct?: number | null; direction?: "up" | "down";18  spark?: Point[]; help?: string;19};20export type Point = { t: string; v: number };21export type Serie = {22  id: string; title: string; unit?: string;23  kind?: "line" | "bar" | "area";24  points: Point[]; compare?: Point[];25};26export type MultiSerie = {27  id: string; title: string; unit?: string;28  series: { label: string; points: Point[] }[]; // ≤ 4 séries29};30export type StackedSerie = {31  id: string; title: string; unit?: string;32  keys: string[]; points: { t: string; values: number[] }[];33};34export type BreakItem = { label: string; value: number; delta_pct?: number | null };35export type Distribution = { id: string; title: string; unit?: string; bins: { label: string; value: number }[] };36export type Gauge = { id: string; label: string; value: number; max: number; unit?: string; help?: string };37export type HourCell = { dow: number; hour: number; value: number }; // dow 0=lun … 6=dim38export type TableSpec = { id: string; title: string; columns: string[]; rows: (string | number)[][] };39export type RecordFact = { label: string; value: string; date?: string };4041export const PERIODS: { id: string; label: string }[] = [42  { id: "auj", label: "Aujourd'hui" },43  { id: "7j", label: "7 jours" },44  { id: "30j", label: "30 jours" },45  { id: "3m", label: "3 mois" },46  { id: "6m", label: "6 mois" },47  { id: "12m", label: "12 mois" },48  { id: "annee", label: "Année en cours" },49  { id: "tout", label: "Tout" },50];5152export const REPORT_MODES: { id: string; label: string; desc: string }[] = [53  { id: "complet", label: "Rapport complet", desc: "Toutes les sections — KPI, tendances, répartitions, tableaux, records" },54  { id: "synthese", label: "Synthèse exécutive", desc: "2 pages — indicateurs clés et faits marquants" },55  { id: "tendances", label: "Tendances & évolution", desc: "Courbes, comparaisons N-1 et statistiques de séries" },56  { id: "repartitions", label: "Répartitions & géographie", desc: "Catégories, distributions, régions et activité" },57  { id: "donnees", label: "Données détaillées", desc: "Tous les tableaux, en version longue" },58];5960export const fmtInt = (n: number) => n.toLocaleString("fr-CA");61export const fmtNum = (n: number) =>62  Number.isInteger(n) ? fmtInt(n) : n.toLocaleString("fr-CA", { maximumFractionDigits: 2 });63const fmtPct = (n: number) => `${n >= 0 ? "+" : ""}${fmtNum(n)} %`;6465/* Styles des séries multiples : couleur + motif de trait (l'identité n'est66   jamais portée par la couleur seule — règle d'accessibilité). */67const MULTI_STYLES = [68  { stroke: "var(--accent)", dash: undefined, width: 2.4 },69  { stroke: "var(--ink)", dash: undefined, width: 1.6 },70  { stroke: "var(--accent-deep, var(--accent))", dash: "6 3", width: 2 },71  { stroke: "var(--ink-3)", dash: "2 3", width: 2 },72];7374/* ---------- KPI (+ sparkline) ---------- */75export function KpiCard({ k }: { k: Kpi }) {76  const up = (k.direction ?? ((k.delta_pct ?? 0) >= 0 ? "up" : "down")) === "up";77  const sp = (k.spark ?? []).filter((p) => typeof p.v === "number");78  const spark = useMemo(() => {79    if (sp.length < 2) return null;80    const w = 120, h = 30;81    const vmax = Math.max(...sp.map((p) => p.v));82    const vmin = Math.min(...sp.map((p) => p.v));83    const rng = vmax - vmin || 1;84    const X = (i: number) => (w * i) / (sp.length - 1);85    const Y = (v: number) => 2 + (h - 4) * (1 - (v - vmin) / rng);86    const d = sp.map((p, i) => `${i ? "L" : "M"}${X(i).toFixed(1)},${Y(p.v).toFixed(1)}`).join("");87    return { w, h, d, area: `${d}L${w},${h}L0,${h}Z` };88  }, [k.spark]);89  return (90    <article className="card" style={{ padding: "14px 16px", minWidth: 0 }} title={k.help}>91      <p style={{ margin: 0, fontFamily: "var(--font-display)", fontWeight: 700, fontSize: "clamp(22px,2.4vw,30px)", letterSpacing: "-0.02em" }}>92        {typeof k.value === "number" ? fmtNum(k.value) : k.value}93        {k.unit ? <span style={{ fontSize: "0.6em", color: "var(--ink-2)" }}> {k.unit}</span> : null}94      </p>95      <p className="klabel" style={{ margin: "6px 0 0" }}>{k.label}</p>96      <div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-end", gap: 8 }}>97        {k.delta_pct !== undefined && k.delta_pct !== null ? (98          <p style={{ margin: "8px 0 0", fontFamily: "var(--font-mono)", fontSize: 11, fontWeight: 700, color: up ? "var(--green)" : "var(--danger)" }}>99            {up ? "▲" : "▼"} {fmtPct(k.delta_pct)} <span style={{ color: "var(--ink-3)", fontWeight: 500 }}>vs période préc.</span>100          </p>101        ) : <span />}102        {spark && (103          <svg viewBox={`0 0 ${spark.w} ${spark.h}`} style={{ width: 96, height: 24, flex: "none" }} aria-hidden="true">104            <path d={spark.area} fill="var(--accent)" opacity={0.14} />105            <path d={spark.d} fill="none" stroke="var(--accent)" strokeWidth={1.8} />106          </svg>107        )}108      </div>109    </article>110  );111}112113/* ---------- Sélecteur de période ---------- */114export function PeriodSelector({115  value, onChange, custom, onCustom,116}: {117  value: string; onChange: (p: string) => void;118  custom?: { from: string; to: string }; onCustom?: (from: string, to: string) => void;119}) {120  return (121    <div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>122      {PERIODS.map((p) => (123        <button key={p.id} type="button" onClick={() => onChange(p.id)}124          className="chip" aria-pressed={value === p.id}125          style={{ cursor: "pointer", minHeight: 44, background: value === p.id ? "var(--accent)" : "var(--surface)", color: value === p.id ? "var(--on-accent)" : "var(--ink)" }}>126          {p.label}127        </button>128      ))}129      {onCustom && (130        <span style={{ display: "inline-flex", gap: 6, alignItems: "center" }}>131          <input className="input" type="date" style={{ width: 150 }} value={custom?.from ?? ""} aria-label="Du"132            onChange={(e) => onCustom(e.target.value, custom?.to ?? "")} />133          <span className="klabel">au</span>134          <input className="input" type="date" style={{ width: 150 }} value={custom?.to ?? ""} aria-label="Au"135            onChange={(e) => onCustom(custom?.from ?? "", e.target.value)} />136        </span>137      )}138    </div>139  );140}141142/* ---------- Courbe / aire ---------- */143export function LineChart({ serie, height = 240 }: { serie: Serie; height?: number }) {144  const [hide, setHide] = useState<{ cur: boolean; cmp: boolean }>({ cur: false, cmp: false });145  const [hover, setHover] = useState<number | null>(null);146  const W = 720, H = height, PL = 54, PR = 10, PT = 14, PB = 26;147  const pts = serie.points ?? [];148  if (serie.kind === "bar") return <VBarChart serie={serie} height={height} />;149  if (pts.length < 2) return <EmptyBlock title={serie.title} />;150  const all = [...(hide.cur ? [] : pts), ...(!hide.cmp && serie.compare ? serie.compare : [])];151  const vmax = Math.max(...all.map((p) => p.v), 1);152  const vmin = Math.min(0, ...all.map((p) => p.v));153  const X = (i: number, n: number) => PL + ((W - PL - PR) * i) / (n - 1);154  const Y = (v: number) => PT + (H - PT - PB) * (1 - (v - vmin) / (vmax - vmin || 1));155  const path = (s: Point[]) => s.map((p, i) => `${i ? "L" : "M"}${X(i, s.length)},${Y(p.v)}`).join("");156  const hi = hover !== null ? Math.min(pts.length - 1, Math.max(0, hover)) : null;157  return (158    <figure className="card" style={{ margin: 0, padding: 16 }}>159      <figcaption style={{ display: "flex", justifyContent: "space-between", flexWrap: "wrap", gap: 8 }}>160        <b style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{serie.title}</b>161        <span style={{ display: "flex", gap: 10 }}>162          <LegendChip label="Période courante" color="var(--accent)" off={hide.cur} onClick={() => setHide((h) => ({ ...h, cur: !h.cur }))} />163          {serie.compare && <LegendChip label="Période comparée" color="var(--ink-3)" dashed off={hide.cmp} onClick={() => setHide((h) => ({ ...h, cmp: !h.cmp }))} />}164        </span>165      </figcaption>166      <svg viewBox={`0 0 ${W} ${H}`} style={{ width: "100%", height: "auto", marginTop: 10, touchAction: "pan-y" }} role="img" aria-label={serie.title}167        onMouseMove={(e) => {168          const r = (e.currentTarget as SVGSVGElement).getBoundingClientRect();169          const fx = ((e.clientX - r.left) / r.width) * W;170          setHover(Math.round(((fx - PL) / (W - PL - PR)) * (pts.length - 1)));171        }}172        onMouseLeave={() => setHover(null)}>173        {[0, 1, 2, 3, 4].map((g) => {174          const y = PT + ((H - PT - PB) * g) / 4;175          const v = vmax - ((vmax - vmin) * g) / 4;176          return (177            <g key={g}>178              <line x1={PL} x2={W - PR} y1={y} y2={y} stroke="var(--line)" strokeWidth={1} />179              <text x={PL - 6} y={y + 3} textAnchor="end" fontSize={10} fill="var(--ink-3)" fontFamily="var(--font-mono)">{fmtInt(Math.round(v))}</text>180            </g>181          );182        })}183        {[0, Math.floor(pts.length / 2), pts.length - 1].map((i) => (184          <text key={i} x={X(i, pts.length)} y={H - 8} textAnchor="middle" fontSize={10} fill="var(--ink-3)" fontFamily="var(--font-mono)">{pts[i].t}</text>185        ))}186        {serie.kind === "area" && !hide.cur && (187          <path d={`${path(pts)}L${X(pts.length - 1, pts.length)},${Y(0)}L${X(0, pts.length)},${Y(0)}Z`} fill="var(--accent)" opacity={0.13} />188        )}189        {!hide.cmp && serie.compare && serie.compare.length > 1 && (190          <path d={path(serie.compare)} fill="none" stroke="var(--ink-3)" strokeWidth={1.4} strokeDasharray="4 4" />191        )}192        {!hide.cur && <path d={path(pts)} fill="none" stroke="var(--accent)" strokeWidth={2.4} />}193        {hi !== null && (194          <g>195            <line x1={X(hi, pts.length)} x2={X(hi, pts.length)} y1={PT} y2={H - PB} stroke="var(--ink)" strokeWidth={1} strokeDasharray="2 3" />196            <circle cx={X(hi, pts.length)} cy={Y(pts[hi].v)} r={4} fill="var(--accent)" stroke="var(--ink)" strokeWidth={1.5} />197          </g>198        )}199      </svg>200      {hi !== null && (201        <p className="chip" style={{ marginTop: 8 }}>202          {pts[hi].t} — <b>{fmtNum(pts[hi].v)}{serie.unit ? ` ${serie.unit}` : ""}</b>203          {serie.compare?.[hi] && !hide.cmp ? <span style={{ color: "var(--ink-3)" }}> · N-1 : {fmtNum(serie.compare[hi].v)}</span> : null}204        </p>205      )}206    </figure>207  );208}209210function LegendChip({ label, color, off, dashed, onClick }: { label: string; color: string; off?: boolean; dashed?: boolean; onClick?: () => void }) {211  return (212    <button type="button" onClick={onClick} aria-pressed={!off} disabled={!onClick}213      style={{ display: "inline-flex", alignItems: "center", gap: 6, border: 0, background: "none", cursor: onClick ? "pointer" : "default", opacity: off ? 0.4 : 1, fontFamily: "var(--font-mono)", fontSize: 10.5, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.06em", minHeight: 44 }}>214      <span style={{ width: 18, height: 0, borderTop: `3px ${dashed ? "dashed" : "solid"} ${color}` }} />215      {label}216    </button>217  );218}219220/* ---------- Multi-courbes (≤ 4 séries, motifs distincts) ---------- */221export function MultiLineChart({ ms, height = 260 }: { ms: MultiSerie; height?: number }) {222  const series = (ms.series ?? []).filter((s) => (s.points ?? []).length > 1).slice(0, 4);223  const [off, setOff] = useState<Record<string, boolean>>({});224  const [hover, setHover] = useState<number | null>(null);225  if (!series.length) return <EmptyBlock title={ms.title} />;226  const W = 720, H = height, PL = 54, PR = 10, PT = 14, PB = 26;227  const n = Math.max(...series.map((s) => s.points.length));228  const shown = series.filter((s) => !off[s.label]);229  const all = shown.flatMap((s) => s.points.map((p) => p.v));230  const vmax = Math.max(...(all.length ? all : [1]), 1);231  const vmin = Math.min(0, ...(all.length ? all : [0]));232  const X = (i: number, len: number) => PL + ((W - PL - PR) * i) / (len - 1);233  const Y = (v: number) => PT + (H - PT - PB) * (1 - (v - vmin) / (vmax - vmin || 1));234  const ref = series[0].points;235  const hi = hover !== null ? Math.min(n - 1, Math.max(0, hover)) : null;236  return (237    <figure className="card" style={{ margin: 0, padding: 16 }}>238      <figcaption style={{ display: "flex", justifyContent: "space-between", flexWrap: "wrap", gap: 8 }}>239        <b style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{ms.title}</b>240        <span style={{ display: "flex", gap: 10, flexWrap: "wrap" }}>241          {series.map((s, i) => (242            <LegendChip key={s.label} label={s.label} color={MULTI_STYLES[i].stroke}243              dashed={!!MULTI_STYLES[i].dash} off={!!off[s.label]}244              onClick={() => setOff((o) => ({ ...o, [s.label]: !o[s.label] }))} />245          ))}246        </span>247      </figcaption>248      <svg viewBox={`0 0 ${W} ${H}`} style={{ width: "100%", height: "auto", marginTop: 10, touchAction: "pan-y" }} role="img" aria-label={ms.title}249        onMouseMove={(e) => {250          const r = (e.currentTarget as SVGSVGElement).getBoundingClientRect();251          const fx = ((e.clientX - r.left) / r.width) * W;252          setHover(Math.round(((fx - PL) / (W - PL - PR)) * (n - 1)));253        }}254        onMouseLeave={() => setHover(null)}>255        {[0, 1, 2, 3, 4].map((g) => {256          const y = PT + ((H - PT - PB) * g) / 4;257          const v = vmax - ((vmax - vmin) * g) / 4;258          return (259            <g key={g}>260              <line x1={PL} x2={W - PR} y1={y} y2={y} stroke="var(--line)" strokeWidth={1} />261              <text x={PL - 6} y={y + 3} textAnchor="end" fontSize={10} fill="var(--ink-3)" fontFamily="var(--font-mono)">{fmtInt(Math.round(v))}</text>262            </g>263          );264        })}265        {[0, Math.floor(ref.length / 2), ref.length - 1].map((i) => (266          <text key={i} x={X(i, ref.length)} y={H - 8} textAnchor="middle" fontSize={10} fill="var(--ink-3)" fontFamily="var(--font-mono)">{ref[i]?.t}</text>267        ))}268        {series.map((s, i) => off[s.label] ? null : (269          <path key={s.label}270            d={s.points.map((p, j) => `${j ? "L" : "M"}${X(j, s.points.length)},${Y(p.v)}`).join("")}271            fill="none" stroke={MULTI_STYLES[i].stroke} strokeWidth={MULTI_STYLES[i].width}272            strokeDasharray={MULTI_STYLES[i].dash} />273        ))}274        {hi !== null && (275          <line x1={X(hi, n)} x2={X(hi, n)} y1={PT} y2={H - PB} stroke="var(--ink)" strokeWidth={1} strokeDasharray="2 3" />276        )}277      </svg>278      {hi !== null && (279        <p className="chip" style={{ marginTop: 8, display: "inline-flex", gap: 12, flexWrap: "wrap" }}>280          <b>{ref[hi]?.t}</b>281          {shown.map((s) => (282            <span key={s.label}>{s.label} : <b>{s.points[hi] ? fmtNum(s.points[hi].v) : "—"}{ms.unit ? ` ${ms.unit}` : ""}</b></span>283          ))}284        </p>285      )}286    </figure>287  );288}289290/* ---------- Barres verticales (volumes quotidiens, histogrammes) ---------- */291export function VBarChart({ serie, height = 240 }: { serie: Serie; height?: number }) {292  const [hover, setHover] = useState<number | null>(null);293  const pts = serie.points ?? [];294  if (!pts.length) return <EmptyBlock title={serie.title} />;295  const W = 720, H = height, PL = 54, PR = 10, PT = 14, PB = 26;296  const vmax = Math.max(...pts.map((p) => p.v), 1);297  const bw = Math.max(2, (W - PL - PR) / pts.length - 2);298  return (299    <figure className="card" style={{ margin: 0, padding: 16 }}>300      <figcaption><b style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{serie.title}</b></figcaption>301      <svg viewBox={`0 0 ${W} ${H}`} style={{ width: "100%", height: "auto", marginTop: 10 }} role="img" aria-label={serie.title}302        onMouseLeave={() => setHover(null)}>303        {[0, 1, 2, 3, 4].map((g) => {304          const y = PT + ((H - PT - PB) * g) / 4;305          const v = vmax - (vmax * g) / 4;306          return (307            <g key={g}>308              <line x1={PL} x2={W - PR} y1={y} y2={y} stroke="var(--line)" strokeWidth={1} />309              <text x={PL - 6} y={y + 3} textAnchor="end" fontSize={10} fill="var(--ink-3)" fontFamily="var(--font-mono)">{fmtInt(Math.round(v))}</text>310            </g>311          );312        })}313        {pts.map((p, i) => {314          const x = PL + ((W - PL - PR) * i) / pts.length;315          const h = (H - PT - PB) * (p.v / vmax);316          return (317            <rect key={i} x={x + 1} y={H - PB - h} width={bw} height={Math.max(h, p.v > 0 ? 1.5 : 0)} rx={2}318              fill="var(--accent)" opacity={hover === null || hover === i ? 1 : 0.45}319              stroke="var(--ink)" strokeWidth={0.5}320              onMouseEnter={() => setHover(i)}>321              <title>{`${p.t} — ${fmtNum(p.v)}${serie.unit ? ` ${serie.unit}` : ""}`}</title>322            </rect>323          );324        })}325        {[0, Math.floor(pts.length / 2), pts.length - 1].filter((v, i, a) => a.indexOf(v) === i).map((i) => (326          <text key={i} x={PL + ((W - PL - PR) * (i + 0.5)) / pts.length} y={H - 8} textAnchor="middle" fontSize={10} fill="var(--ink-3)" fontFamily="var(--font-mono)">{pts[i].t}</text>327        ))}328      </svg>329      {hover !== null && (330        <p className="chip" style={{ marginTop: 8 }}>{pts[hover].t} — <b>{fmtNum(pts[hover].v)}{serie.unit ? ` ${serie.unit}` : ""}</b></p>331      )}332    </figure>333  );334}335336/* ---------- Histogramme (distribution) ---------- */337export function Histogram({ dist }: { dist: Distribution }) {338  const serie: Serie = {339    id: dist.id, title: dist.title, unit: dist.unit, kind: "bar",340    points: (dist.bins ?? []).map((b) => ({ t: b.label, v: b.value })),341  };342  return <VBarChart serie={serie} height={220} />;343}344345/* ---------- Barres empilées (composition dans le temps) ---------- */346export function StackedBarChart({ st, height = 260 }: { st: StackedSerie; height?: number }) {347  const [hover, setHover] = useState<number | null>(null);348  const keys = (st.keys ?? []).slice(0, 6);349  const pts = st.points ?? [];350  if (!keys.length || !pts.length) return <EmptyBlock title={st.title} />;351  const W = 720, H = height, PL = 54, PR = 10, PT = 14, PB = 26;352  const totals = pts.map((p) => p.values.slice(0, keys.length).reduce((s, v) => s + (v || 0), 0));353  const vmax = Math.max(...totals, 1);354  const bw = Math.max(2, (W - PL - PR) / pts.length - 2);355  const shades = [1, 0.72, 0.5, 0.34, 0.22, 0.13];356  return (357    <figure className="card" style={{ margin: 0, padding: 16 }}>358      <figcaption style={{ display: "flex", justifyContent: "space-between", flexWrap: "wrap", gap: 8 }}>359        <b style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{st.title}</b>360        <span style={{ display: "flex", gap: 10, flexWrap: "wrap" }}>361          {keys.map((k, i) => (362            <span key={k} style={{ display: "inline-flex", alignItems: "center", gap: 5, fontFamily: "var(--font-mono)", fontSize: 10.5, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.06em" }}>363              <span style={{ width: 11, height: 11, borderRadius: 3, border: "1px solid var(--ink)", background: "var(--accent)", opacity: shades[i] }} />364              {k}365            </span>366          ))}367        </span>368      </figcaption>369      <svg viewBox={`0 0 ${W} ${H}`} style={{ width: "100%", height: "auto", marginTop: 10 }} role="img" aria-label={st.title}370        onMouseLeave={() => setHover(null)}>371        {[0, 1, 2, 3, 4].map((g) => {372          const y = PT + ((H - PT - PB) * g) / 4;373          return (374            <g key={g}>375              <line x1={PL} x2={W - PR} y1={y} y2={y} stroke="var(--line)" strokeWidth={1} />376              <text x={PL - 6} y={y + 3} textAnchor="end" fontSize={10} fill="var(--ink-3)" fontFamily="var(--font-mono)">{fmtInt(Math.round(vmax - (vmax * g) / 4))}</text>377            </g>378          );379        })}380        {pts.map((p, i) => {381          const x = PL + ((W - PL - PR) * i) / pts.length;382          let yAcc = H - PB;383          return (384            <g key={i} onMouseEnter={() => setHover(i)} opacity={hover === null || hover === i ? 1 : 0.5}>385              {keys.map((k, j) => {386                const v = p.values[j] || 0;387                const h = (H - PT - PB) * (v / vmax);388                yAcc -= h;389                return v > 0 ? (390                  <rect key={k} x={x + 1} y={yAcc} width={bw} height={Math.max(h - 1, 0.8)} rx={1.5}391                    fill="var(--accent)" opacity={shades[j]} stroke="var(--ink)" strokeWidth={0.4}>392                    <title>{`${p.t} · ${k} — ${fmtNum(v)}${st.unit ? ` ${st.unit}` : ""}`}</title>393                  </rect>394                ) : null;395              })}396            </g>397          );398        })}399        {[0, Math.floor(pts.length / 2), pts.length - 1].filter((v, i, a) => a.indexOf(v) === i).map((i) => (400          <text key={i} x={PL + ((W - PL - PR) * (i + 0.5)) / pts.length} y={H - 8} textAnchor="middle" fontSize={10} fill="var(--ink-3)" fontFamily="var(--font-mono)">{pts[i].t}</text>401        ))}402      </svg>403      {hover !== null && (404        <p className="chip" style={{ marginTop: 8, display: "inline-flex", gap: 12, flexWrap: "wrap" }}>405          <b>{pts[hover].t}</b>406          {keys.map((k, j) => <span key={k}>{k} : <b>{fmtNum(pts[hover].values[j] || 0)}</b></span>)}407          <span style={{ color: "var(--ink-3)" }}>total {fmtNum(totals[hover])}</span>408        </p>409      )}410    </figure>411  );412}413414/* ---------- Barres horizontales (répartitions, géo) — deltas optionnels ---------- */415export function BarChart({ title, items, unit }: { title: string; items: BreakItem[]; unit?: string }) {416  const rows = (items ?? []).slice(0, 14);417  if (!rows.length) return <EmptyBlock title={title} />;418  const max = Math.max(...rows.map((r) => r.value), 1);419  return (420    <figure className="card" style={{ margin: 0, padding: 16 }}>421      <figcaption><b style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{title}</b></figcaption>422      <div style={{ marginTop: 12, display: "grid", gap: 9 }}>423        {rows.map((r) => (424          <div key={r.label} title={`${r.label} — ${fmtNum(r.value)}${unit ? ` ${unit}` : ""}`}>425            <div style={{ display: "flex", justifyContent: "space-between", gap: 8, fontSize: 12.5 }}>426              <span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{r.label}</span>427              <span style={{ display: "inline-flex", gap: 8, alignItems: "baseline", flex: "none" }}>428                {r.delta_pct !== undefined && r.delta_pct !== null && (429                  <span style={{ fontFamily: "var(--font-mono)", fontSize: 10, fontWeight: 700, color: r.delta_pct >= 0 ? "var(--green)" : "var(--danger)" }}>430                    {r.delta_pct >= 0 ? "▲" : "▼"} {fmtPct(r.delta_pct)}431                  </span>432                )}433                <b style={{ fontFamily: "var(--font-mono)", fontSize: 11.5 }}>{fmtNum(r.value)}{unit ? ` ${unit}` : ""}</b>434              </span>435            </div>436            <div style={{ marginTop: 3, height: 12, background: "rgba(20,24,20,0.06)", borderRadius: "0 3px 3px 0" }}>437              <div style={{ height: "100%", width: `${Math.max((r.value / max) * 100, 1)}%`, background: "var(--accent)", border: "1px solid var(--ink)", borderRadius: "0 3px 3px 0", boxSizing: "border-box" }} />438            </div>439          </div>440        ))}441      </div>442    </figure>443  );444}445446/* ---------- Anneau ---------- */447export function Donut({ title, items }: { title: string; items: BreakItem[] }) {448  const rows = (items ?? []).filter((i) => i.value > 0).slice(0, 8);449  const total = rows.reduce((s, r) => s + r.value, 0);450  if (!total) return <EmptyBlock title={title} />;451  const R = 74, C = 2 * Math.PI * R;452  let acc = 0;453  const shades = [1, 0.78, 0.58, 0.42, 0.3, 0.22, 0.15, 0.1];454  return (455    <figure className="card" style={{ margin: 0, padding: 16 }}>456      <figcaption><b style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{title}</b></figcaption>457      <div style={{ display: "flex", flexWrap: "wrap", gap: 18, alignItems: "center", marginTop: 12 }}>458        <svg viewBox="0 0 200 200" style={{ width: 180, maxWidth: "100%" }} role="img" aria-label={title}>459          {rows.map((r, i) => {460            const frac = r.value / total;461            const off = acc; acc += frac;462            return (463              <circle key={r.label} cx={100} cy={100} r={R} fill="none"464                stroke="var(--accent)" strokeOpacity={shades[i % shades.length]}465                strokeWidth={30} strokeDasharray={`${frac * C} ${C}`} strokeDashoffset={-off * C}466                transform="rotate(-90 100 100)">467                <title>{`${r.label} — ${fmtNum(r.value)} (${((100 * r.value) / total).toFixed(1)} %)`}</title>468              </circle>469            );470          })}471          <circle cx={100} cy={100} r={R} fill="none" stroke="var(--ink)" strokeWidth={1} opacity={0.5} />472        </svg>473        <ul style={{ listStyle: "none", margin: 0, padding: 0, display: "grid", gap: 6, minWidth: 200, flex: 1 }}>474          {rows.map((r, i) => (475            <li key={r.label} style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 12.5 }}>476              <span style={{ width: 11, height: 11, borderRadius: 3, border: "1px solid var(--ink)", background: "var(--accent)", opacity: shades[i % shades.length] }} />477              <span style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{r.label}</span>478              <b style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}>{((100 * r.value) / total).toFixed(1)} %</b>479            </li>480          ))}481        </ul>482      </div>483    </figure>484  );485}486487/* ---------- Jauge (taux, complétude, couverture) ---------- */488export function GaugeCard({ g }: { g: Gauge }) {489  const frac = Math.max(0, Math.min(1, g.max ? g.value / g.max : 0));490  const R = 60, C = Math.PI * R;491  return (492    <article className="card" style={{ padding: "14px 16px", minWidth: 0 }} title={g.help}>493      <svg viewBox="0 0 150 84" style={{ width: "100%", maxWidth: 190, display: "block", margin: "0 auto" }} role="img" aria-label={`${g.label} : ${fmtNum(g.value)}${g.unit ?? ""} sur ${fmtNum(g.max)}`}>494        <path d={`M15,75 A${R},${R} 0 0 1 135,75`} fill="none" stroke="rgba(20,24,20,0.08)" strokeWidth={13} strokeLinecap="round" />495        <path d={`M15,75 A${R},${R} 0 0 1 135,75`} fill="none" stroke="var(--accent)" strokeWidth={13} strokeLinecap="round"496          strokeDasharray={`${frac * C} ${C}`} />497        <text x={75} y={66} textAnchor="middle" fontFamily="var(--font-display)" fontWeight={700} fontSize={22} fill="var(--ink)">498          {fmtNum(g.value)}{g.unit ? <tspan fontSize={12} fill="var(--ink-2)"> {g.unit}</tspan> : null}499        </text>500        <text x={75} y={80} textAnchor="middle" fontSize={9} fill="var(--ink-3)" fontFamily="var(--font-mono)">{(frac * 100).toFixed(0)} % de {fmtNum(g.max)}{g.unit ? ` ${g.unit}` : ""}</text>501      </svg>502      <p className="klabel" style={{ margin: "8px 0 0", textAlign: "center" }}>{g.label}</p>503    </article>504  );505}506507/* ---------- Calendrier de chaleur ---------- */508export function CalendarHeatmap({ title, cells }: { title: string; cells: { date: string; value: number }[] }) {509  if (!cells?.length) return <EmptyBlock title={title} />;510  const byDate = new Map(cells.map((c) => [c.date, c.value]));511  const dates = cells.map((c) => c.date).sort();512  const end = new Date(dates[dates.length - 1] + "T12:00:00");513  const max = Math.max(...cells.map((c) => c.value), 1);514  const weeks = 26, cols: { date: string; v: number }[][] = [];515  const cur = new Date(end);516  cur.setDate(cur.getDate() - (weeks * 7 - 1));517  for (let w = 0; w < weeks; w++) {518    const col: { date: string; v: number }[] = [];519    for (let d = 0; d < 7; d++) {520      const iso = cur.toISOString().slice(0, 10);521      col.push({ date: iso, v: byDate.get(iso) ?? 0 });522      cur.setDate(cur.getDate() + 1);523    }524    cols.push(col);525  }526  return (527    <figure className="card" style={{ margin: 0, padding: 16 }}>528      <figcaption><b style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{title}</b> <span className="klabel">26 dernières semaines</span></figcaption>529      <div className="tbl-wrap" style={{ marginTop: 12 }}>530        <svg viewBox={`0 0 ${weeks * 14} ${7 * 14}`} style={{ minWidth: 480, width: "100%", height: "auto" }} role="img" aria-label={title}>531          {cols.map((col, w) => col.map((c, d) => (532            <rect key={c.date} x={w * 14} y={d * 14} width={12} height={12} rx={2.5}533              fill={c.v ? "var(--accent)" : "rgba(20,24,20,0.07)"} fillOpacity={c.v ? 0.25 + 0.75 * (c.v / max) : 1}534              stroke="rgba(20,24,20,0.15)" strokeWidth={0.5}>535              <title>{`${c.date} — ${fmtNum(c.v)}`}</title>536            </rect>537          )))}538        </svg>539      </div>540    </figure>541  );542}543544/* ---------- Heatmap horaire 7 × 24 (activité par heure) ---------- */545const DOW = ["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"];546export function HourHeatmap({ title, cells }: { title: string; cells: HourCell[] }) {547  if (!cells?.length) return <EmptyBlock title={title} />;548  const grid = new Map(cells.map((c) => [`${c.dow}-${c.hour}`, c.value]));549  const max = Math.max(...cells.map((c) => c.value), 1);550  const CW = 24, CH = 20, LX = 34, LY = 16;551  return (552    <figure className="card" style={{ margin: 0, padding: 16 }}>553      <figcaption><b style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{title}</b> <span className="klabel">jour × heure</span></figcaption>554      <div className="tbl-wrap" style={{ marginTop: 12 }}>555        <svg viewBox={`0 0 ${LX + 24 * CW} ${LY + 7 * CH}`} style={{ minWidth: 520, width: "100%", height: "auto" }} role="img" aria-label={title}>556          {[0, 6, 12, 18, 23].map((h) => (557            <text key={h} x={LX + h * CW + CW / 2} y={11} textAnchor="middle" fontSize={9} fill="var(--ink-3)" fontFamily="var(--font-mono)">{h} h</text>558          ))}559          {DOW.map((d, i) => (560            <text key={d} x={LX - 6} y={LY + i * CH + CH / 2 + 3} textAnchor="end" fontSize={9} fill="var(--ink-3)" fontFamily="var(--font-mono)">{d}</text>561          ))}562          {Array.from({ length: 7 }, (_, d) => Array.from({ length: 24 }, (_, h) => {563            const v = grid.get(`${d}-${h}`) ?? 0;564            return (565              <rect key={`${d}-${h}`} x={LX + h * CW} y={LY + d * CH} width={CW - 2} height={CH - 2} rx={2.5}566                fill={v ? "var(--accent)" : "rgba(20,24,20,0.07)"} fillOpacity={v ? 0.22 + 0.78 * (v / max) : 1}567                stroke="rgba(20,24,20,0.15)" strokeWidth={0.5}>568                <title>{`${DOW[d]} ${h} h — ${fmtNum(v)}`}</title>569              </rect>570            );571          }))}572        </svg>573      </div>574    </figure>575  );576}577578/* ---------- Statistiques de série (min/max/moy/méd/σ) ---------- */579export function StatSummary({ serie }: { serie: Serie }) {580  const vs = (serie.points ?? []).map((p) => p.v).filter((v) => typeof v === "number");581  if (vs.length < 2) return null;582  const sorted = [...vs].sort((a, b) => a - b);583  const mean = vs.reduce((s, v) => s + v, 0) / vs.length;584  const med = sorted[Math.floor(sorted.length / 2)];585  const sd = Math.sqrt(vs.reduce((s, v) => s + (v - mean) ** 2, 0) / vs.length);586  const items: [string, number][] = [587    ["Min", sorted[0]], ["Max", sorted[sorted.length - 1]],588    ["Moyenne", Math.round(mean * 100) / 100], ["Médiane", med],589    ["Écart-type", Math.round(sd * 100) / 100],590  ];591  return (592    <div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginTop: 8 }}>593      {items.map(([l, v]) => (594        <span key={l} className="chip" style={{ fontSize: 11 }}>595          <span style={{ color: "var(--ink-3)" }}>{l}</span> <b style={{ fontFamily: "var(--font-mono)" }}>{fmtNum(v)}</b>596        </span>597      ))}598    </div>599  );600}601602/* ---------- Tableau : tri, recherche, pagination ---------- */603export function DataTable({ spec, pageSize = 25 }: { spec: TableSpec; pageSize?: number }) {604  const [q, setQ] = useState("");605  const [sort, setSort] = useState<{ col: number; dir: 1 | -1 } | null>(null);606  const [page, setPage] = useState(0);607  const rows = useMemo(() => {608    let r = spec.rows ?? [];609    if (q) r = r.filter((row) => row.some((c) => String(c).toLowerCase().includes(q.toLowerCase())));610    if (sort) r = [...r].sort((a, b) => {611      const x = a[sort.col], y = b[sort.col];612      const nx = typeof x === "number" ? x : parseFloat(String(x).replace(/[^\d.,-]/g, "").replace(",", "."));613      const ny = typeof y === "number" ? y : parseFloat(String(y).replace(/[^\d.,-]/g, "").replace(",", "."));614      if (!Number.isNaN(nx) && !Number.isNaN(ny)) return (nx - ny) * sort.dir;615      return String(x).localeCompare(String(y), "fr") * sort.dir;616    });617    return r;618  }, [spec.rows, q, sort]);619  const pages = Math.max(1, Math.ceil(rows.length / pageSize));620  const cur = Math.min(page, pages - 1);621  return (622    <section className="card" style={{ padding: 16 }}>623      <div style={{ display: "flex", flexWrap: "wrap", gap: 10, justifyContent: "space-between", alignItems: "center" }}>624        <b style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{spec.title}</b>625        <input className="input" style={{ maxWidth: 240 }} placeholder="Rechercher…" value={q}626          onChange={(e) => { setQ(e.target.value); setPage(0); }} aria-label={`Rechercher dans ${spec.title}`} />627      </div>628      <div className="tbl-wrap" style={{ marginTop: 10 }}>629        <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>630          <thead>631            <tr>632              {spec.columns.map((c, i) => (633                <th key={c} onClick={() => setSort((s) => ({ col: i, dir: s?.col === i && s.dir === 1 ? -1 : 1 }))}634                  style={{ cursor: "pointer", textAlign: "left", padding: "8px 10px", background: "var(--ink)", color: "var(--paper)", fontFamily: "var(--font-mono)", fontSize: 10.5, textTransform: "uppercase", letterSpacing: "0.06em", whiteSpace: "nowrap", userSelect: "none" }}635                  aria-sort={sort?.col === i ? (sort.dir === 1 ? "ascending" : "descending") : "none"}>636                  {c} {sort?.col === i ? (sort.dir === 1 ? "▲" : "▼") : "↕"}637                </th>638              ))}639            </tr>640          </thead>641          <tbody>642            {rows.slice(cur * pageSize, (cur + 1) * pageSize).map((row, ri) => (643              <tr key={ri} style={{ background: ri % 2 ? "var(--surface-2)" : "var(--surface)" }}>644                {row.map((c, ci) => (645                  <td key={ci} style={{ padding: "7px 10px", borderBottom: "1px solid var(--line)", whiteSpace: "nowrap" }}>646                    {typeof c === "number" ? fmtNum(c) : c}647                  </td>648                ))}649              </tr>650            ))}651          </tbody>652        </table>653      </div>654      <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginTop: 10, flexWrap: "wrap", gap: 8 }}>655        <span className="klabel">{fmtInt(rows.length)} lignes</span>656        <span style={{ display: "flex", gap: 6 }}>657          <button type="button" className="btn btn-ghost" disabled={cur === 0} onClick={() => setPage(cur - 1)}>←</button>658          <span className="chip">{cur + 1} / {pages}</span>659          <button type="button" className="btn btn-ghost" disabled={cur >= pages - 1} onClick={() => setPage(cur + 1)}>→</button>660        </span>661      </div>662    </section>663  );664}665666/* ---------- Records / faits marquants ---------- */667export function RecordCard({ r }: { r: RecordFact }) {668  return (669    <article className="card" style={{ padding: "12px 16px", display: "flex", justifyContent: "space-between", gap: 12, alignItems: "baseline", background: "var(--surface-2)" }}>670      <span style={{ fontSize: 13, color: "var(--ink-2)" }}>{r.label}</span>671      <span style={{ textAlign: "right" }}>672        <b style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{r.value}</b>673        {r.date && <span className="klabel" style={{ display: "block" }}>{r.date}</span>}674      </span>675    </article>676  );677}678679/* ---------- Menu de rapports PDF (5 rapports + personnalisé v3) ---------- */680export function PdfButton({ period, from, to, endpoint = "/api/stats/report" }: { period: string; from?: string; to?: string; endpoint?: string }) {681  const [open, setOpen] = useState(false);682  const [busy, setBusy] = useState<string | null>(null);683  const [builder, setBuilder] = useState(false);684  const box = useRef<HTMLSpanElement>(null);685  useEffect(() => {686    if (!open) return;687    const close = (e: MouseEvent) => {688      if (box.current && !box.current.contains(e.target as Node)) setOpen(false);689    };690    document.addEventListener("mousedown", close);691    return () => document.removeEventListener("mousedown", close);692  }, [open]);693  const url = (mode: string) => {694    const p = new URLSearchParams({ period, mode });695    if (from) p.set("from", from);696    if (to) p.set("to", to);697    return `${endpoint}?${p}`;698  };699  const dl = (mode: string) => {700    setBusy(mode);701    setOpen(false);702    const a = document.createElement("a");703    a.href = url(mode);704    a.download = "";705    document.body.appendChild(a);706    a.click();707    a.remove();708    setTimeout(() => setBusy(null), 3000);709  };710  return (711    <span ref={box} style={{ position: "relative", display: "inline-flex", gap: 8, flexWrap: "wrap" }}>712      <button type="button" className="btn btn-primary" onClick={() => dl("complet")} disabled={!!busy}>713        {busy ? "Génération…" : "⬇ Rapport PDF complet"}714      </button>715      <button type="button" className="btn btn-ghost" onClick={() => setOpen((o) => !o)} disabled={!!busy}716        aria-haspopup="menu" aria-expanded={open}>717        Autres rapports ▾718      </button>719      <button type="button" className="btn btn-ghost" onClick={() => setBuilder(true)} disabled={!!busy}>720        🛠 Rapport personnalisé721      </button>722      {builder && (723        <ReportBuilder period={period} from={from} to={to} endpoint={endpoint}724          onClose={() => setBuilder(false)} />725      )}726      {open && (727        <div role="menu" className="card" style={{ position: "absolute", top: "calc(100% + 6px)", right: 0, zIndex: 50, minWidth: 300, padding: 6, background: "var(--surface)", boxShadow: "0 10px 28px rgba(20,24,20,0.18)" }}>728          {REPORT_MODES.map((m) => (729            <button key={m.id} type="button" role="menuitem" onClick={() => dl(m.id)}730              style={{ display: "block", width: "100%", textAlign: "left", border: 0, background: "none", cursor: "pointer", padding: "9px 10px", borderRadius: 6 }}731              onMouseEnter={(e) => (e.currentTarget.style.background = "var(--surface-2)")}732              onMouseLeave={(e) => (e.currentTarget.style.background = "none")}>733              <b style={{ display: "block", fontSize: 13, fontFamily: "var(--font-display)" }}>{m.label}</b>734              <span className="klabel" style={{ fontSize: 11 }}>{m.desc}</span>735            </button>736          ))}737        </div>738      )}739    </span>740  );741}742743/* ---------- v3 : constructeur de rapports personnalisés ----------744   Compose un PDF bloc par bloc : catalogue dérivé du dashboard745   (GET /api/stats/catalog), rendu au choix par bloc, ordre libre, modèles746   sauvegardés en localStorage (clé ka-stats-rapports, propre au site).747   Contrat : SPEC.md §3bis. Rendu dans PdfButton — aucune modif des pages. */748export type CatalogBlock = {749  key: string; section: string; title: string;750  renders: string[]; default_render: string; count?: number;751};752type BuilderSel = { key: string; render: string };753type BuilderTpl = { name: string; title: string; blocks: BuilderSel[] };754755const RENDER_LABELS: Record<string, string> = {756  line: "Courbe", area: "Aire", bar: "Barres verticales",757  bars: "Barres horizontales", donut: "Anneau", lines: "Multi-courbes",758  stacked: "Barres empilées", histogram: "Histogramme", heatmap: "Heatmap",759  cards: "Cartes", gauges: "Jauges", table: "Tableau",760};761const SECTION_LABELS: Record<string, string> = {762  kpis: "Indicateurs", gauges: "Jauges", series: "Évolution",763  multiseries: "Multi-courbes", stacked: "Compositions",764  breakdowns: "Répartitions", distributions: "Distributions",765  geo: "Géographie", heatmap: "Calendrier", hourly: "Activité horaire",766  tables: "Tableaux", records: "Records",767};768const TPL_KEY = "ka-stats-rapports";769770function loadTemplates(): BuilderTpl[] {771  try { return JSON.parse(localStorage.getItem(TPL_KEY) ?? "[]"); }772  catch { return []; }773}774function saveTemplates(t: BuilderTpl[]) {775  try { localStorage.setItem(TPL_KEY, JSON.stringify(t)); } catch { /* plein/privé */ }776}777778export function ReportBuilder({779  period, from, to, endpoint = "/api/stats/report", onClose,780}: {781  period: string; from?: string; to?: string; endpoint?: string; onClose: () => void;782}) {783  const [cat, setCat] = useState<CatalogBlock[] | null>(null);784  const [err, setErr] = useState("");785  const [sel, setSel] = useState<BuilderSel[]>([]);786  const [title, setTitle] = useState("");787  const [busy, setBusy] = useState(false);788  const [tpls, setTpls] = useState<BuilderTpl[]>(loadTemplates);789  const catalogUrl = endpoint.replace(/\/report$/, "/catalog");790791  useEffect(() => {792    const p = new URLSearchParams({ period });793    if (from) p.set("from", from);794    if (to) p.set("to", to);795    fetch(`${catalogUrl}?${p}`)796      .then((r) => (r.ok ? r.json() : Promise.reject(r.status)))797      .then((d) => setCat(d.blocks ?? []))798      .catch(() => setErr("Catalogue indisponible — réessayez plus tard."));799  }, [period, from, to, catalogUrl]);800801  useEffect(() => {802    const esc = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };803    document.addEventListener("keydown", esc);804    const prev = document.body.style.overflow;805    document.body.style.overflow = "hidden";806    return () => { document.removeEventListener("keydown", esc); document.body.style.overflow = prev; };807  }, [onClose]);808809  const add = (b: CatalogBlock) =>810    setSel((s) => s.some((x) => x.key === b.key && x.render === b.default_render)811      ? s : [...s, { key: b.key, render: b.default_render }]);812  const move = (i: number, d: number) => setSel((s) => {813    const j = i + d;814    if (j < 0 || j >= s.length) return s;815    const n = [...s]; [n[i], n[j]] = [n[j], n[i]]; return n;816  });817818  const generate = async () => {819    if (busy || !sel.length) return;820    setBusy(true); setErr("");821    try {822      const body: Record<string, unknown> = { title, period, blocks: sel };823      if (from && to) { body.from = from; body.to = to; }824      const r = await fetch(`${endpoint}/custom`, {825        method: "POST", headers: { "Content-Type": "application/json" },826        body: JSON.stringify(body),827      });828      if (!r.ok) throw new Error(String(r.status));829      const blob = await r.blob();830      const m = (r.headers.get("Content-Disposition") ?? "").match(/filename="?([^";]+)/);831      const a = document.createElement("a");832      a.href = URL.createObjectURL(blob);833      a.download = m ? m[1] : "rapport-personnalise.pdf";834      document.body.appendChild(a); a.click(); a.remove();835      setTimeout(() => URL.revokeObjectURL(a.href), 4000);836    } catch {837      setErr("La génération a échoué — réessayez.");838    }839    setBusy(false);840  };841842  const groups: [string, CatalogBlock[]][] = [];843  for (const b of cat ?? []) {844    const g = groups.find(([s]) => s === b.section);845    if (g) g[1].push(b); else groups.push([b.section, [b]]);846  }847  const selKeys = new Set(sel.map((s) => s.key));848  const mono: React.CSSProperties = { fontFamily: "var(--font-mono)", fontSize: 10.5, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.06em" };849850  return (851    <div role="dialog" aria-modal="true" aria-label="Rapport personnalisé"852      onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}853      style={{ position: "fixed", inset: 0, zIndex: "var(--z-modal, 900)" as never, background: "rgba(20,24,20,0.45)", display: "flex", alignItems: "flex-start", justifyContent: "center", padding: "4vh 14px", overflow: "auto" }}>854      <div className="card" style={{ width: "min(980px,100%)", maxHeight: "92vh", display: "flex", flexDirection: "column", background: "var(--surface)", padding: 0, textAlign: "left", cursor: "default" }}>855        <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 10, padding: "16px 20px", borderBottom: "1px solid var(--line)" }}>856          <b style={{ fontFamily: "var(--font-display)", fontSize: 18 }}>857            Rapport personnalisé <span className="klabel">· période : {from && to ? `${from} → ${to}` : (PERIODS.find((p) => p.id === period)?.label ?? period)}</span>858          </b>859          <button type="button" className="btn btn-ghost" onClick={onClose}>✕ Fermer</button>860        </div>861        <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))", overflow: "auto", flex: 1 }}>862          <div style={{ padding: "14px 20px", minWidth: 0, borderRight: "1px solid var(--line)" }}>863            <h3 style={{ ...mono, color: "var(--ink-3)", margin: "4px 0 10px" }}>Blocs disponibles ({cat?.length ?? "…"})</h3>864            {!cat && !err && <p className="klabel">Chargement du catalogue…</p>}865            {groups.map(([secId, bs]) => (866              <div key={secId}>867                <p style={{ ...mono, color: "var(--ink-2)", margin: "12px 0 6px" }}>{SECTION_LABELS[secId] ?? secId}</p>868                {bs.map((b) => (869                  <div key={b.key} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 8, padding: "7px 10px", border: "1px solid var(--line)", borderRadius: 8, marginBottom: 6, fontSize: 13, opacity: selKeys.has(b.key) ? 0.45 : 1 }}>870                    <span style={{ minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={b.title}>{b.title}</span>871                    <button type="button" className="btn btn-ghost" onClick={() => add(b)} aria-label={`Ajouter ${b.title}`} style={{ flex: "none" }}>+</button>872                  </div>873                ))}874              </div>875            ))}876          </div>877          <div style={{ padding: "14px 20px", minWidth: 0 }}>878            <h3 style={{ ...mono, color: "var(--ink-3)", margin: "4px 0 10px" }}>Composition du rapport ({sel.length})</h3>879            <label className="klabel" htmlFor="rb-title">Titre du rapport</label>880            <input id="rb-title" className="input" style={{ width: "100%", margin: "4px 0 12px", boxSizing: "border-box" }}881              maxLength={80} placeholder="Ex. : Revue mensuelle" value={title} onChange={(e) => setTitle(e.target.value)} />882            {sel.length ? sel.map((s, i) => {883              const b = (cat ?? []).find((x) => x.key === s.key) ?? { title: s.key, renders: [s.render] } as CatalogBlock;884              return (885                <div key={`${s.key}:${s.render}:${i}`} style={{ display: "flex", alignItems: "center", gap: 8, padding: "8px 10px", border: "1px solid var(--ink)", borderRadius: 8, marginBottom: 6, background: "var(--surface-2)", fontSize: 13 }}>886                  <button type="button" onClick={() => move(i, -1)} disabled={i === 0} aria-label="Monter" style={{ border: 0, background: "none", cursor: "pointer", opacity: i === 0 ? 0.25 : 1 }}>▲</button>887                  <button type="button" onClick={() => move(i, 1)} disabled={i === sel.length - 1} aria-label="Descendre" style={{ border: 0, background: "none", cursor: "pointer", opacity: i === sel.length - 1 ? 0.25 : 1 }}>▼</button>888                  <span style={{ flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={b.title}><b>{i + 1}.</b> {b.title}</span>889                  {b.renders.length > 1 ? (890                    <select className="input" value={s.render} aria-label="Rendu" style={{ maxWidth: 150, padding: "4px 6px", fontSize: 12 }}891                      onChange={(e) => setSel((xs) => xs.map((x, j) => j === i ? { ...x, render: e.target.value } : x))}>892                      {b.renders.map((r) => <option key={r} value={r}>{RENDER_LABELS[r] ?? r}</option>)}893                    </select>894                  ) : <span className="klabel">{RENDER_LABELS[s.render] ?? s.render}</span>}895                  <button type="button" onClick={() => setSel((xs) => xs.filter((_, j) => j !== i))} aria-label="Retirer" style={{ border: 0, background: "none", cursor: "pointer" }}>✕</button>896                </div>897              );898            }) : (899              <div style={{ border: "1px dashed var(--line)", borderRadius: 8, padding: 16, color: "var(--ink-3)", fontSize: 13, textAlign: "center" }}>900                Aucun bloc — ajoutez des blocs depuis la colonne de gauche, ou chargez un modèle ci-dessous.901              </div>902            )}903            <p style={{ display: "flex", gap: 8, margin: "10px 0 0" }}>904              <button type="button" className="btn btn-ghost" disabled={!cat?.length}905                onClick={() => setSel((cat ?? []).map((b) => ({ key: b.key, render: b.default_render })))}>Tout ajouter</button>906              <button type="button" className="btn btn-ghost" disabled={!sel.length} onClick={() => setSel([])}>Vider</button>907            </p>908            {err && <p style={{ color: "var(--danger)", fontSize: 12.5, margin: "6px 0 0" }}>{err}</p>}909          </div>910        </div>911        <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 10, flexWrap: "wrap", padding: "14px 20px", borderTop: "1px solid var(--line)" }}>912          <span style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>913            <select className="input" aria-label="Modèles sauvegardés" style={{ maxWidth: 210 }} value=""914              onChange={(e) => {915                const t = tpls[Number(e.target.value)];916                if (!t) return;917                setTitle(t.title || t.name);918                setSel((t.blocks ?? []).filter((s) => (cat ?? []).some((b) => b.key === s.key)).map((s) => ({ ...s })));919              }}>920              <option value="">Modèles ({tpls.length})…</option>921              {tpls.map((t, i) => <option key={t.name} value={i}>{t.name}</option>)}922            </select>923            <button type="button" className="btn btn-ghost" disabled={!sel.length}924              onClick={() => {925                const name = window.prompt("Nom du modèle :", title || "Mon rapport");926                if (!name) return;927                const next = [...tpls.filter((t) => t.name !== name), { name, title, blocks: sel.map((s) => ({ ...s })) }];928                setTpls(next); saveTemplates(next);929              }}>💾 Sauvegarder</button>930            <button type="button" className="btn btn-ghost" disabled={!tpls.length}931              onClick={() => {932                const name = window.prompt(`Nom du modèle à supprimer :\n${tpls.map((t) => `· ${t.name}`).join("\n")}`);933                if (!name) return;934                const next = tpls.filter((t) => t.name !== name);935                setTpls(next); saveTemplates(next);936              }}>🗑 Supprimer</button>937          </span>938          <button type="button" className="btn btn-primary" disabled={!sel.length || busy} onClick={generate}>939            {busy ? "Génération…" : "⬇ Générer le PDF"}940          </button>941        </div>942      </div>943    </div>944  );945}946947/* ---------- États ---------- */948export function EmptyBlock({ title }: { title: string }) {949  return (950    <div className="card" style={{ padding: 20, background: "var(--surface-2)", borderStyle: "dashed" }}>951      <b style={{ fontFamily: "var(--font-display)", fontSize: 14 }}>{title}</b>952      <p className="klabel" style={{ margin: "6px 0 0" }}>Pas encore mesuré — aucune donnée disponible pour cette période.</p>953    </div>954  );955}956957export function Fraicheur({ updated, onRefresh }: { updated: string; onRefresh: () => void }) {958  return (959    <p style={{ display: "flex", gap: 10, alignItems: "center", flexWrap: "wrap", margin: 0 }}>960      <span className="klabel">Mis à jour le {new Date(updated).toLocaleString("fr-CA", { dateStyle: "medium", timeStyle: "short" })}</span>961      <button type="button" className="btn btn-ghost" onClick={onRefresh}>↻ Rafraîchir</button>962    </p>963  );964}965