Resto·Ka — tous les restaurants du Québec, menus complets et prix réels (famille ·Ka)
Python 69.3%
TypeScript 16.7%
CSS 7.9%
JavaScript 4.7%
HTML 1.4%
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