| 2 |
2 |
// ka-ui/stats/kacharts.tsx — kit de graphiques SVG du module Stats commun |
| 3 |
3 |
// Groupe KA (zéro dépendance, React 18+). Style : design system ka-ui |
| 4 |
4 |
// (bordures encre, accent de la plateforme via var(--accent)). |
| 5 |
|
−// Composants : KpiCard, PeriodSelector, LineChart (infobulle + légende |
| 6 |
|
−// cliquable + comparaison N-1), BarChart, Donut, CalendarHeatmap, DataTable |
| 7 |
|
−// (tri/recherche/pagination), RecordCard, PdfButton, EmptyBlock, Fraicheur. |
| 8 |
|
−import { useMemo, useState } from "react"; |
|
5 |
+// v2 — composants : KpiCard (+sparkline), PeriodSelector, LineChart (ligne/ |
|
6 |
+// aire, infobulle + légende cliquable + comparaison N-1), MultiLineChart |
|
7 |
+// (≤4 séries, pointillés distincts = jamais la couleur seule), VBarChart |
|
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 |
15 |
export type Kpi = { |
| 12 |
16 |
id: string; label: string; value: number | string; unit?: string; |
| 13 |
17 |
delta_pct?: number | null; direction?: "up" | "down"; |
|
18 |
+ spark?: Point[]; help?: string; |
| 14 |
19 |
}; |
| 15 |
20 |
export type Point = { t: string; v: number }; |
| 16 |
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 |
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 |
38 |
export type TableSpec = { id: string; title: string; columns: string[]; rows: (string | number)[][] }; |
| 22 |
39 |
export type RecordFact = { label: string; value: string; date?: string }; |
| 23 |
40 |
|
| 32 |
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 |
60 |
export const fmtInt = (n: number) => n.toLocaleString("fr-CA"); |
| 36 |
61 |
export const fmtNum = (n: number) => |
| 37 |
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 |
75 |
export function KpiCard({ k }: { k: Kpi }) { |
| 41 |
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 |
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 |
91 |
<p style={{ margin: 0, fontFamily: "var(--font-display)", fontWeight: 700, fontSize: "clamp(22px,2.4vw,30px)", letterSpacing: "-0.02em" }}> |
| 45 |
92 |
{typeof k.value === "number" ? fmtNum(k.value) : k.value} |
| 46 |
93 |
{k.unit ? <span style={{ fontSize: "0.6em", color: "var(--ink-2)" }}> {k.unit}</span> : null} |
| 47 |
94 |
</p> |
| 48 |
95 |
<p className="klabel" style={{ margin: "6px 0 0" }}>{k.label}</p> |
| 49 |
|
− {k.delta_pct !== undefined && k.delta_pct !== null && ( |
| 50 |
|
− <p style={{ margin: "8px 0 0", fontFamily: "var(--font-mono)", fontSize: 11, fontWeight: 700, color: up ? "var(--green)" : "var(--danger)" }}> |
| 51 |
|
− {up ? "▲" : "▼"} {k.delta_pct >= 0 ? "+" : ""}{fmtNum(k.delta_pct)} % <span style={{ color: "var(--ink-3)", fontWeight: 500 }}>vs période préc.</span> |
| 52 |
|
− </p> |
| 53 |
|
− )} |
|
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> |
| 54 |
109 |
</article> |
| 55 |
110 |
); |
| 56 |
111 |
} |
| 84 |
139 |
); |
| 85 |
140 |
} |
| 86 |
141 |
|
| 87 |
|
−/* ---------- Courbe ---------- */ |
|
142 |
+/* ---------- Courbe / aire ---------- */ |
| 88 |
143 |
export function LineChart({ serie, height = 240 }: { serie: Serie; height?: number }) { |
| 89 |
144 |
const [hide, setHide] = useState<{ cur: boolean; cmp: boolean }>({ cur: false, cmp: false }); |
| 90 |
145 |
const [hover, setHover] = useState<number | null>(null); |
| 91 |
146 |
const W = 720, H = height, PL = 54, PR = 10, PT = 14, PB = 26; |
| 92 |
147 |
const pts = serie.points ?? []; |
|
148 |
+ if (serie.kind === "bar") return <VBarChart serie={serie} height={height} />; |
| 93 |
149 |
if (pts.length < 2) return <EmptyBlock title={serie.title} />; |
| 94 |
150 |
const all = [...(hide.cur ? [] : pts), ...(!hide.cmp && serie.compare ? serie.compare : [])]; |
| 95 |
151 |
const vmax = Math.max(...all.map((p) => p.v), 1); |
| 127 |
183 |
{[0, Math.floor(pts.length / 2), pts.length - 1].map((i) => ( |
| 128 |
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 |
189 |
{!hide.cmp && serie.compare && serie.compare.length > 1 && ( |
| 131 |
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 |
211 |
return ( |
| 153 |
|
− <button type="button" onClick={onClick} aria-pressed={!off} |
| 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 }}> |
|
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 }}> |
| 155 |
214 |
<span style={{ width: 18, height: 0, borderTop: `3px ${dashed ? "dashed" : "solid"} ${color}` }} /> |
| 156 |
215 |
{label} |
| 157 |
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 |
415 |
export function BarChart({ title, items, unit }: { title: string; items: BreakItem[]; unit?: string }) { |
| 163 |
416 |
const rows = (items ?? []).slice(0, 14); |
| 164 |
417 |
if (!rows.length) return <EmptyBlock title={title} />; |
| 169 |
422 |
<div style={{ marginTop: 12, display: "grid", gap: 9 }}> |
| 170 |
423 |
{rows.map((r) => ( |
| 171 |
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 |
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 |
435 |
</div> |
| 176 |
436 |
<div style={{ marginTop: 3, height: 12, background: "rgba(20,24,20,0.06)", borderRadius: "0 3px 3px 0" }}> |
| 177 |
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 |
507 |
/* ---------- Calendrier de chaleur ---------- */ |
| 228 |
508 |
export function CalendarHeatmap({ title, cells }: { title: string; cells: { date: string; value: number }[] }) { |
| 229 |
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 |
602 |
/* ---------- Tableau : tri, recherche, pagination ---------- */ |
| 265 |
603 |
export function DataTable({ spec, pageSize = 25 }: { spec: TableSpec; pageSize?: number }) { |
| 266 |
604 |
const [q, setQ] = useState(""); |
| 338 |
676 |
); |
| 339 |
677 |
} |
| 340 |
678 |
|
| 341 |
|
−/* ---------- Bouton PDF ---------- */ |
|
679 |
+/* ---------- Menu de rapports PDF (5 rapports + personnalisé v3) ---------- */ |
| 342 |
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 |
693 |
const url = (mode: string) => { |
| 345 |
694 |
const p = new URLSearchParams({ period, mode }); |
| 346 |
695 |
if (from) p.set("from", from); |
| 348 |
697 |
return `${endpoint}?${p}`; |
| 349 |
698 |
}; |
| 350 |
699 |
const dl = (mode: string) => { |
| 351 |
|
− setBusy(true); |
|
700 |
+ setBusy(mode); |
|
701 |
+ setOpen(false); |
| 352 |
702 |
const a = document.createElement("a"); |
| 353 |
703 |
a.href = url(mode); |
| 354 |
704 |
a.download = ""; |
| 355 |
705 |
document.body.appendChild(a); |
| 356 |
706 |
a.click(); |
| 357 |
707 |
a.remove(); |
| 358 |
|
− setTimeout(() => setBusy(false), 2500); |
|
708 |
+ setTimeout(() => setBusy(null), 3000); |
| 359 |
709 |
}; |
| 360 |
710 |
return ( |
| 361 |
|
− <span style={{ display: "inline-flex", gap: 8, flexWrap: "wrap" }}> |
| 362 |
|
− <button type="button" className="btn btn-primary" onClick={() => dl("complet")} disabled={busy}> |
| 363 |
|
− {busy ? "Génération…" : "⬇ Télécharger le rapport PDF"} |
|
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"} |
| 364 |
714 |
</button> |
| 365 |
|
− <button type="button" className="btn btn-ghost" onClick={() => dl("synthese")} disabled={busy}> |
| 366 |
|
− Synthèse (2 p.) |
|
715 |
+ <button type="button" className="btn btn-ghost" onClick={() => setOpen((o) => !o)} disabled={!!busy} |
|
716 |
+ aria-haspopup="menu" aria-expanded={open}> |
|
717 |
+ Autres rapports ▾ |
| 367 |
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 |
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 |
947 |
/* ---------- États ---------- */ |
| 373 |
948 |
export function EmptyBlock({ title }: { title: string }) { |
| 374 |
949 |
return ( |