| 2 | // ka-ui/stats/kacharts.tsx — kit de graphiques SVG du module Stats commun |
2 | // ka-ui/stats/kacharts.tsx — kit de graphiques SVG du module Stats commun |
| 3 | // Groupe KA (zéro dépendance, React 18+). Style : design system ka-ui |
3 | // Groupe KA (zéro dépendance, React 18+). Style : design system ka-ui |
| 4 | // (bordures encre, accent de la plateforme via var(--accent)). |
4 | // (bordures encre, accent de la plateforme via var(--accent)). |
| 5 | −// Composants : KpiCard, PeriodSelector, LineChart (infobulle + légende |
5 | +// v2 — composants : KpiCard (+sparkline), PeriodSelector, LineChart (ligne/ |
| 6 | −// cliquable + comparaison N-1), BarChart, Donut, CalendarHeatmap, DataTable |
6 | +// aire, infobulle + légende cliquable + comparaison N-1), MultiLineChart |
| 7 | −// (tri/recherche/pagination), RecordCard, PdfButton, EmptyBlock, Fraicheur. |
7 | +// (≤4 séries, pointillés distincts = jamais la couleur seule), VBarChart |
| 8 | −import { useMemo, useState } from "react"; |
8 | +// (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. |
| |
12 | +import { useEffect, useMemo, useRef, useState } from "react"; |
| 9 | |
13 | |
| 10 | −/* ---------- types (contrat SPEC.md) ---------- */ |
14 | +/* ---------- types (contrat SPEC.md v2) ---------- */ |
| 11 | export type Kpi = { |
15 | export type Kpi = { |
| 12 | id: string; label: string; value: number | string; unit?: string; |
16 | id: string; label: string; value: number | string; unit?: string; |
| 13 | delta_pct?: number | null; direction?: "up" | "down"; |
17 | delta_pct?: number | null; direction?: "up" | "down"; |
| |
18 | + spark?: Point[]; help?: string; |
| 14 | }; |
19 | }; |
| 15 | export type Point = { t: string; v: number }; |
20 | export type Point = { t: string; v: number }; |
| 16 | export type Serie = { |
21 | export type Serie = { |
| 17 | − id: string; title: string; unit?: string; kind?: "line" | "bar"; |
22 | + id: string; title: string; unit?: string; |
| |
23 | + kind?: "line" | "bar" | "area"; |
| 18 | points: Point[]; compare?: Point[]; |
24 | points: Point[]; compare?: Point[]; |
| 19 | }; |
25 | }; |
| 20 | −export type BreakItem = { label: string; value: number }; |
26 | +export type MultiSerie = { |
| |
27 | + id: string; title: string; unit?: string; |
| |
28 | + series: { label: string; points: Point[] }[]; // ≤ 4 séries |
| |
29 | +}; |
| |
30 | +export type StackedSerie = { |
| |
31 | + id: string; title: string; unit?: string; |
| |
32 | + keys: string[]; points: { t: string; values: number[] }[]; |
| |
33 | +}; |
| |
34 | +export type BreakItem = { label: string; value: number; delta_pct?: number | null }; |
| |
35 | +export type Distribution = { id: string; title: string; unit?: string; bins: { label: string; value: number }[] }; |
| |
36 | +export type Gauge = { id: string; label: string; value: number; max: number; unit?: string; help?: string }; |
| |
37 | +export type HourCell = { dow: number; hour: number; value: number }; // dow 0=lun … 6=dim |
| 21 | export type TableSpec = { id: string; title: string; columns: string[]; rows: (string | number)[][] }; |
38 | export type TableSpec = { id: string; title: string; columns: string[]; rows: (string | number)[][] }; |
| 22 | export type RecordFact = { label: string; value: string; date?: string }; |
39 | export type RecordFact = { label: string; value: string; date?: string }; |
| 23 | |
40 | |
| 32 | { id: "tout", label: "Tout" }, |
49 | { id: "tout", label: "Tout" }, |
| 33 | ]; |
50 | ]; |
| 34 | |
51 | |
| |
52 | +export 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 | +]; |
| |
59 | + |
| 35 | export const fmtInt = (n: number) => n.toLocaleString("fr-CA"); |
60 | export const fmtInt = (n: number) => n.toLocaleString("fr-CA"); |
| 36 | export const fmtNum = (n: number) => |
61 | export const fmtNum = (n: number) => |
| 37 | Number.isInteger(n) ? fmtInt(n) : n.toLocaleString("fr-CA", { maximumFractionDigits: 2 }); |
62 | Number.isInteger(n) ? fmtInt(n) : n.toLocaleString("fr-CA", { maximumFractionDigits: 2 }); |
| |
63 | +const fmtPct = (n: number) => `${n >= 0 ? "+" : ""}${fmtNum(n)} %`; |
| |
64 | + |
| |
65 | +/* Styles des séries multiples : couleur + motif de trait (l'identité n'est |
| |
66 | + jamais portée par la couleur seule — règle d'accessibilité). */ |
| |
67 | +const 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 | +]; |
| 38 | |
73 | |
| 39 | −/* ---------- KPI ---------- */ |
74 | +/* ---------- KPI (+ sparkline) ---------- */ |
| 40 | export function KpiCard({ k }: { k: Kpi }) { |
75 | export function KpiCard({ k }: { k: Kpi }) { |
| 41 | const up = (k.direction ?? ((k.delta_pct ?? 0) >= 0 ? "up" : "down")) === "up"; |
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]); |
| 42 | return ( |
89 | return ( |
| 43 | − <article className="card" style={{ padding: "14px 16px", minWidth: 0 }}> |
90 | + <article className="card" style={{ padding: "14px 16px", minWidth: 0 }} title={k.help}> |
| 44 | <p style={{ margin: 0, fontFamily: "var(--font-display)", fontWeight: 700, fontSize: "clamp(22px,2.4vw,30px)", letterSpacing: "-0.02em" }}> |
91 | <p style={{ margin: 0, fontFamily: "var(--font-display)", fontWeight: 700, fontSize: "clamp(22px,2.4vw,30px)", letterSpacing: "-0.02em" }}> |
| 45 | {typeof k.value === "number" ? fmtNum(k.value) : k.value} |
92 | {typeof k.value === "number" ? fmtNum(k.value) : k.value} |
| 46 | {k.unit ? <span style={{ fontSize: "0.6em", color: "var(--ink-2)" }}> {k.unit}</span> : null} |
93 | {k.unit ? <span style={{ fontSize: "0.6em", color: "var(--ink-2)" }}> {k.unit}</span> : null} |
| 47 | </p> |
94 | </p> |
| 48 | <p className="klabel" style={{ margin: "6px 0 0" }}>{k.label}</p> |
95 | <p className="klabel" style={{ margin: "6px 0 0" }}>{k.label}</p> |
| 49 | − {k.delta_pct !== undefined && k.delta_pct !== null && ( |
96 | + <div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-end", gap: 8 }}> |
| 50 | − <p style={{ margin: "8px 0 0", fontFamily: "var(--font-mono)", fontSize: 11, fontWeight: 700, color: up ? "var(--green)" : "var(--danger)" }}> |
97 | + {k.delta_pct !== undefined && k.delta_pct !== null ? ( |
| 51 | − {up ? "▲" : "▼"} {k.delta_pct >= 0 ? "+" : ""}{fmtNum(k.delta_pct)} % <span style={{ color: "var(--ink-3)", fontWeight: 500 }}>vs période préc.</span> |
98 | + <p style={{ margin: "8px 0 0", fontFamily: "var(--font-mono)", fontSize: 11, fontWeight: 700, color: up ? "var(--green)" : "var(--danger)" }}> |
| 52 | − </p> |
99 | + {up ? "▲" : "▼"} {fmtPct(k.delta_pct)} <span style={{ color: "var(--ink-3)", fontWeight: 500 }}>vs période préc.</span> |
| 53 | − )} |
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> |
| 54 | </article> |
109 | </article> |
| 55 | ); |
110 | ); |
| 56 | } |
111 | } |
| 84 | ); |
139 | ); |
| 85 | } |
140 | } |
| 86 | |
141 | |
| 87 | −/* ---------- Courbe ---------- */ |
142 | +/* ---------- Courbe / aire ---------- */ |
| 88 | export function LineChart({ serie, height = 240 }: { serie: Serie; height?: number }) { |
143 | export function LineChart({ serie, height = 240 }: { serie: Serie; height?: number }) { |
| 89 | const [hide, setHide] = useState<{ cur: boolean; cmp: boolean }>({ cur: false, cmp: false }); |
144 | const [hide, setHide] = useState<{ cur: boolean; cmp: boolean }>({ cur: false, cmp: false }); |
| 90 | const [hover, setHover] = useState<number | null>(null); |
145 | const [hover, setHover] = useState<number | null>(null); |
| 91 | const W = 720, H = height, PL = 54, PR = 10, PT = 14, PB = 26; |
146 | const W = 720, H = height, PL = 54, PR = 10, PT = 14, PB = 26; |
| 92 | const pts = serie.points ?? []; |
147 | const pts = serie.points ?? []; |
| |
148 | + if (serie.kind === "bar") return <VBarChart serie={serie} height={height} />; |
| 93 | if (pts.length < 2) return <EmptyBlock title={serie.title} />; |
149 | if (pts.length < 2) return <EmptyBlock title={serie.title} />; |
| 94 | const all = [...(hide.cur ? [] : pts), ...(!hide.cmp && serie.compare ? serie.compare : [])]; |
150 | const all = [...(hide.cur ? [] : pts), ...(!hide.cmp && serie.compare ? serie.compare : [])]; |
| 95 | const vmax = Math.max(...all.map((p) => p.v), 1); |
151 | const vmax = Math.max(...all.map((p) => p.v), 1); |
| 127 | {[0, Math.floor(pts.length / 2), pts.length - 1].map((i) => ( |
183 | {[0, Math.floor(pts.length / 2), pts.length - 1].map((i) => ( |
| 128 | <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> |
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> |
| 129 | ))} |
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 | + )} |
| 130 | {!hide.cmp && serie.compare && serie.compare.length > 1 && ( |
189 | {!hide.cmp && serie.compare && serie.compare.length > 1 && ( |
| 131 | <path d={path(serie.compare)} fill="none" stroke="var(--ink-3)" strokeWidth={1.4} strokeDasharray="4 4" /> |
190 | <path d={path(serie.compare)} fill="none" stroke="var(--ink-3)" strokeWidth={1.4} strokeDasharray="4 4" /> |
| 132 | )} |
191 | )} |
| 148 | ); |
207 | ); |
| 149 | } |
208 | } |
| 150 | |
209 | |
| 151 | −function LegendChip({ label, color, off, dashed, onClick }: { label: string; color: string; off: boolean; dashed?: boolean; onClick: () => void }) { |
210 | +function LegendChip({ label, color, off, dashed, onClick }: { label: string; color: string; off?: boolean; dashed?: boolean; onClick?: () => void }) { |
| 152 | return ( |
211 | return ( |
| 153 | − <button type="button" onClick={onClick} aria-pressed={!off} |
212 | + <button type="button" onClick={onClick} aria-pressed={!off} disabled={!onClick} |
| 154 | − style={{ display: "inline-flex", alignItems: "center", gap: 6, border: 0, background: "none", cursor: "pointer", opacity: off ? 0.4 : 1, fontFamily: "var(--font-mono)", fontSize: 10.5, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.06em", minHeight: 44 }}> |
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 }}> |
| 155 | <span style={{ width: 18, height: 0, borderTop: `3px ${dashed ? "dashed" : "solid"} ${color}` }} /> |
214 | <span style={{ width: 18, height: 0, borderTop: `3px ${dashed ? "dashed" : "solid"} ${color}` }} /> |
| 156 | {label} |
215 | {label} |
| 157 | </button> |
216 | </button> |
| 158 | ); |
217 | ); |
| 159 | } |
218 | } |
| 160 | |
219 | |
| 161 | −/* ---------- Barres horizontales (répartitions, géo) ---------- */ |
220 | +/* ---------- Multi-courbes (≤ 4 séries, motifs distincts) ---------- */ |
| |
221 | +export 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 | +} |
| |
289 | + |
| |
290 | +/* ---------- Barres verticales (volumes quotidiens, histogrammes) ---------- */ |
| |
291 | +export 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 | +} |
| |
335 | + |
| |
336 | +/* ---------- Histogramme (distribution) ---------- */ |
| |
337 | +export 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 | +} |
| |
344 | + |
| |
345 | +/* ---------- Barres empilées (composition dans le temps) ---------- */ |
| |
346 | +export 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 | +} |
| |
413 | + |
| |
414 | +/* ---------- Barres horizontales (répartitions, géo) — deltas optionnels ---------- */ |
| 162 | export function BarChart({ title, items, unit }: { title: string; items: BreakItem[]; unit?: string }) { |
415 | export function BarChart({ title, items, unit }: { title: string; items: BreakItem[]; unit?: string }) { |
| 163 | const rows = (items ?? []).slice(0, 14); |
416 | const rows = (items ?? []).slice(0, 14); |
| 164 | if (!rows.length) return <EmptyBlock title={title} />; |
417 | if (!rows.length) return <EmptyBlock title={title} />; |
| 169 | <div style={{ marginTop: 12, display: "grid", gap: 9 }}> |
422 | <div style={{ marginTop: 12, display: "grid", gap: 9 }}> |
| 170 | {rows.map((r) => ( |
423 | {rows.map((r) => ( |
| 171 | <div key={r.label} title={`${r.label} — ${fmtNum(r.value)}${unit ? ` ${unit}` : ""}`}> |
424 | <div key={r.label} title={`${r.label} — ${fmtNum(r.value)}${unit ? ` ${unit}` : ""}`}> |
| 172 | − <div style={{ display: "flex", justifyContent: "space-between", fontSize: 12.5 }}> |
425 | + <div style={{ display: "flex", justifyContent: "space-between", gap: 8, fontSize: 12.5 }}> |
| 173 | <span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{r.label}</span> |
426 | <span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{r.label}</span> |
| 174 | − <b style={{ fontFamily: "var(--font-mono)", fontSize: 11.5 }}>{fmtNum(r.value)}{unit ? ` ${unit}` : ""}</b> |
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> |
| 175 | </div> |
435 | </div> |
| 176 | <div style={{ marginTop: 3, height: 12, background: "rgba(20,24,20,0.06)", borderRadius: "0 3px 3px 0" }}> |
436 | <div style={{ marginTop: 3, height: 12, background: "rgba(20,24,20,0.06)", borderRadius: "0 3px 3px 0" }}> |
| 177 | <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" }} /> |
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" }} /> |
| 224 | ); |
484 | ); |
| 225 | } |
485 | } |
| 226 | |
486 | |
| |
487 | +/* ---------- Jauge (taux, complétude, couverture) ---------- */ |
| |
488 | +export 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 | +} |
| |
506 | + |
| 227 | /* ---------- Calendrier de chaleur ---------- */ |
507 | /* ---------- Calendrier de chaleur ---------- */ |
| 228 | export function CalendarHeatmap({ title, cells }: { title: string; cells: { date: string; value: number }[] }) { |
508 | export function CalendarHeatmap({ title, cells }: { title: string; cells: { date: string; value: number }[] }) { |
| 229 | if (!cells?.length) return <EmptyBlock title={title} />; |
509 | if (!cells?.length) return <EmptyBlock title={title} />; |
| 261 | ); |
541 | ); |
| 262 | } |
542 | } |
| 263 | |
543 | |
| |
544 | +/* ---------- Heatmap horaire 7 × 24 (activité par heure) ---------- */ |
| |
545 | +const DOW = ["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"]; |
| |
546 | +export 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 | +} |
| |
577 | + |
| |
578 | +/* ---------- Statistiques de série (min/max/moy/méd/σ) ---------- */ |
| |
579 | +export 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 | +} |
| |
601 | + |
| 264 | /* ---------- Tableau : tri, recherche, pagination ---------- */ |
602 | /* ---------- Tableau : tri, recherche, pagination ---------- */ |
| 265 | export function DataTable({ spec, pageSize = 25 }: { spec: TableSpec; pageSize?: number }) { |
603 | export function DataTable({ spec, pageSize = 25 }: { spec: TableSpec; pageSize?: number }) { |
| 266 | const [q, setQ] = useState(""); |
604 | const [q, setQ] = useState(""); |
| 338 | ); |
676 | ); |
| 339 | } |
677 | } |
| 340 | |
678 | |
| 341 | −/* ---------- Bouton PDF ---------- */ |
679 | +/* ---------- Menu de rapports PDF (5 rapports + personnalisé v3) ---------- */ |
| 342 | export function PdfButton({ period, from, to, endpoint = "/api/stats/report" }: { period: string; from?: string; to?: string; endpoint?: string }) { |
680 | export function PdfButton({ period, from, to, endpoint = "/api/stats/report" }: { period: string; from?: string; to?: string; endpoint?: string }) { |
| 343 | − const [busy, setBusy] = useState(false); |
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]); |
| 344 | const url = (mode: string) => { |
693 | const url = (mode: string) => { |
| 345 | const p = new URLSearchParams({ period, mode }); |
694 | const p = new URLSearchParams({ period, mode }); |
| 346 | if (from) p.set("from", from); |
695 | if (from) p.set("from", from); |
| 348 | return `${endpoint}?${p}`; |
697 | return `${endpoint}?${p}`; |
| 349 | }; |
698 | }; |
| 350 | const dl = (mode: string) => { |
699 | const dl = (mode: string) => { |
| 351 | − setBusy(true); |
700 | + setBusy(mode); |
| |
701 | + setOpen(false); |
| 352 | const a = document.createElement("a"); |
702 | const a = document.createElement("a"); |
| 353 | a.href = url(mode); |
703 | a.href = url(mode); |
| 354 | a.download = ""; |
704 | a.download = ""; |
| 355 | document.body.appendChild(a); |
705 | document.body.appendChild(a); |
| 356 | a.click(); |
706 | a.click(); |
| 357 | a.remove(); |
707 | a.remove(); |
| 358 | − setTimeout(() => setBusy(false), 2500); |
708 | + setTimeout(() => setBusy(null), 3000); |
| 359 | }; |
709 | }; |
| 360 | return ( |
710 | return ( |
| 361 | − <span style={{ display: "inline-flex", gap: 8, flexWrap: "wrap" }}> |
711 | + <span ref={box} style={{ position: "relative", display: "inline-flex", gap: 8, flexWrap: "wrap" }}> |
| 362 | − <button type="button" className="btn btn-primary" onClick={() => dl("complet")} disabled={busy}> |
712 | + <button type="button" className="btn btn-primary" onClick={() => dl("complet")} disabled={!!busy}> |
| 363 | − {busy ? "Génération…" : "⬇ Télécharger le rapport PDF"} |
713 | + {busy ? "Génération…" : "⬇ Rapport PDF complet"} |
| 364 | </button> |
714 | </button> |
| 365 | − <button type="button" className="btn btn-ghost" onClick={() => dl("synthese")} disabled={busy}> |
715 | + <button type="button" className="btn btn-ghost" onClick={() => setOpen((o) => !o)} disabled={!!busy} |
| 366 | − Synthèse (2 p.) |
716 | + aria-haspopup="menu" aria-expanded={open}> |
| |
717 | + Autres rapports ▾ |
| 367 | </button> |
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 | + )} |
| 368 | </span> |
739 | </span> |
| 369 | ); |
740 | ); |
| 370 | } |
741 | } |
| 371 | |
742 | |
| |
743 | +/* ---------- v3 : constructeur de rapports personnalisés ---------- |
| |
744 | + Compose un PDF bloc par bloc : catalogue dérivé du dashboard |
| |
745 | + (GET /api/stats/catalog), rendu au choix par bloc, ordre libre, modèles |
| |
746 | + sauvegardés en localStorage (clé ka-stats-rapports, propre au site). |
| |
747 | + Contrat : SPEC.md §3bis. Rendu dans PdfButton — aucune modif des pages. */ |
| |
748 | +export type CatalogBlock = { |
| |
749 | + key: string; section: string; title: string; |
| |
750 | + renders: string[]; default_render: string; count?: number; |
| |
751 | +}; |
| |
752 | +type BuilderSel = { key: string; render: string }; |
| |
753 | +type BuilderTpl = { name: string; title: string; blocks: BuilderSel[] }; |
| |
754 | + |
| |
755 | +const 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 | +}; |
| |
761 | +const 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 | +}; |
| |
768 | +const TPL_KEY = "ka-stats-rapports"; |
| |
769 | + |
| |
770 | +function loadTemplates(): BuilderTpl[] { |
| |
771 | + try { return JSON.parse(localStorage.getItem(TPL_KEY) ?? "[]"); } |
| |
772 | + catch { return []; } |
| |
773 | +} |
| |
774 | +function saveTemplates(t: BuilderTpl[]) { |
| |
775 | + try { localStorage.setItem(TPL_KEY, JSON.stringify(t)); } catch { /* plein/privé */ } |
| |
776 | +} |
| |
777 | + |
| |
778 | +export 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"); |
| |
790 | + |
| |
791 | + 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]); |
| |
800 | + |
| |
801 | + 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]); |
| |
808 | + |
| |
809 | + 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 | + }); |
| |
817 | + |
| |
818 | + 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 | + }; |
| |
841 | + |
| |
842 | + 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" }; |
| |
849 | + |
| |
850 | + 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 | +} |
| |
946 | + |
| 372 | /* ---------- États ---------- */ |
947 | /* ---------- États ---------- */ |
| 373 | export function EmptyBlock({ title }: { title: string }) { |
948 | export function EmptyBlock({ title }: { title: string }) { |
| 374 | return ( |
949 | return ( |