Page /stats refaite : tableau de bord analytique Groupe KA + rapport PDF
- autoka/statsdash.py : moteur du dashboard (contrat ka/stats/SPEC.md) — KPI avec deltas vs période précédente, séries quotidiennes reconstruites du cycle de vie réel des annonces (inventaire, nouveautés, prix moyen), répartitions (carburant, boîte, marques, années), géo par région, heatmap, tableaux (marques/modèles/concessionnaires), records ; cache serveur 5 min par période, aucun delta inventé avant le début réel des données. - API : GET /api/stats/dashboard?period=… et GET /api/stats/report (PDF Groupe-KA complet ou synthèse, filename normalisé groupe-ka_…). - autoka/kapdf.py : moteur PDF commun fpdf2 (couverture wordmark Auto·Ka accent #ff5a2a, sommaire, KPI, graphiques vectoriels, tableaux zébrés, records, page contact) + substitutions latin-1 (≤ ≥). - frontend : Stats.tsx réécrite sur le kit ka/stats/kacharts.tsx — PdfButton + fraîcheur, sélecteur de période (refetch), KPI, courbes interactives, anneau, barres, heatmap calendrier, DataTables triables, records ; mobile 360/768/1440 vérifié. - requirements.txt : fpdf2. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
8 changed files +2,204 −148
added
autoka/kapdf.py
+559 −0
@@ -0,0 +1,559 @@ | ||
| 1 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +# ka-ui/stats/kapdf.py — moteur PDF commun Groupe KA (fpdf2). | |
| 3 | +# Consomme le JSON du contrat /api/stats/dashboard (voir SPEC.md) et produit | |
| 4 | +# le rapport estampillé Groupe-KA : couverture, sommaire, KPI, graphiques | |
| 5 | +# VECTORIELS (courbes/barres/anneaux), tableaux paginés, records, page de fin. | |
| 6 | +# Usage : | |
| 7 | +# from kapdf import GroupeKAReport | |
| 8 | +# pdf_bytes = GroupeKAReport(site={"wordmark":"Lou·Ka","accent":"#ff6a00", | |
| 9 | +# "domain":"www.lou-ka.com","tagline":"…"}, dashboard=dash_json, | |
| 10 | +# mode="complet").build() | |
| 11 | +# Dépendance : pip install fpdf2 (aucune autre) | |
| 12 | +from __future__ import annotations | |
| 13 | + | |
| 14 | +import math | |
| 15 | +from datetime import datetime | |
| 16 | +from zoneinfo import ZoneInfo | |
| 17 | + | |
| 18 | +from fpdf import FPDF | |
| 19 | + | |
| 20 | +INK = (20, 24, 20) | |
| 21 | +INK2 = (77, 85, 81) | |
| 22 | +INK3 = (139, 146, 140) | |
| 23 | +PAPER = (245, 243, 238) | |
| 24 | +SURFACE2 = (250, 249, 245) | |
| 25 | +GREEN = (28, 92, 65) | |
| 26 | +DANGER = (179, 66, 58) | |
| 27 | +WHITE = (255, 255, 255) | |
| 28 | + | |
| 29 | +EMAILS = [ | |
| 30 | + ("contact@groupe-ka.com", "Projets, partenariats & données"), | |
| 31 | + ("info@groupe-ka.com", "Médias & questions générales"), | |
| 32 | + ("admin@groupe-ka.com", "Légal, vie privée & Loi 25"), | |
| 33 | +] | |
| 34 | +DISCLAIMER = ( | |
| 35 | + "Groupe KA est un agrégateur de contenu : nous ne vendons rien, ne louons " | |
| 36 | + "rien et ne sommes partie à aucune transaction. Données lues à la source, " | |
| 37 | + "rien d'inventé, tout est traçable." | |
| 38 | +) | |
| 39 | + | |
| 40 | + | |
| 41 | +def _hex(c: str) -> tuple[int, int, int]: | |
| 42 | + c = c.lstrip("#") | |
| 43 | + return tuple(int(c[i : i + 2], 16) for i in (0, 2, 4)) # type: ignore | |
| 44 | + | |
| 45 | + | |
| 46 | +def _fr(n) -> str: | |
| 47 | + if isinstance(n, float) and not n.is_integer(): | |
| 48 | + return f"{n:,.2f}".replace(",", " ").replace(".", ",") | |
| 49 | + return f"{int(n):,}".replace(",", " ") | |
| 50 | + | |
| 51 | + | |
| 52 | +_SUBST = { | |
| 53 | + "—": "-", "–": "-", "→": "->", "▲": "+", "▼": "-", | |
| 54 | + "…": "...", "’": "'", "‘": "'", "“": '"', "”": '"', | |
| 55 | + "œ": "oe", "Œ": "OE", "−": "-", " ": " ", " ": " ", | |
| 56 | + "≤": "<=", "≥": ">=", | |
| 57 | +} | |
| 58 | + | |
| 59 | + | |
| 60 | +def _latin1(s: str) -> str: | |
| 61 | + for k, v in _SUBST.items(): | |
| 62 | + s = s.replace(k, v) | |
| 63 | + return s.encode("latin-1", "replace").decode("latin-1") | |
| 64 | + | |
| 65 | + | |
| 66 | +class _PDF(FPDF): | |
| 67 | + """FPDF avec en-tête/pied Groupe-KA sur chaque page (sauf couverture). | |
| 68 | + Les polices core sont latin-1 : normalize_text sanitise en amont.""" | |
| 69 | + | |
| 70 | + def normalize_text(self, text): | |
| 71 | + return super().normalize_text(_latin1(text)) | |
| 72 | + | |
| 73 | + def __init__(self, brand: str, accent: tuple, period_label: str): | |
| 74 | + super().__init__(orientation="P", unit="mm", format="A4") | |
| 75 | + self.brand = brand | |
| 76 | + self.accent = accent | |
| 77 | + self.period_label = period_label | |
| 78 | + self.cover_mode = False | |
| 79 | + self.set_margins(18, 20, 18) | |
| 80 | + self.set_auto_page_break(True, margin=22) | |
| 81 | + | |
| 82 | + def header(self): | |
| 83 | + if self.cover_mode: | |
| 84 | + return | |
| 85 | + self.set_font("helvetica", "B", 8.5) | |
| 86 | + self.set_text_color(*INK) | |
| 87 | + self.set_xy(18, 9) | |
| 88 | + self.cell(0, 5, f"Groupe KA · {self.brand}") | |
| 89 | + self.set_font("helvetica", "", 8) | |
| 90 | + self.set_text_color(*INK3) | |
| 91 | + self.set_xy(18, 9) | |
| 92 | + self.cell(0, 5, "Rapport statistique", align="R") | |
| 93 | + self.set_draw_color(*INK) | |
| 94 | + self.set_line_width(0.5) | |
| 95 | + self.line(18, 15.5, 192, 15.5) | |
| 96 | + self.set_y(20) | |
| 97 | + | |
| 98 | + def footer(self): | |
| 99 | + if self.cover_mode: | |
| 100 | + return | |
| 101 | + self.set_y(-15) | |
| 102 | + self.set_draw_color(*INK3) | |
| 103 | + self.set_line_width(0.2) | |
| 104 | + self.line(18, self.get_y() - 1.5, 192, self.get_y() - 1.5) | |
| 105 | + self.set_font("helvetica", "", 7.5) | |
| 106 | + self.set_text_color(*INK3) | |
| 107 | + year = datetime.now(ZoneInfo("America/Toronto")).year | |
| 108 | + self.cell(130, 5, f"© Groupe-KA — {year} — groupe-ka.com · {self.period_label}") | |
| 109 | + self.cell(0, 5, f"p. {self.page_no()}/{{nb}}", align="R") | |
| 110 | + | |
| 111 | + | |
| 112 | +class GroupeKAReport: | |
| 113 | + def __init__(self, site: dict, dashboard: dict, mode: str = "complet"): | |
| 114 | + self.site = site | |
| 115 | + self.d = dashboard | |
| 116 | + self.mode = mode | |
| 117 | + self.accent = _hex(site.get("accent", "#d9f26b")) | |
| 118 | + period = dashboard.get("period", {}) or {} | |
| 119 | + self.period_label = period.get("label") or "toute la période" | |
| 120 | + self.pdf = _PDF(site.get("wordmark", ""), self.accent, self.period_label) | |
| 121 | + self.toc: list[tuple[str, int]] = [] | |
| 122 | + | |
| 123 | + # ---------- primitives ---------- | |
| 124 | + def _card(self, x, y, w, h, fill=WHITE): | |
| 125 | + p = self.pdf | |
| 126 | + p.set_draw_color(*INK) | |
| 127 | + p.set_line_width(0.45) | |
| 128 | + p.set_fill_color(*fill) | |
| 129 | + p.rect(x, y, w, h, style="DF", round_corners=True, corner_radius=2.2) | |
| 130 | + | |
| 131 | + def _kicker(self, text): | |
| 132 | + p = self.pdf | |
| 133 | + p.set_font("helvetica", "B", 8) | |
| 134 | + p.set_text_color(*GREEN) | |
| 135 | + p.set_draw_color(*GREEN) | |
| 136 | + p.set_line_width(0.6) | |
| 137 | + y = p.get_y() + 2 | |
| 138 | + p.line(p.l_margin, y, p.l_margin + 7, y) | |
| 139 | + p.set_xy(p.l_margin + 9, y - 2.5) | |
| 140 | + p.cell(0, 5, text.upper()) | |
| 141 | + p.ln(8) | |
| 142 | + | |
| 143 | + def _section_title(self, title): | |
| 144 | + if self.pdf.get_y() > 240: | |
| 145 | + self.pdf.add_page() | |
| 146 | + self._kicker("Groupe KA · " + self.site.get("wordmark", "")) | |
| 147 | + self.pdf.set_font("helvetica", "B", 15) | |
| 148 | + self.pdf.set_text_color(*INK) | |
| 149 | + self.pdf.set_x(self.pdf.l_margin) | |
| 150 | + self.pdf.cell(0, 8, title) | |
| 151 | + self.toc.append((title, self.pdf.page_no())) | |
| 152 | + self.pdf.ln(11) | |
| 153 | + | |
| 154 | + # ---------- pages ---------- | |
| 155 | + def _cover(self): | |
| 156 | + p = self.pdf | |
| 157 | + p.cover_mode = True | |
| 158 | + p.set_auto_page_break(False) | |
| 159 | + p.add_page() | |
| 160 | + p.set_fill_color(*PAPER) | |
| 161 | + p.rect(0, 0, 210, 297, style="F") | |
| 162 | + p.set_draw_color(*INK) | |
| 163 | + p.set_line_width(1.0) | |
| 164 | + p.rect(10, 10, 190, 277) | |
| 165 | + # kicker | |
| 166 | + p.set_font("helvetica", "B", 10) | |
| 167 | + p.set_text_color(*GREEN) | |
| 168 | + p.set_xy(24, 34) | |
| 169 | + p.cell(0, 6, "GROUPE KA · RAPPORT STATISTIQUE") | |
| 170 | + # wordmark : partie gauche + boîte encre/accent | |
| 171 | + wm = self.site.get("wordmark", "") | |
| 172 | + left, boxed = (wm.split("·") + [None])[:2] if "·" in wm else (wm, None) | |
| 173 | + p.set_xy(24, 70) | |
| 174 | + p.set_font("helvetica", "B", 40) | |
| 175 | + p.set_text_color(*INK) | |
| 176 | + p.cell(p.get_string_width(left) + 2, 20, left) | |
| 177 | + if boxed: | |
| 178 | + bw = p.get_string_width(boxed) + 12 | |
| 179 | + x = p.get_x() + 2 | |
| 180 | + p.set_fill_color(*INK) | |
| 181 | + p.rect(x, 68, bw, 22, style="F", round_corners=True, corner_radius=3) | |
| 182 | + p.set_text_color(*self.accent) | |
| 183 | + p.set_xy(x + 6, 70) | |
| 184 | + p.cell(bw - 12, 18, boxed) | |
| 185 | + p.set_xy(24, 100) | |
| 186 | + p.set_font("helvetica", "", 13) | |
| 187 | + p.set_text_color(*INK2) | |
| 188 | + p.multi_cell(150, 7, f"Rapport statistique — {wm}") | |
| 189 | + now = datetime.now(ZoneInfo("America/Toronto")) | |
| 190 | + per = self.d.get("period", {}) or {} | |
| 191 | + p.set_xy(24, 125) | |
| 192 | + p.set_font("helvetica", "", 10.5) | |
| 193 | + rows = [ | |
| 194 | + ("Période couverte", self.period_label + (f" ({per.get('from')} → {per.get('to')})" if per.get("from") else "")), | |
| 195 | + ("Généré le", now.strftime("%Y-%m-%d à %H:%M") + " (heure de l'Est)"), | |
| 196 | + ("Plateforme", "https://" + self.site.get("domain", "")), | |
| 197 | + ("Mode", "Rapport complet" if self.mode == "complet" else "Synthèse"), | |
| 198 | + ] | |
| 199 | + y = 128 | |
| 200 | + for k, v in rows: | |
| 201 | + p.set_xy(24, y) | |
| 202 | + p.set_text_color(*INK3) | |
| 203 | + p.cell(40, 6, k) | |
| 204 | + p.set_text_color(*INK) | |
| 205 | + p.set_font("helvetica", "B", 10.5) | |
| 206 | + p.cell(0, 6, str(v)) | |
| 207 | + p.set_font("helvetica", "", 10.5) | |
| 208 | + y += 8 | |
| 209 | + # bande encre au pied | |
| 210 | + p.set_fill_color(*INK) | |
| 211 | + p.rect(10, 262, 190, 25, style="F") | |
| 212 | + p.set_xy(24, 270) | |
| 213 | + p.set_font("helvetica", "B", 12) | |
| 214 | + p.set_text_color(*WHITE) | |
| 215 | + p.cell(60, 8, "par Groupe ") | |
| 216 | + p.set_text_color(*self.accent) | |
| 217 | + p.set_xy(24 + p.get_string_width("par Groupe ") + 1, 270) | |
| 218 | + p.cell(20, 8, "KA") | |
| 219 | + p.set_font("helvetica", "B", 10) | |
| 220 | + p.set_xy(24, 270) | |
| 221 | + p.set_text_color(*self.accent) | |
| 222 | + p.cell(162, 8, "groupe-ka.com", align="R") | |
| 223 | + p.set_auto_page_break(True, margin=22) | |
| 224 | + p.cover_mode = False | |
| 225 | + | |
| 226 | + def _kpis(self): | |
| 227 | + kpis = self.d.get("kpis") or [] | |
| 228 | + if not kpis: | |
| 229 | + return | |
| 230 | + self._section_title("Synthèse des indicateurs") | |
| 231 | + p = self.pdf | |
| 232 | + cols, gw, gh, gap = 3, 56, 26, 3 | |
| 233 | + x0, y = p.l_margin, p.get_y() | |
| 234 | + for i, k in enumerate(kpis[:9]): | |
| 235 | + x = x0 + (i % cols) * (gw + gap) | |
| 236 | + if i and i % cols == 0: | |
| 237 | + y += gh + gap | |
| 238 | + if y > 250: | |
| 239 | + p.add_page(); y = p.get_y() | |
| 240 | + self._card(x, y, gw, gh) | |
| 241 | + p.set_xy(x + 4, y + 4) | |
| 242 | + p.set_font("helvetica", "B", 14) | |
| 243 | + p.set_text_color(*INK) | |
| 244 | + val = k.get("value") | |
| 245 | + p.cell(gw - 8, 7, (_fr(val) if isinstance(val, (int, float)) else str(val)) + (" " + k["unit"] if k.get("unit") else "")) | |
| 246 | + p.set_xy(x + 4, y + 12) | |
| 247 | + p.set_font("helvetica", "", 7.6) | |
| 248 | + p.set_text_color(*INK2) | |
| 249 | + p.multi_cell(gw - 8, 3.6, str(k.get("label", ""))[:70]) | |
| 250 | + if k.get("delta_pct") is not None: | |
| 251 | + up = (k.get("direction") or ("up" if k["delta_pct"] >= 0 else "down")) == "up" | |
| 252 | + p.set_xy(x + 4, y + gh - 6.5) | |
| 253 | + p.set_font("helvetica", "B", 8) | |
| 254 | + p.set_text_color(*(GREEN if up else DANGER)) | |
| 255 | + arrow = "+" if k["delta_pct"] >= 0 else "" | |
| 256 | + p.cell(gw - 8, 4, f"{'▲' if up else '▼'} {arrow}{str(k['delta_pct']).replace('.', ',')} % vs période préc.") | |
| 257 | + p.set_y(y + gh + 8) | |
| 258 | + | |
| 259 | + def _line_chart(self, s): | |
| 260 | + p = self.pdf | |
| 261 | + pts = s.get("points") or [] | |
| 262 | + if len(pts) < 2: | |
| 263 | + return | |
| 264 | + if p.get_y() > 200: | |
| 265 | + p.add_page() | |
| 266 | + p.set_font("helvetica", "B", 10) | |
| 267 | + p.set_text_color(*INK) | |
| 268 | + p.cell(0, 6, s.get("title", "")) | |
| 269 | + p.ln(7) | |
| 270 | + x0, y0, w, h = p.l_margin, p.get_y(), 174, 52 | |
| 271 | + self._card(x0, y0, w, h, fill=WHITE) | |
| 272 | + cx, cy, cw, ch = x0 + 12, y0 + 6, w - 20, h - 16 | |
| 273 | + vals = [pt["v"] for pt in pts] + [c["v"] for c in (s.get("compare") or [])] | |
| 274 | + vmax = max(vals) or 1 | |
| 275 | + vmin = min(0, min(vals)) | |
| 276 | + rng = (vmax - vmin) or 1 | |
| 277 | + # grille + graduations | |
| 278 | + p.set_font("helvetica", "", 6.3) | |
| 279 | + p.set_text_color(*INK3) | |
| 280 | + p.set_draw_color(200, 200, 195) | |
| 281 | + p.set_line_width(0.15) | |
| 282 | + for g in range(5): | |
| 283 | + gy = cy + ch - ch * g / 4 | |
| 284 | + p.line(cx, gy, cx + cw, gy) | |
| 285 | + p.set_xy(x0 + 1, gy - 1.6) | |
| 286 | + p.cell(10, 3, _fr(vmin + rng * g / 4), align="R") | |
| 287 | + | |
| 288 | + def draw(series, color, width, dash=None): | |
| 289 | + n = len(series) | |
| 290 | + p.set_draw_color(*color) | |
| 291 | + p.set_line_width(width) | |
| 292 | + if dash: | |
| 293 | + p.set_dash_pattern(dash=1.2, gap=1.2) | |
| 294 | + last = None | |
| 295 | + for i, pt in enumerate(series): | |
| 296 | + px = cx + cw * (i / (n - 1)) | |
| 297 | + py = cy + ch - ch * ((pt["v"] - vmin) / rng) | |
| 298 | + if last: | |
| 299 | + p.line(last[0], last[1], px, py) | |
| 300 | + last = (px, py) | |
| 301 | + p.set_dash_pattern() | |
| 302 | + | |
| 303 | + if s.get("compare"): | |
| 304 | + draw(s["compare"], INK3, 0.35, dash=True) | |
| 305 | + draw(pts, self.accent, 0.7) | |
| 306 | + # libellés d'axe X (premier / milieu / dernier) | |
| 307 | + p.set_text_color(*INK3) | |
| 308 | + for frac, idx in ((0, 0), (0.5, len(pts) // 2), (1, -1)): | |
| 309 | + p.set_xy(cx + cw * frac - 9, cy + ch + 1.5) | |
| 310 | + p.cell(18, 3, str(pts[idx].get("t", ""))[:10], align="C") | |
| 311 | + p.set_y(y0 + h + 4) | |
| 312 | + if s.get("compare"): | |
| 313 | + p.set_font("helvetica", "", 6.8) | |
| 314 | + p.set_text_color(*INK3) | |
| 315 | + p.cell(0, 4, "— période courante (accent) · ---- période comparée") | |
| 316 | + p.ln(6) | |
| 317 | + else: | |
| 318 | + p.ln(2) | |
| 319 | + | |
| 320 | + def _bars(self, title, items, unit=""): | |
| 321 | + p = self.pdf | |
| 322 | + items = [it for it in (items or []) if isinstance(it.get("value"), (int, float))][:12] | |
| 323 | + if not items: | |
| 324 | + return | |
| 325 | + need = 10 + len(items) * 7 | |
| 326 | + if p.get_y() + need > 265: | |
| 327 | + p.add_page() | |
| 328 | + p.set_font("helvetica", "B", 10) | |
| 329 | + p.set_text_color(*INK) | |
| 330 | + p.cell(0, 6, title) | |
| 331 | + p.ln(8) | |
| 332 | + vmax = max(it["value"] for it in items) or 1 | |
| 333 | + for it in items: | |
| 334 | + y = p.get_y() | |
| 335 | + p.set_font("helvetica", "", 7.6) | |
| 336 | + p.set_text_color(*INK) | |
| 337 | + p.set_x(p.l_margin) | |
| 338 | + p.cell(46, 5, str(it["label"])[:34]) | |
| 339 | + bw = 96 * (it["value"] / vmax) | |
| 340 | + p.set_fill_color(*self.accent) | |
| 341 | + p.set_draw_color(*INK) | |
| 342 | + p.set_line_width(0.25) | |
| 343 | + p.rect(p.l_margin + 48, y + 0.7, max(bw, 0.8), 3.6, style="DF") | |
| 344 | + p.set_xy(p.l_margin + 148, y) | |
| 345 | + p.set_font("helvetica", "B", 7.6) | |
| 346 | + p.cell(26, 5, _fr(it["value"]) + (" " + unit if unit else ""), align="R") | |
| 347 | + p.ln(6.4) | |
| 348 | + p.ln(3) | |
| 349 | + | |
| 350 | + def _donut(self, b): | |
| 351 | + # anneau vectoriel simple (arcs) + légende | |
| 352 | + p = self.pdf | |
| 353 | + items = [it for it in (b.get("items") or []) if it.get("value")][:8] | |
| 354 | + total = sum(it["value"] for it in items) | |
| 355 | + if not items or not total: | |
| 356 | + return | |
| 357 | + if p.get_y() > 210: | |
| 358 | + p.add_page() | |
| 359 | + p.set_font("helvetica", "B", 10) | |
| 360 | + p.set_text_color(*INK) | |
| 361 | + p.cell(0, 6, b.get("title", "")) | |
| 362 | + p.ln(8) | |
| 363 | + cx, cy, r = p.l_margin + 26, p.get_y() + 24, 20 | |
| 364 | + shades = [1.0, 0.78, 0.58, 0.42, 0.30, 0.22, 0.15, 0.10] | |
| 365 | + start = -90.0 | |
| 366 | + for i, it in enumerate(items): | |
| 367 | + frac = it["value"] / total | |
| 368 | + f = shades[i % len(shades)] | |
| 369 | + col = tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) for j in range(3)) | |
| 370 | + steps = max(2, int(72 * frac)) | |
| 371 | + p.set_fill_color(*col) | |
| 372 | + p.set_draw_color(*col) | |
| 373 | + for st in range(steps): | |
| 374 | + a0 = math.radians(start + 360 * frac * st / steps) | |
| 375 | + a1 = math.radians(start + 360 * frac * (st + 1) / steps) | |
| 376 | + p.polygon( | |
| 377 | + [(cx, cy), | |
| 378 | + (cx + r * math.cos(a0), cy + r * math.sin(a0)), | |
| 379 | + (cx + r * math.cos(a1), cy + r * math.sin(a1))], | |
| 380 | + style="DF", | |
| 381 | + ) | |
| 382 | + start += 360 * frac | |
| 383 | + p.set_fill_color(*WHITE) | |
| 384 | + p.set_draw_color(*INK) | |
| 385 | + p.set_line_width(0.4) | |
| 386 | + p.ellipse(cx - 11, cy - 11, 22, 22, style="DF") | |
| 387 | + p.ellipse(cx - r, cy - r, 2 * r, 2 * r, style="D") | |
| 388 | + # légende | |
| 389 | + ly = cy - 22 | |
| 390 | + for i, it in enumerate(items): | |
| 391 | + f = shades[i % len(shades)] | |
| 392 | + col = tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) for j in range(3)) | |
| 393 | + p.set_fill_color(*col) | |
| 394 | + p.set_draw_color(*INK) | |
| 395 | + p.rect(p.l_margin + 60, ly + 0.8, 4, 4, style="DF") | |
| 396 | + p.set_xy(p.l_margin + 66, ly) | |
| 397 | + p.set_font("helvetica", "", 7.6) | |
| 398 | + p.set_text_color(*INK) | |
| 399 | + pct = 100 * it["value"] / total | |
| 400 | + p.cell(0, 5.6, f"{str(it['label'])[:40]} — {_fr(it['value'])} ({pct:.1f} %)".replace(".", ",")) | |
| 401 | + ly += 5.6 | |
| 402 | + p.set_y(max(cy + r, ly) + 6) | |
| 403 | + | |
| 404 | + def _table(self, t): | |
| 405 | + p = self.pdf | |
| 406 | + cols = t.get("columns") or [] | |
| 407 | + rows = t.get("rows") or [] | |
| 408 | + if not cols or not rows: | |
| 409 | + return | |
| 410 | + self._section_title(t.get("title", "Tableau")) | |
| 411 | + w = 174 / len(cols) | |
| 412 | + def head(): | |
| 413 | + p.set_font("helvetica", "B", 7.6) | |
| 414 | + p.set_fill_color(*INK) | |
| 415 | + p.set_text_color(*WHITE) | |
| 416 | + for c in cols: | |
| 417 | + p.cell(w, 6, " " + str(c)[:30], fill=True) | |
| 418 | + p.ln(6) | |
| 419 | + head() | |
| 420 | + p.set_text_color(*INK) | |
| 421 | + for i, row in enumerate(rows[:200]): | |
| 422 | + if p.get_y() > 262: | |
| 423 | + p.add_page() | |
| 424 | + head() | |
| 425 | + p.set_text_color(*INK) | |
| 426 | + p.set_font("helvetica", "", 7.4) | |
| 427 | + p.set_fill_color(*(SURFACE2 if i % 2 else WHITE)) | |
| 428 | + for cell in row: | |
| 429 | + txt = _fr(cell) if isinstance(cell, (int, float)) else str(cell) | |
| 430 | + p.cell(w, 5.4, " " + txt[:34], fill=True) | |
| 431 | + p.ln(5.4) | |
| 432 | + if len(rows) > 200: | |
| 433 | + p.set_font("helvetica", "", 7) | |
| 434 | + p.set_text_color(*INK3) | |
| 435 | + p.cell(0, 5, f"… {len(rows) - 200} lignes supplémentaires non imprimées") | |
| 436 | + p.ln(6) | |
| 437 | + | |
| 438 | + def _records(self): | |
| 439 | + recs = self.d.get("records") or [] | |
| 440 | + if not recs: | |
| 441 | + return | |
| 442 | + self._section_title("Records & faits marquants") | |
| 443 | + p = self.pdf | |
| 444 | + for r in recs[:10]: | |
| 445 | + if p.get_y() > 258: | |
| 446 | + p.add_page() | |
| 447 | + y = p.get_y() | |
| 448 | + self._card(p.l_margin, y, 174, 11, fill=SURFACE2) | |
| 449 | + p.set_xy(p.l_margin + 4, y + 2) | |
| 450 | + p.set_font("helvetica", "", 8.6) | |
| 451 | + p.set_text_color(*INK2) | |
| 452 | + p.cell(96, 7, str(r.get("label", ""))[:70]) | |
| 453 | + p.set_font("helvetica", "B", 9) | |
| 454 | + p.set_text_color(*INK) | |
| 455 | + p.cell(52, 7, str(r.get("value", ""))[:36], align="R") | |
| 456 | + p.set_font("helvetica", "", 7.6) | |
| 457 | + p.set_text_color(*INK3) | |
| 458 | + p.cell(20, 7, str(r.get("date", "") or ""), align="R") | |
| 459 | + p.set_y(y + 13.5) | |
| 460 | + p.ln(4) | |
| 461 | + | |
| 462 | + def _final_page(self): | |
| 463 | + p = self.pdf | |
| 464 | + p.add_page() | |
| 465 | + self._kicker("Groupe KA · contact") | |
| 466 | + p.set_font("helvetica", "B", 15) | |
| 467 | + p.set_text_color(*INK) | |
| 468 | + p.cell(0, 8, "Coordonnées du Groupe KA") | |
| 469 | + p.ln(12) | |
| 470 | + for email, role in EMAILS: | |
| 471 | + p.set_font("helvetica", "B", 10.5) | |
| 472 | + p.set_text_color(*INK) | |
| 473 | + p.cell(0, 6, email) | |
| 474 | + p.ln(5.5) | |
| 475 | + p.set_font("helvetica", "", 8.6) | |
| 476 | + p.set_text_color(*INK3) | |
| 477 | + p.cell(0, 5, role) | |
| 478 | + p.ln(8) | |
| 479 | + p.ln(2) | |
| 480 | + p.set_font("helvetica", "B", 10) | |
| 481 | + p.set_text_color(*GREEN) | |
| 482 | + p.cell(0, 6, "groupe-ka.com — le portail de l'écosystème ·Ka") | |
| 483 | + p.ln(10) | |
| 484 | + p.set_draw_color(*self.accent) | |
| 485 | + p.set_line_width(0.8) | |
| 486 | + p.line(p.l_margin, p.get_y(), p.l_margin + 30, p.get_y()) | |
| 487 | + p.ln(4) | |
| 488 | + p.set_font("helvetica", "", 8.6) | |
| 489 | + p.set_text_color(*INK2) | |
| 490 | + p.multi_cell(160, 4.6, DISCLAIMER) | |
| 491 | + p.ln(4) | |
| 492 | + p.set_font("helvetica", "", 7.6) | |
| 493 | + p.set_text_color(*INK3) | |
| 494 | + p.multi_cell( | |
| 495 | + 160, 4.2, | |
| 496 | + "Mentions : rapport généré automatiquement à partir des données réelles de la " | |
| 497 | + "plateforme au moment indiqué en couverture. Conditions d'utilisation, politique " | |
| 498 | + "de confidentialité et protection des renseignements personnels (Loi 25) : " | |
| 499 | + "groupe-ka.com/conditions · /confidentialite · /loi-25.", | |
| 500 | + ) | |
| 501 | + | |
| 502 | + def _toc_page(self): | |
| 503 | + # insérée après coup ? fpdf ne réordonne pas : on écrit le sommaire en | |
| 504 | + # page 2 en réservant la page lors du build (voir build()). | |
| 505 | + pass | |
| 506 | + | |
| 507 | + def build(self) -> bytes: | |
| 508 | + p = self.pdf | |
| 509 | + p.alias_nb_pages() | |
| 510 | + self._cover() | |
| 511 | + if self.mode == "synthese": | |
| 512 | + p.add_page() | |
| 513 | + self._kpis() | |
| 514 | + self._records() | |
| 515 | + self._final_page() | |
| 516 | + else: | |
| 517 | + p.add_page() | |
| 518 | + toc_page_no = p.page_no() | |
| 519 | + p.add_page() | |
| 520 | + self._kpis() | |
| 521 | + for s in self.d.get("series") or []: | |
| 522 | + if s.get("kind") == "bar": | |
| 523 | + self._bars(s.get("title", ""), [{"label": pt.get("t"), "value": pt.get("v")} for pt in (s.get("points") or [])], s.get("unit", "")) | |
| 524 | + else: | |
| 525 | + self._line_chart(s) | |
| 526 | + for b in self.d.get("breakdowns") or []: | |
| 527 | + if b.get("kind") == "donut": | |
| 528 | + self._donut(b) | |
| 529 | + else: | |
| 530 | + self._bars(b.get("title", ""), b.get("items")) | |
| 531 | + geo = self.d.get("geo") | |
| 532 | + if geo: | |
| 533 | + self._bars(geo.get("title", "Répartition géographique"), geo.get("items")) | |
| 534 | + for t in self.d.get("tables") or []: | |
| 535 | + self._table(t) | |
| 536 | + self._records() | |
| 537 | + self._final_page() | |
| 538 | + # sommaire écrit sur la page réservée (page 2) | |
| 539 | + last_page = p.page | |
| 540 | + p.page = toc_page_no | |
| 541 | + p.set_y(22) | |
| 542 | + p.set_font("helvetica", "B", 15) | |
| 543 | + p.set_text_color(*INK) | |
| 544 | + p.cell(0, 8, "Sommaire") | |
| 545 | + p.ln(12) | |
| 546 | + p.set_font("helvetica", "", 9.5) | |
| 547 | + for title, page_no in self.toc: | |
| 548 | + p.set_text_color(*INK) | |
| 549 | + p.cell(140, 6.5, title[:80]) | |
| 550 | + p.set_text_color(*INK3) | |
| 551 | + p.cell(0, 6.5, str(page_no), align="R") | |
| 552 | + p.ln(6.5) | |
| 553 | + p.page = last_page | |
| 554 | + return bytes(p.output()) | |
| 555 | + | |
| 556 | + | |
| 557 | +def filename(platform_id: str, period: str) -> str: | |
| 558 | + today = datetime.now(ZoneInfo("America/Toronto")).strftime("%Y-%m-%d") | |
| 559 | + return f"groupe-ka_{platform_id}_stats_{period}_{today}.pdf" | |
added
autoka/statsdash.py
+399 −0
@@ -0,0 +1,399 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# statsdash.py : tableau de bord analytique — construit le JSON du contrat | |
| 5 | +# commun Groupe KA (/api/stats/dashboard, voir ka/stats/SPEC.md) | |
| 6 | +# à partir des données réelles (vehicles, price_log). Sert aussi | |
| 7 | +# de source unique au rapport PDF (autoka/kapdf.py). | |
| 8 | +# | |
| 9 | +# Principes : | |
| 10 | +# - AUCUNE statistique inventée : tout vient de la base SQLite. | |
| 11 | +# - L'« inventaire à la date t » est reconstruit depuis le cycle de vie réel | |
| 12 | +# des annonces : first_seen (arrivée) et updated_at des annonces | |
| 13 | +# désactivées (retrait). Les prix historiques par jour utilisent le | |
| 14 | +# dernier prix connu de chaque véhicule (approximation documentée). | |
| 15 | +# - Les séries sont bornées au début réel des données (12 août 2026) : | |
| 16 | +# avant, rien n'était mesuré — on ne trace pas de faux zéros. | |
| 17 | +# - Cache serveur de 5 minutes par période. | |
| 18 | +# ----------------------------------------------------------------------------- | |
| 19 | +from __future__ import annotations | |
| 20 | + | |
| 21 | +import threading | |
| 22 | +import time | |
| 23 | +from collections import Counter | |
| 24 | +from datetime import date, datetime, timedelta | |
| 25 | +from zoneinfo import ZoneInfo | |
| 26 | + | |
| 27 | +from . import db | |
| 28 | + | |
| 29 | +TZ = ZoneInfo("America/Toronto") | |
| 30 | +CACHE_TTL = 300 # 5 minutes | |
| 31 | + | |
| 32 | +_cache: dict[tuple, tuple[float, dict]] = {} | |
| 33 | +_cache_lock = threading.Lock() | |
| 34 | + | |
| 35 | +PERIOD_LABELS = { | |
| 36 | + "auj": "Aujourd'hui", | |
| 37 | + "7j": "7 jours", | |
| 38 | + "30j": "30 jours", | |
| 39 | + "3m": "3 mois", | |
| 40 | + "6m": "6 mois", | |
| 41 | + "12m": "12 mois", | |
| 42 | + "annee": "Année en cours", | |
| 43 | + "tout": "Toute la période", | |
| 44 | +} | |
| 45 | +PERIOD_DAYS = {"auj": 1, "7j": 7, "30j": 30, "3m": 90, "6m": 180, "12m": 365} | |
| 46 | + | |
| 47 | + | |
| 48 | +# --- utilitaires --------------------------------------------------------------- | |
| 49 | + | |
| 50 | +def _day_start(d: date) -> float: | |
| 51 | + return datetime(d.year, d.month, d.day, tzinfo=TZ).timestamp() | |
| 52 | + | |
| 53 | + | |
| 54 | +def _to_date(ts: float) -> date: | |
| 55 | + return datetime.fromtimestamp(ts, TZ).date() | |
| 56 | + | |
| 57 | + | |
| 58 | +def _fr_money(p) -> str: | |
| 59 | + if p is None: | |
| 60 | + return "—" | |
| 61 | + return f"{int(round(p)):,}".replace(",", " ") + " $" | |
| 62 | + | |
| 63 | + | |
| 64 | +def _fr_km(k) -> str: | |
| 65 | + if k is None: | |
| 66 | + return "—" | |
| 67 | + return f"{int(round(k)):,}".replace(",", " ") + " km" | |
| 68 | + | |
| 69 | + | |
| 70 | +def _delta(cur, prev) -> tuple[float | None, str | None]: | |
| 71 | + """Variation en % vs période précédente ; None si non mesurable.""" | |
| 72 | + if cur is None or prev is None or not prev: | |
| 73 | + return None, None | |
| 74 | + pct = round(100.0 * (cur - prev) / prev, 1) | |
| 75 | + return pct, ("up" if pct >= 0 else "down") | |
| 76 | + | |
| 77 | + | |
| 78 | +def _resolve_period(period: str, from_: str | None, to: str | None): | |
| 79 | + """(start_ts, end_ts, prev_start_ts, prev_end_ts, label, from_iso, to_iso).""" | |
| 80 | + now = datetime.now(TZ) | |
| 81 | + today = now.date() | |
| 82 | + if from_ and to: | |
| 83 | + d0 = date.fromisoformat(from_) | |
| 84 | + d1 = date.fromisoformat(to) | |
| 85 | + if d1 < d0: | |
| 86 | + d0, d1 = d1, d0 | |
| 87 | + start = _day_start(d0) | |
| 88 | + end = min(now.timestamp(), _day_start(d1 + timedelta(days=1))) | |
| 89 | + label = f"du {d0.isoformat()} au {d1.isoformat()}" | |
| 90 | + elif period == "tout": | |
| 91 | + con = db.connect() | |
| 92 | + row = con.execute("SELECT MIN(first_seen) m FROM vehicles").fetchone() | |
| 93 | + con.close() | |
| 94 | + start = row["m"] or now.timestamp() | |
| 95 | + end = now.timestamp() | |
| 96 | + label = PERIOD_LABELS["tout"] | |
| 97 | + elif period == "annee": | |
| 98 | + start = _day_start(date(today.year, 1, 1)) | |
| 99 | + end = now.timestamp() | |
| 100 | + label = f"Année {today.year}" | |
| 101 | + else: | |
| 102 | + days = PERIOD_DAYS.get(period, 30) | |
| 103 | + start = _day_start(today - timedelta(days=days - 1)) | |
| 104 | + end = now.timestamp() | |
| 105 | + label = PERIOD_LABELS.get(period, PERIOD_LABELS["30j"]) | |
| 106 | + span = max(end - start, 1.0) | |
| 107 | + return start, end, start - span, start, label, \ | |
| 108 | + _to_date(start).isoformat(), _to_date(end - 1).isoformat() | |
| 109 | + | |
| 110 | + | |
| 111 | +# --- reconstruction de l'inventaire par jour ------------------------------------ | |
| 112 | + | |
| 113 | +def _lifecycle_rows(con) -> list[tuple[float, float | None, float | None]]: | |
| 114 | + """(arrivée, retrait|None, prix courant|None) pour chaque annonce auto.""" | |
| 115 | + return [ | |
| 116 | + (r["first_seen"], r["updated_at"] if not r["active"] else None, r["price"]) | |
| 117 | + for r in con.execute( | |
| 118 | + "SELECT first_seen, active, updated_at, price FROM vehicles" | |
| 119 | + " WHERE kind='auto' AND first_seen IS NOT NULL") | |
| 120 | + ] | |
| 121 | + | |
| 122 | + | |
| 123 | +def _daily_series(rows, start_ts: float, end_ts: float): | |
| 124 | + """Par jour : inventaire actif, prix moyen de l'inventaire, nouveautés. | |
| 125 | + | |
| 126 | + Balayage d'événements (arrivées/retraits triés) — l'inventaire au soir du | |
| 127 | + jour J = annonces arrivées avant la fin de J et pas encore retirées. | |
| 128 | + """ | |
| 129 | + if not rows: | |
| 130 | + return [] | |
| 131 | + data_start = min(r[0] for r in rows) | |
| 132 | + d = max(_to_date(start_ts), _to_date(data_start)) | |
| 133 | + d_end = _to_date(end_ts - 1) | |
| 134 | + if d > d_end: | |
| 135 | + return [] | |
| 136 | + adds = sorted(rows, key=lambda r: r[0]) | |
| 137 | + rems = sorted((r for r in rows if r[1] is not None), key=lambda r: r[1]) | |
| 138 | + new_by_day = Counter(_to_date(r[0]) for r in rows) | |
| 139 | + ai = ri = count = n_price = 0 | |
| 140 | + sum_price = 0.0 | |
| 141 | + out = [] | |
| 142 | + while d <= d_end: | |
| 143 | + cutoff = _day_start(d + timedelta(days=1)) | |
| 144 | + while ai < len(adds) and adds[ai][0] < cutoff: | |
| 145 | + count += 1 | |
| 146 | + if adds[ai][2] is not None: | |
| 147 | + sum_price += adds[ai][2] | |
| 148 | + n_price += 1 | |
| 149 | + ai += 1 | |
| 150 | + while ri < len(rems) and rems[ri][1] < cutoff: | |
| 151 | + count -= 1 | |
| 152 | + if rems[ri][2] is not None: | |
| 153 | + sum_price -= rems[ri][2] | |
| 154 | + n_price -= 1 | |
| 155 | + ri += 1 | |
| 156 | + out.append({ | |
| 157 | + "t": d.isoformat(), | |
| 158 | + "inv": count, | |
| 159 | + "avg_price": round(sum_price / n_price) if n_price else None, | |
| 160 | + "new": new_by_day.get(d, 0), | |
| 161 | + }) | |
| 162 | + d += timedelta(days=1) | |
| 163 | + return out | |
| 164 | + | |
| 165 | + | |
| 166 | +def _snapshot(con, t: float): | |
| 167 | + """Indicateurs de l'inventaire actif reconstitué à l'instant t.""" | |
| 168 | + return dict(con.execute( | |
| 169 | + """SELECT COUNT(*) n, AVG(price) avg_price, AVG(mileage_km) avg_km, | |
| 170 | + AVG(year) avg_year, COUNT(DISTINCT dealer_name) dealers | |
| 171 | + FROM vehicles WHERE kind='auto' AND first_seen<=? | |
| 172 | + AND (active=1 OR updated_at>?)""", (t, t)).fetchone()) | |
| 173 | + | |
| 174 | + | |
| 175 | +# --- construction du dashboard --------------------------------------------------- | |
| 176 | + | |
| 177 | +def _build(period: str, from_: str | None, to: str | None) -> dict: | |
| 178 | + start, end, pstart, pend, label, f_iso, t_iso = _resolve_period(period, from_, to) | |
| 179 | + con = db.connect() | |
| 180 | + now_ts = time.time() | |
| 181 | + | |
| 182 | + # --- KPI : maintenant vs début de période / période précédente ------------- | |
| 183 | + cur = _snapshot(con, min(end, now_ts)) | |
| 184 | + # Pas de delta si la période commence avant le début réel des données : | |
| 185 | + # l'inventaire de référence n'existait pas encore (rien d'inventé). | |
| 186 | + data_start = con.execute( | |
| 187 | + "SELECT MIN(first_seen) m FROM vehicles WHERE kind='auto'").fetchone()["m"] | |
| 188 | + if data_start is not None and start > data_start: | |
| 189 | + ref = _snapshot(con, start) | |
| 190 | + else: | |
| 191 | + ref = {"n": None, "avg_price": None, "avg_km": None, | |
| 192 | + "avg_year": None, "dealers": None} | |
| 193 | + | |
| 194 | + def _count(sql, args): | |
| 195 | + return con.execute(sql, args).fetchone()["n"] | |
| 196 | + | |
| 197 | + new_cur = _count("SELECT COUNT(*) n FROM vehicles WHERE kind='auto'" | |
| 198 | + " AND first_seen>=? AND first_seen<?", (start, end)) | |
| 199 | + new_prev = _count("SELECT COUNT(*) n FROM vehicles WHERE kind='auto'" | |
| 200 | + " AND first_seen>=? AND first_seen<?", (pstart, pend)) | |
| 201 | + gone_cur = _count("SELECT COUNT(*) n FROM vehicles WHERE kind='auto'" | |
| 202 | + " AND active=0 AND updated_at>=? AND updated_at<?", (start, end)) | |
| 203 | + gone_prev = _count("SELECT COUNT(*) n FROM vehicles WHERE kind='auto'" | |
| 204 | + " AND active=0 AND updated_at>=? AND updated_at<?", (pstart, pend)) | |
| 205 | + | |
| 206 | + def _kpi(id_, label_, value, unit="", prev=None): | |
| 207 | + pct, direction = _delta(value, prev) | |
| 208 | + return {"id": id_, "label": label_, "value": value, "unit": unit, | |
| 209 | + "delta_pct": pct, "direction": direction} | |
| 210 | + | |
| 211 | + kpis = [ | |
| 212 | + _kpi("actifs", "Véhicules actifs", cur["n"], "", ref["n"]), | |
| 213 | + _kpi("nouveaux", "Nouveaux véhicules (période)", new_cur, "", new_prev), | |
| 214 | + _kpi("retires", "Vendus / retirés (période)", gone_cur, "", gone_prev), | |
| 215 | + _kpi("prix", "Prix moyen (inventaire actif)", | |
| 216 | + round(cur["avg_price"]) if cur["avg_price"] else None, "$", | |
| 217 | + round(ref["avg_price"]) if ref["avg_price"] else None), | |
| 218 | + _kpi("km", "Km moyen (inventaire actif)", | |
| 219 | + round(cur["avg_km"]) if cur["avg_km"] else None, "km", | |
| 220 | + round(ref["avg_km"]) if ref["avg_km"] else None), | |
| 221 | + # année moyenne : valeur pré-formatée (« 2021,1 » — pas de séparateur | |
| 222 | + # de milliers) ; un delta en % n'aurait aucun sens sur un millésime. | |
| 223 | + _kpi("annee", "Année-modèle moyenne", | |
| 224 | + f"{cur['avg_year']:.1f}".replace(".", ",") | |
| 225 | + if cur["avg_year"] else None, ""), | |
| 226 | + _kpi("dealers", "Concessionnaires actifs", cur["dealers"], "", ref["dealers"]), | |
| 227 | + ] | |
| 228 | + kpis = [k for k in kpis if k["value"] is not None] | |
| 229 | + | |
| 230 | + # --- séries quotidiennes --------------------------------------------------- | |
| 231 | + rows = _lifecycle_rows(con) | |
| 232 | + daily = _daily_series(rows, start, end) | |
| 233 | + prev_daily = _daily_series(rows, pstart, pend) | |
| 234 | + full_prev = len(prev_daily) == len(daily) and len(daily) > 1 | |
| 235 | + | |
| 236 | + series = [] | |
| 237 | + if daily: | |
| 238 | + series.append({ | |
| 239 | + "id": "inv", "title": "Inventaire actif par jour", "unit": "véhicules", | |
| 240 | + "kind": "line", "points": [{"t": p["t"], "v": p["inv"]} for p in daily], | |
| 241 | + **({"compare": [{"t": p["t"], "v": p["inv"]} for p in prev_daily]} | |
| 242 | + if full_prev else {}), | |
| 243 | + }) | |
| 244 | + series.append({ | |
| 245 | + "id": "new", "title": "Nouveaux véhicules par jour", "unit": "véhicules", | |
| 246 | + "kind": "line", "points": [{"t": p["t"], "v": p["new"]} for p in daily], | |
| 247 | + **({"compare": [{"t": p["t"], "v": p["new"]} for p in prev_daily]} | |
| 248 | + if full_prev else {}), | |
| 249 | + }) | |
| 250 | + price_pts = [{"t": p["t"], "v": p["avg_price"]} for p in daily | |
| 251 | + if p["avg_price"] is not None] | |
| 252 | + if price_pts: | |
| 253 | + series.append({ | |
| 254 | + "id": "avg_price", "title": "Prix moyen de l'inventaire par jour", | |
| 255 | + "unit": "$", "kind": "line", "points": price_pts, | |
| 256 | + }) | |
| 257 | + | |
| 258 | + # --- répartitions (inventaire actif) ---------------------------------------- | |
| 259 | + def _items(sql, args=()): | |
| 260 | + return [{"label": r[0] or "Non précisé", "value": r[1]} | |
| 261 | + for r in con.execute(sql, args).fetchall()] | |
| 262 | + | |
| 263 | + by_fuel = _items( | |
| 264 | + "SELECT CASE WHEN fuel IN ('', 'N.D.') THEN 'Non précisé' ELSE fuel END f," | |
| 265 | + " COUNT(*) FROM vehicles WHERE active=1 AND kind='auto'" | |
| 266 | + " GROUP BY f ORDER BY COUNT(*) DESC") | |
| 267 | + by_trans = _items( | |
| 268 | + "SELECT CASE WHEN transmission IN ('', 'NA', 'N.D.') THEN 'Non précisé'" | |
| 269 | + " ELSE transmission END t, COUNT(*) FROM vehicles" | |
| 270 | + " WHERE active=1 AND kind='auto' GROUP BY t ORDER BY COUNT(*) DESC LIMIT 8") | |
| 271 | + by_make = _items( | |
| 272 | + "SELECT make, COUNT(*) FROM vehicles WHERE active=1 AND kind='auto'" | |
| 273 | + " AND make<>'' GROUP BY make ORDER BY COUNT(*) DESC LIMIT 12") | |
| 274 | + year_rows = con.execute( | |
| 275 | + "SELECT year, COUNT(*) n FROM vehicles WHERE active=1 AND kind='auto'" | |
| 276 | + " AND year IS NOT NULL GROUP BY year ORDER BY year").fetchall() | |
| 277 | + # 11 années récentes + un groupe « antérieures » = 12 barres max, aucune | |
| 278 | + # année récente escamotée par la limite d'affichage des graphiques. | |
| 279 | + by_year, older = [], 0 | |
| 280 | + cutoff = max((r["year"] for r in year_rows), default=0) - 10 | |
| 281 | + for r in year_rows: | |
| 282 | + if r["year"] < cutoff: | |
| 283 | + older += r["n"] | |
| 284 | + else: | |
| 285 | + by_year.append({"label": str(r["year"]), "value": r["n"]}) | |
| 286 | + if older: | |
| 287 | + by_year.insert(0, {"label": f"≤ {cutoff - 1}", "value": older}) | |
| 288 | + | |
| 289 | + breakdowns = [ | |
| 290 | + {"id": "fuel", "title": "Par carburant", "kind": "donut", "items": by_fuel}, | |
| 291 | + {"id": "trans", "title": "Par boîte de vitesses", "kind": "bar", "items": by_trans}, | |
| 292 | + {"id": "make", "title": "Top 12 des marques", "kind": "bar", "items": by_make}, | |
| 293 | + {"id": "year", "title": "Par année-modèle", "kind": "bar", "items": by_year}, | |
| 294 | + ] | |
| 295 | + breakdowns = [b for b in breakdowns if b["items"]] | |
| 296 | + | |
| 297 | + geo = {"title": "Par région", "items": _items( | |
| 298 | + "SELECT region, COUNT(*) FROM vehicles WHERE active=1 AND kind='auto'" | |
| 299 | + " AND region<>'' GROUP BY region ORDER BY COUNT(*) DESC")} | |
| 300 | + | |
| 301 | + heatmap = {"title": "Nouveaux véhicules par jour", | |
| 302 | + "cells": [{"date": p["t"], "value": p["new"]} for p in daily]} | |
| 303 | + | |
| 304 | + # --- tableaux détaillés (inventaire actif) ------------------------------------ | |
| 305 | + def _table_rows(sql): | |
| 306 | + return [list(r) for r in con.execute(sql).fetchall()] | |
| 307 | + | |
| 308 | + tables = [ | |
| 309 | + {"id": "makes", "title": "Top marques — volume, prix et km moyens", | |
| 310 | + "columns": ["Marque", "Véhicules", "Prix moyen", "Km moyen"], | |
| 311 | + "rows": [[m, n, _fr_money(p), _fr_km(k)] for m, n, p, k in _table_rows( | |
| 312 | + "SELECT make, COUNT(*), AVG(price), AVG(mileage_km) FROM vehicles" | |
| 313 | + " WHERE active=1 AND kind='auto' AND make<>''" | |
| 314 | + " GROUP BY make ORDER BY COUNT(*) DESC LIMIT 25")]}, | |
| 315 | + {"id": "models", "title": "Top modèles — volume, prix et km moyens", | |
| 316 | + "columns": ["Modèle", "Véhicules", "Prix moyen", "Km moyen"], | |
| 317 | + "rows": [[m, n, _fr_money(p), _fr_km(k)] for m, n, p, k in _table_rows( | |
| 318 | + "SELECT make || ' ' || model, COUNT(*), AVG(price), AVG(mileage_km)" | |
| 319 | + " FROM vehicles WHERE active=1 AND kind='auto' AND make<>''" | |
| 320 | + " AND model<>'' GROUP BY make, model ORDER BY COUNT(*) DESC LIMIT 25")]}, | |
| 321 | + {"id": "dealers", "title": "Top concessionnaires — inventaire et prix moyen", | |
| 322 | + "columns": ["Concessionnaire", "Région", "Véhicules", "Prix moyen"], | |
| 323 | + "rows": [[d, rg or "—", n, _fr_money(p)] for d, rg, n, p in _table_rows( | |
| 324 | + "SELECT dealer_name, MAX(region), COUNT(*), AVG(price) FROM vehicles" | |
| 325 | + " WHERE active=1 AND kind='auto' AND dealer_name<>''" | |
| 326 | + " GROUP BY dealer_name ORDER BY COUNT(*) DESC LIMIT 25")]}, | |
| 327 | + ] | |
| 328 | + | |
| 329 | + # --- records & faits marquants ------------------------------------------------- | |
| 330 | + records = [] | |
| 331 | + if daily: | |
| 332 | + best = max(daily, key=lambda p: p["new"]) | |
| 333 | + if best["new"]: | |
| 334 | + records.append({"label": "Jour record d'arrivées", | |
| 335 | + "value": f"{best['new']:,}".replace(",", " ") + " véhicules", | |
| 336 | + "date": best["t"]}) | |
| 337 | + fastest = con.execute( | |
| 338 | + """SELECT title, year, updated_at - first_seen dur, updated_at | |
| 339 | + FROM vehicles WHERE kind='auto' AND active=0 | |
| 340 | + AND updated_at>=? AND updated_at<? AND updated_at>first_seen | |
| 341 | + ORDER BY dur ASC LIMIT 1""", (start, end)).fetchone() | |
| 342 | + if fastest: | |
| 343 | + days_ = fastest["dur"] / 86400 | |
| 344 | + dur_txt = (f"{fastest['dur'] / 3600:.0f} h" if days_ < 1 | |
| 345 | + else f"{days_:.1f} jours".replace(".", ",")) | |
| 346 | + records.append({"label": f"Vente la plus rapide — {fastest['title']}", | |
| 347 | + "value": dur_txt, | |
| 348 | + "date": _to_date(fastest["updated_at"]).isoformat()}) | |
| 349 | + drop = con.execute( | |
| 350 | + """WITH pl AS ( | |
| 351 | + SELECT uid, ts, price, | |
| 352 | + LAG(price) OVER (PARTITION BY uid ORDER BY ts) prev_price | |
| 353 | + FROM price_log WHERE price IS NOT NULL) | |
| 354 | + SELECT v.title, pl.prev_price - pl.price baisse, pl.ts | |
| 355 | + FROM pl JOIN vehicles v ON v.uid=pl.uid AND v.kind='auto' | |
| 356 | + WHERE pl.ts>=? AND pl.ts<? AND pl.prev_price > pl.price | |
| 357 | + ORDER BY baisse DESC LIMIT 1""", (start, end)).fetchone() | |
| 358 | + if drop: | |
| 359 | + records.append({"label": f"Plus forte baisse de prix — {drop['title']}", | |
| 360 | + "value": "−" + _fr_money(drop["baisse"]), | |
| 361 | + "date": _to_date(drop["ts"]).isoformat()}) | |
| 362 | + busiest = con.execute( | |
| 363 | + """SELECT dealer_name, COUNT(*) n FROM vehicles | |
| 364 | + WHERE kind='auto' AND dealer_name<>'' AND first_seen>=? AND first_seen<? | |
| 365 | + GROUP BY dealer_name ORDER BY n DESC LIMIT 1""", (start, end)).fetchone() | |
| 366 | + if busiest and busiest["n"]: | |
| 367 | + records.append({"label": f"Concessionnaire le plus actif — {busiest['dealer_name']}", | |
| 368 | + "value": f"{busiest['n']:,}".replace(",", " ") + " nouveautés", | |
| 369 | + "date": None}) | |
| 370 | + | |
| 371 | + con.close() | |
| 372 | + return { | |
| 373 | + "updated": datetime.now(TZ).isoformat(timespec="seconds"), | |
| 374 | + "period": {"from": f_iso, "to": t_iso, "label": label}, | |
| 375 | + "kpis": kpis, | |
| 376 | + "series": series, | |
| 377 | + "breakdowns": breakdowns, | |
| 378 | + "geo": geo, | |
| 379 | + "heatmap": heatmap, | |
| 380 | + "tables": tables, | |
| 381 | + "records": records, | |
| 382 | + } | |
| 383 | + | |
| 384 | + | |
| 385 | +def dashboard(period: str = "30j", from_: str | None = None, | |
| 386 | + to: str | None = None) -> dict: | |
| 387 | + """Dashboard du contrat SPEC — mis en cache 5 minutes par période.""" | |
| 388 | + if period not in PERIOD_LABELS and not (from_ and to): | |
| 389 | + period = "30j" | |
| 390 | + key = (period, from_ or "", to or "") | |
| 391 | + now = time.time() | |
| 392 | + with _cache_lock: | |
| 393 | + hit = _cache.get(key) | |
| 394 | + if hit and now - hit[0] < CACHE_TTL: | |
| 395 | + return hit[1] | |
| 396 | + data = _build(period, from_, to) | |
| 397 | + with _cache_lock: | |
| 398 | + _cache[key] = (now, data) | |
| 399 | + return data | |
modified
autoka/web.py
+46 −0
@@ -260,6 +260,52 @@ def stats_detailed(): | ||
| 260 | 260 | return marketstats.compute() |
| 261 | 261 | |
| 262 | 262 | |
| 263 | +# --- Module Stats commun Groupe KA (contrat ka/stats/SPEC.md) ---------------- | |
| 264 | + | |
| 265 | +KA_SITE = { | |
| 266 | + "wordmark": "Auto·Ka", | |
| 267 | + "accent": "#ff5a2a", | |
| 268 | + "domain": "www.auto-ka.com", | |
| 269 | + "tagline": "Voitures usagées · tout le Québec · toujours à jour", | |
| 270 | +} | |
| 271 | + | |
| 272 | + | |
| 273 | +@app.get("/api/stats/dashboard") | |
| 274 | +def stats_dashboard( | |
| 275 | + period: str = "30j", | |
| 276 | + from_: str | None = Query(None, alias="from"), | |
| 277 | + to: str | None = None, | |
| 278 | +): | |
| 279 | + """Tableau de bord analytique — contrat commun Groupe KA (cache 5 min).""" | |
| 280 | + from . import statsdash | |
| 281 | + try: | |
| 282 | + return statsdash.dashboard(period, from_, to) | |
| 283 | + except ValueError: | |
| 284 | + raise HTTPException(400, "Dates invalides (format attendu : YYYY-MM-DD)") | |
| 285 | + | |
| 286 | + | |
| 287 | +@app.get("/api/stats/report") | |
| 288 | +def stats_report( | |
| 289 | + period: str = "30j", | |
| 290 | + from_: str | None = Query(None, alias="from"), | |
| 291 | + to: str | None = None, | |
| 292 | + mode: str = "complet", | |
| 293 | +): | |
| 294 | + """Rapport PDF estampillé Groupe-KA (complet ou synthèse 2 pages).""" | |
| 295 | + from fastapi.responses import Response | |
| 296 | + from . import kapdf, statsdash | |
| 297 | + try: | |
| 298 | + dash = statsdash.dashboard(period, from_, to) | |
| 299 | + except ValueError: | |
| 300 | + raise HTTPException(400, "Dates invalides (format attendu : YYYY-MM-DD)") | |
| 301 | + mode = "synthese" if mode == "synthese" else "complet" | |
| 302 | + pdf = kapdf.GroupeKAReport(site=KA_SITE, dashboard=dash, mode=mode).build() | |
| 303 | + return Response( | |
| 304 | + content=pdf, media_type="application/pdf", | |
| 305 | + headers={"Content-Disposition": | |
| 306 | + f'attachment; filename="{kapdf.filename("auto-ka", period)}"'}) | |
| 307 | + | |
| 308 | + | |
| 263 | 309 | @app.get("/api/stats/rapport.pdf") |
| 264 | 310 | def rapport_pdf(): |
| 265 | 311 | """Rapport PDF « Le marché de l'occasion » — vue d'ensemble.""" |
added
frontend/src/ka/stats/SPEC.md
+123 −0
@@ -0,0 +1,123 @@ | ||
| 1 | +# ka-stats — module Stats commun Groupe KA (spec v1) | |
| 2 | + | |
| 3 | +Contrat partagé par les 12 plateformes pour leurs pages **/stats** (tableau de | |
| 4 | +bord analytique) et l'**export PDF** estampillé Groupe-KA. Le visuel suit le | |
| 5 | +design system ka-ui (tokens.css) avec l'accent de la marque. | |
| 6 | + | |
| 7 | +## 1. Page /stats — structure obligatoire (dans cet ordre) | |
| 8 | + | |
| 9 | +1. **Bandeau KPI** : 4–6 grandes cartes (`KpiCard`) — valeur, libellé, | |
| 10 | + variation vs période précédente (▲/▼ + %, vert `--green` / rouge `--danger`). | |
| 11 | +2. **Sélecteur de période global** (`PeriodSelector`) : `aujourd'hui · 7 j · | |
| 12 | + 30 j · 3 m · 6 m · 12 m · année en cours · tout` + plage personnalisée | |
| 13 | + (2 champs date). Toute la page se recalcule (state → refetch dashboard). | |
| 14 | +3. **Graphiques** : courbes d'évolution (`LineChart`, survol = infobulle, | |
| 15 | + légende cliquable pour masquer une série, comparaison N vs N-1 en | |
| 16 | + pointillé), barres (`BarChart`), anneaux (`Donut`), calendrier de chaleur | |
| 17 | + (`CalendarHeatmap`) quand pertinent. | |
| 18 | +4. **Répartition géographique** (par ville/région) quand pertinent — barres | |
| 19 | + horizontales triées (pas besoin de vraie carte). | |
| 20 | +5. **Tableaux détaillés** (`DataTable`) : tri par colonne, recherche interne, | |
| 21 | + pagination (25/pg), débordement horizontal propre sur mobile (.tbl-wrap). | |
| 22 | +6. **Records & faits marquants** : générés depuis les données (jour record, | |
| 23 | + plus forte croissance, meilleure entrée…) — cartes compactes. | |
| 24 | +7. **Fraîcheur** : « Mis à jour le {date heure} » + bouton Rafraîchir. | |
| 25 | +8. **Bouton PDF** bien visible en haut : « Télécharger le rapport PDF » avec | |
| 26 | + deux choix (Rapport complet / Synthèse 2 pages). Indicateur de progression | |
| 27 | + si > 2 s. | |
| 28 | + | |
| 29 | +Responsive : KPI empilés < 768 px, graphiques pleine largeur redimensionnés | |
| 30 | +(SVG viewBox), tableaux en défilement horizontal contenu, tactile ≥ 44 px. | |
| 31 | +AUCUNE donnée inventée : une stat indisponible = bloc « Pas encore mesuré » | |
| 32 | +(carte grise propre), jamais un faux chiffre. | |
| 33 | + | |
| 34 | +## 2. API — contrat commun | |
| 35 | + | |
| 36 | +`GET /api/stats/dashboard?period=7j|30j|3m|6m|12m|annee|tout|auj&from=YYYY-MM-DD&to=YYYY-MM-DD` | |
| 37 | + | |
| 38 | +```jsonc | |
| 39 | +{ | |
| 40 | + "updated": "2026-08-17T21:04:00-04:00", | |
| 41 | + "period": { "from": "2026-07-18", "to": "2026-08-17", "label": "30 jours" }, | |
| 42 | + "kpis": [ { "id": "total", "label": "Annonces actives", "value": 33744, | |
| 43 | + "unit": "", "delta_pct": 4.2, "direction": "up" } ], | |
| 44 | + "series": [ { "id": "vol", "title": "Annonces actives par jour", "unit": "annonces", | |
| 45 | + "kind": "line", "points": [{ "t": "2026-07-18", "v": 31200 }], | |
| 46 | + "compare": [{ "t": "2025-07-18", "v": 24100 }] } ], | |
| 47 | + "breakdowns": [ { "id": "types", "title": "Par type", "kind": "donut", | |
| 48 | + "items": [{ "label": "4½", "value": 9120 }] } ], | |
| 49 | + "geo": { "title": "Par région", "items": [{ "label": "Montréal", "value": 15680 }] }, | |
| 50 | + "heatmap": { "title": "Activité", "cells": [{ "date": "2026-08-01", "value": 210 }] }, | |
| 51 | + "tables": [ { "id": "top", "title": "Top villes", "columns": ["Ville", "Annonces", "Δ 30 j"], | |
| 52 | + "rows": [["Montréal", 15680, "+3,1 %"]] } ], | |
| 53 | + "records": [ { "label": "Jour record d'ajouts", "value": "412 annonces", "date": "2026-08-09" } ] | |
| 54 | +} | |
| 55 | +``` | |
| 56 | + | |
| 57 | +Champs absents = section masquée. Cache serveur recommandé (≥ 5 min par | |
| 58 | +période). Les valeurs proviennent des données réelles (DB de la plateforme, | |
| 59 | +journaux de sync des connecteurs, /api/v1/runs d'API-KA…). | |
| 60 | + | |
| 61 | +`GET /api/stats/report?period=…&from=&to=&mode=complet|synthese` | |
| 62 | +→ `application/pdf`, en-tête `Content-Disposition: attachment; filename= | |
| 63 | +groupe-ka_<plateforme>_stats_<periode>_<YYYY-MM-DD>.pdf`. | |
| 64 | + | |
| 65 | +## 3. PDF — gabarit Groupe-KA (implémentations : `kapdf.py` fpdf2 pour les | |
| 66 | +apps Python ; les apps Next portent le même gabarit en pdfkit) | |
| 67 | + | |
| 68 | +- **Couverture** : cadre encre, kicker « GROUPE KA · RAPPORT STATISTIQUE », | |
| 69 | + wordmark de la plateforme (boîte encre + accent), sous-titre, période | |
| 70 | + couverte, date/heure de génération, bande encre au pied avec | |
| 71 | + « par Groupe KA — groupe-ka.com ». | |
| 72 | +- **Sommaire** avec numéros de pages (mode complet). | |
| 73 | +- **KPI** : grille de cartes (bordure encre, valeur en gros, delta coloré). | |
| 74 | +- **Graphiques VECTORIELS** (dessinés en primitives, jamais de capture) : | |
| 75 | + courbes, barres, anneaux — accent de la plateforme, axes/graduations encre. | |
| 76 | +- **Tableaux** paginés proprement (lignes zébrées `--surface-2`, jamais | |
| 77 | + coupés en deux à cheval sur une ligne). | |
| 78 | +- **Records** puis **page de fin** : coordonnées Groupe KA (3 courriels + | |
| 79 | + rôles d'ecosystem.json, groupe-ka.com), avertissement d'agrégateur, | |
| 80 | + mentions légales courtes. | |
| 81 | +- **Chaque page** : en-tête discret (« Groupe KA · {Plateforme} », filet | |
| 82 | + encre) + pied (« © Groupe-KA — {année} — groupe-ka.com · {période} · p. X/Y »). | |
| 83 | +- A4 portrait, marges 18 mm, typo : Helvetica (fallback sûr) ou fonts TTF du | |
| 84 | + DS si présentes. Mode « synthese » = couverture + 1 page KPI/records. | |
| 85 | + | |
| 86 | +## 4. Spécifique par plateforme (sections métier attendues) | |
| 87 | + | |
| 88 | +- **groupe-ka** : tableau de bord maître — consolidation des 12 (volume total, | |
| 89 | + croissance), classement des plateformes, bloc résumé par plateforme + lien | |
| 90 | + vers sa page /stats ; « Rapport écosystème complet » = PDF consolidé. | |
| 91 | +- **lou-ka** : annonces actives/nouvelles/retirées, loyers moyens/médians par | |
| 92 | + ville & taille, évolution, répartition par type, top villes. | |
| 93 | +- **immo-ka** : annonces actives/nouvelles/vendues-retirées, prix moyen/médian | |
| 94 | + par ville/région/type, délai de présence, top villes, tension du marché. | |
| 95 | +- **vrai-prix** : couverture du rôle (unités, valeur totale), estimations | |
| 96 | + servies si journalisées, répartitions par municipalité/type, indices marché. | |
| 97 | +- **auto-ka** : volume par marque/modèle/année/carburant/boîte, prix moyens et | |
| 98 | + km moyens par segment, top marques/modèles. | |
| 99 | +- **fabri-ka** : produits par catégorie/région/boutique, fourchettes de prix, | |
| 100 | + nouveautés par période, top catégories. | |
| 101 | +- **food-ka** : produits suivis, relevés de prix, soldes détectés (baisses/ | |
| 102 | + hausses, amplitude), top produits en solde, prix moyens par catégorie. | |
| 103 | +- **resto-ka** : restos par cuisine/ville/gamme, menus & plats, nouveautés/ | |
| 104 | + fermetures détectées, top établissements. | |
| 105 | +- **sorti-ka** : événements à venir/passés par catégorie/ville, gratuits vs | |
| 106 | + payants, heatmap calendrier, top lieux. | |
| 107 | +- **crea-ka** : créateurs par plateforme/niche/tier, comptes reliés, top | |
| 108 | + créateurs, croissance du répertoire. | |
| 109 | +- **trouve-ka** : pages indexées, domaines, rythme de crawl (indexées/h), | |
| 110 | + erreurs, file frontier, tendances si les requêtes sont journalisées. | |
| 111 | +- **api-ka** : appels par endpoint/jour/heure, latences moyennes + p95, taux | |
| 112 | + d'erreur, top endpoints, uptime (données des middlewares de logging + runs). | |
| 113 | +- **Transverse (tous)** : volume total agrégé + croissance, connecteurs actifs | |
| 114 | + et éléments ajoutés/mis à jour par période (journaux de sync), complétude/ | |
| 115 | + fraîcheur moyenne des fiches quand mesurable. Trafic web : seulement si des | |
| 116 | + journaux d'accès existent — sinon état vide propre. | |
| 117 | + | |
| 118 | +## 5. Ajouter une métrique / un graphique / une plateforme | |
| 119 | + | |
| 120 | +1 métrique = 1 entrée `kpis[]` ou `series[]` côté API (requête SQL agrégée + | |
| 121 | +cache) — le front la rend automatiquement. 1 plateforme = implémenter les 2 | |
| 122 | +endpoints du contrat + une page /stats montée sur les composants du kit + | |
| 123 | +`kapdf.py` (ou gabarit pdfkit) branché sur le même JSON de dashboard. | |
added
frontend/src/ka/stats/kacharts.tsx
+391 −0
@@ -0,0 +1,391 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 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 | |
| 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"; | |
| 9 | + | |
| 10 | +/* ---------- types (contrat SPEC.md) ---------- */ | |
| 11 | +export type Kpi = { | |
| 12 | + id: string; label: string; value: number | string; unit?: string; | |
| 13 | + delta_pct?: number | null; direction?: "up" | "down"; | |
| 14 | +}; | |
| 15 | +export type Point = { t: string; v: number }; | |
| 16 | +export type Serie = { | |
| 17 | + id: string; title: string; unit?: string; kind?: "line" | "bar"; | |
| 18 | + points: Point[]; compare?: Point[]; | |
| 19 | +}; | |
| 20 | +export type BreakItem = { label: string; value: number }; | |
| 21 | +export type TableSpec = { id: string; title: string; columns: string[]; rows: (string | number)[][] }; | |
| 22 | +export type RecordFact = { label: string; value: string; date?: string }; | |
| 23 | + | |
| 24 | +export const PERIODS: { id: string; label: string }[] = [ | |
| 25 | + { id: "auj", label: "Aujourd'hui" }, | |
| 26 | + { id: "7j", label: "7 jours" }, | |
| 27 | + { id: "30j", label: "30 jours" }, | |
| 28 | + { id: "3m", label: "3 mois" }, | |
| 29 | + { id: "6m", label: "6 mois" }, | |
| 30 | + { id: "12m", label: "12 mois" }, | |
| 31 | + { id: "annee", label: "Année en cours" }, | |
| 32 | + { id: "tout", label: "Tout" }, | |
| 33 | +]; | |
| 34 | + | |
| 35 | +export const fmtInt = (n: number) => n.toLocaleString("fr-CA"); | |
| 36 | +export const fmtNum = (n: number) => | |
| 37 | + Number.isInteger(n) ? fmtInt(n) : n.toLocaleString("fr-CA", { maximumFractionDigits: 2 }); | |
| 38 | + | |
| 39 | +/* ---------- KPI ---------- */ | |
| 40 | +export function KpiCard({ k }: { k: Kpi }) { | |
| 41 | + const up = (k.direction ?? ((k.delta_pct ?? 0) >= 0 ? "up" : "down")) === "up"; | |
| 42 | + return ( | |
| 43 | + <article className="card" style={{ padding: "14px 16px", minWidth: 0 }}> | |
| 44 | + <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} | |
| 46 | + {k.unit ? <span style={{ fontSize: "0.6em", color: "var(--ink-2)" }}> {k.unit}</span> : null} | |
| 47 | + </p> | |
| 48 | + <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 | + )} | |
| 54 | + </article> | |
| 55 | + ); | |
| 56 | +} | |
| 57 | + | |
| 58 | +/* ---------- Sélecteur de période ---------- */ | |
| 59 | +export function PeriodSelector({ | |
| 60 | + value, onChange, custom, onCustom, | |
| 61 | +}: { | |
| 62 | + value: string; onChange: (p: string) => void; | |
| 63 | + custom?: { from: string; to: string }; onCustom?: (from: string, to: string) => void; | |
| 64 | +}) { | |
| 65 | + return ( | |
| 66 | + <div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}> | |
| 67 | + {PERIODS.map((p) => ( | |
| 68 | + <button key={p.id} type="button" onClick={() => onChange(p.id)} | |
| 69 | + className="chip" aria-pressed={value === p.id} | |
| 70 | + style={{ cursor: "pointer", minHeight: 44, background: value === p.id ? "var(--accent)" : "var(--surface)", color: value === p.id ? "var(--on-accent)" : "var(--ink)" }}> | |
| 71 | + {p.label} | |
| 72 | + </button> | |
| 73 | + ))} | |
| 74 | + {onCustom && ( | |
| 75 | + <span style={{ display: "inline-flex", gap: 6, alignItems: "center" }}> | |
| 76 | + <input className="input" type="date" style={{ width: 150 }} value={custom?.from ?? ""} aria-label="Du" | |
| 77 | + onChange={(e) => onCustom(e.target.value, custom?.to ?? "")} /> | |
| 78 | + <span className="klabel">au</span> | |
| 79 | + <input className="input" type="date" style={{ width: 150 }} value={custom?.to ?? ""} aria-label="Au" | |
| 80 | + onChange={(e) => onCustom(custom?.from ?? "", e.target.value)} /> | |
| 81 | + </span> | |
| 82 | + )} | |
| 83 | + </div> | |
| 84 | + ); | |
| 85 | +} | |
| 86 | + | |
| 87 | +/* ---------- Courbe ---------- */ | |
| 88 | +export function LineChart({ serie, height = 240 }: { serie: Serie; height?: number }) { | |
| 89 | + const [hide, setHide] = useState<{ cur: boolean; cmp: boolean }>({ cur: false, cmp: false }); | |
| 90 | + const [hover, setHover] = useState<number | null>(null); | |
| 91 | + const W = 720, H = height, PL = 54, PR = 10, PT = 14, PB = 26; | |
| 92 | + const pts = serie.points ?? []; | |
| 93 | + if (pts.length < 2) return <EmptyBlock title={serie.title} />; | |
| 94 | + const all = [...(hide.cur ? [] : pts), ...(!hide.cmp && serie.compare ? serie.compare : [])]; | |
| 95 | + const vmax = Math.max(...all.map((p) => p.v), 1); | |
| 96 | + const vmin = Math.min(0, ...all.map((p) => p.v)); | |
| 97 | + const X = (i: number, n: number) => PL + ((W - PL - PR) * i) / (n - 1); | |
| 98 | + const Y = (v: number) => PT + (H - PT - PB) * (1 - (v - vmin) / (vmax - vmin || 1)); | |
| 99 | + const path = (s: Point[]) => s.map((p, i) => `${i ? "L" : "M"}${X(i, s.length)},${Y(p.v)}`).join(""); | |
| 100 | + const hi = hover !== null ? Math.min(pts.length - 1, Math.max(0, hover)) : null; | |
| 101 | + return ( | |
| 102 | + <figure className="card" style={{ margin: 0, padding: 16 }}> | |
| 103 | + <figcaption style={{ display: "flex", justifyContent: "space-between", flexWrap: "wrap", gap: 8 }}> | |
| 104 | + <b style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{serie.title}</b> | |
| 105 | + <span style={{ display: "flex", gap: 10 }}> | |
| 106 | + <LegendChip label="Période courante" color="var(--accent)" off={hide.cur} onClick={() => setHide((h) => ({ ...h, cur: !h.cur }))} /> | |
| 107 | + {serie.compare && <LegendChip label="Période comparée" color="var(--ink-3)" dashed off={hide.cmp} onClick={() => setHide((h) => ({ ...h, cmp: !h.cmp }))} />} | |
| 108 | + </span> | |
| 109 | + </figcaption> | |
| 110 | + <svg viewBox={`0 0 ${W} ${H}`} style={{ width: "100%", height: "auto", marginTop: 10, touchAction: "pan-y" }} role="img" aria-label={serie.title} | |
| 111 | + onMouseMove={(e) => { | |
| 112 | + const r = (e.currentTarget as SVGSVGElement).getBoundingClientRect(); | |
| 113 | + const fx = ((e.clientX - r.left) / r.width) * W; | |
| 114 | + setHover(Math.round(((fx - PL) / (W - PL - PR)) * (pts.length - 1))); | |
| 115 | + }} | |
| 116 | + onMouseLeave={() => setHover(null)}> | |
| 117 | + {[0, 1, 2, 3, 4].map((g) => { | |
| 118 | + const y = PT + ((H - PT - PB) * g) / 4; | |
| 119 | + const v = vmax - ((vmax - vmin) * g) / 4; | |
| 120 | + return ( | |
| 121 | + <g key={g}> | |
| 122 | + <line x1={PL} x2={W - PR} y1={y} y2={y} stroke="var(--line)" strokeWidth={1} /> | |
| 123 | + <text x={PL - 6} y={y + 3} textAnchor="end" fontSize={10} fill="var(--ink-3)" fontFamily="var(--font-mono)">{fmtInt(Math.round(v))}</text> | |
| 124 | + </g> | |
| 125 | + ); | |
| 126 | + })} | |
| 127 | + {[0, Math.floor(pts.length / 2), pts.length - 1].map((i) => ( | |
| 128 | + <text key={i} x={X(i, pts.length)} y={H - 8} | |
| 129 | + textAnchor={i === 0 ? "start" : i === pts.length - 1 ? "end" : "middle"} | |
| 130 | + fontSize={10} fill="var(--ink-3)" fontFamily="var(--font-mono)">{pts[i].t}</text> | |
| 131 | + ))} | |
| 132 | + {!hide.cmp && serie.compare && serie.compare.length > 1 && ( | |
| 133 | + <path d={path(serie.compare)} fill="none" stroke="var(--ink-3)" strokeWidth={1.4} strokeDasharray="4 4" /> | |
| 134 | + )} | |
| 135 | + {!hide.cur && <path d={path(pts)} fill="none" stroke="var(--accent)" strokeWidth={2.4} />} | |
| 136 | + {hi !== null && ( | |
| 137 | + <g> | |
| 138 | + <line x1={X(hi, pts.length)} x2={X(hi, pts.length)} y1={PT} y2={H - PB} stroke="var(--ink)" strokeWidth={1} strokeDasharray="2 3" /> | |
| 139 | + <circle cx={X(hi, pts.length)} cy={Y(pts[hi].v)} r={4} fill="var(--accent)" stroke="var(--ink)" strokeWidth={1.5} /> | |
| 140 | + </g> | |
| 141 | + )} | |
| 142 | + </svg> | |
| 143 | + {hi !== null && ( | |
| 144 | + <p className="chip" style={{ marginTop: 8 }}> | |
| 145 | + {pts[hi].t} — <b>{fmtNum(pts[hi].v)}{serie.unit ? ` ${serie.unit}` : ""}</b> | |
| 146 | + {serie.compare?.[hi] && !hide.cmp ? <span style={{ color: "var(--ink-3)" }}> · N-1 : {fmtNum(serie.compare[hi].v)}</span> : null} | |
| 147 | + </p> | |
| 148 | + )} | |
| 149 | + </figure> | |
| 150 | + ); | |
| 151 | +} | |
| 152 | + | |
| 153 | +function LegendChip({ label, color, off, dashed, onClick }: { label: string; color: string; off: boolean; dashed?: boolean; onClick: () => void }) { | |
| 154 | + return ( | |
| 155 | + <button type="button" onClick={onClick} aria-pressed={!off} | |
| 156 | + 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 }}> | |
| 157 | + <span style={{ width: 18, height: 0, borderTop: `3px ${dashed ? "dashed" : "solid"} ${color}` }} /> | |
| 158 | + {label} | |
| 159 | + </button> | |
| 160 | + ); | |
| 161 | +} | |
| 162 | + | |
| 163 | +/* ---------- Barres horizontales (répartitions, géo) ---------- */ | |
| 164 | +export function BarChart({ title, items, unit }: { title: string; items: BreakItem[]; unit?: string }) { | |
| 165 | + const rows = (items ?? []).slice(0, 14); | |
| 166 | + if (!rows.length) return <EmptyBlock title={title} />; | |
| 167 | + const max = Math.max(...rows.map((r) => r.value), 1); | |
| 168 | + return ( | |
| 169 | + <figure className="card" style={{ margin: 0, padding: 16 }}> | |
| 170 | + <figcaption><b style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{title}</b></figcaption> | |
| 171 | + <div style={{ marginTop: 12, display: "grid", gap: 9 }}> | |
| 172 | + {rows.map((r) => ( | |
| 173 | + <div key={r.label} title={`${r.label} — ${fmtNum(r.value)}${unit ? ` ${unit}` : ""}`}> | |
| 174 | + <div style={{ display: "flex", justifyContent: "space-between", fontSize: 12.5 }}> | |
| 175 | + <span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{r.label}</span> | |
| 176 | + <b style={{ fontFamily: "var(--font-mono)", fontSize: 11.5 }}>{fmtNum(r.value)}{unit ? ` ${unit}` : ""}</b> | |
| 177 | + </div> | |
| 178 | + <div style={{ marginTop: 3, height: 12, background: "rgba(20,24,20,0.06)", borderRadius: "0 3px 3px 0" }}> | |
| 179 | + <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" }} /> | |
| 180 | + </div> | |
| 181 | + </div> | |
| 182 | + ))} | |
| 183 | + </div> | |
| 184 | + </figure> | |
| 185 | + ); | |
| 186 | +} | |
| 187 | + | |
| 188 | +/* ---------- Anneau ---------- */ | |
| 189 | +export function Donut({ title, items }: { title: string; items: BreakItem[] }) { | |
| 190 | + const rows = (items ?? []).filter((i) => i.value > 0).slice(0, 8); | |
| 191 | + const total = rows.reduce((s, r) => s + r.value, 0); | |
| 192 | + if (!total) return <EmptyBlock title={title} />; | |
| 193 | + const R = 74, C = 2 * Math.PI * R; | |
| 194 | + let acc = 0; | |
| 195 | + const shades = [1, 0.78, 0.58, 0.42, 0.3, 0.22, 0.15, 0.1]; | |
| 196 | + return ( | |
| 197 | + <figure className="card" style={{ margin: 0, padding: 16 }}> | |
| 198 | + <figcaption><b style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{title}</b></figcaption> | |
| 199 | + <div style={{ display: "flex", flexWrap: "wrap", gap: 18, alignItems: "center", marginTop: 12 }}> | |
| 200 | + <svg viewBox="0 0 200 200" style={{ width: 180, maxWidth: "100%" }} role="img" aria-label={title}> | |
| 201 | + {rows.map((r, i) => { | |
| 202 | + const frac = r.value / total; | |
| 203 | + const off = acc; acc += frac; | |
| 204 | + return ( | |
| 205 | + <circle key={r.label} cx={100} cy={100} r={R} fill="none" | |
| 206 | + stroke="var(--accent)" strokeOpacity={shades[i % shades.length]} | |
| 207 | + strokeWidth={30} strokeDasharray={`${frac * C} ${C}`} strokeDashoffset={-off * C} | |
| 208 | + transform="rotate(-90 100 100)"> | |
| 209 | + <title>{`${r.label} — ${fmtNum(r.value)} (${((100 * r.value) / total).toFixed(1)} %)`}</title> | |
| 210 | + </circle> | |
| 211 | + ); | |
| 212 | + })} | |
| 213 | + <circle cx={100} cy={100} r={R} fill="none" stroke="var(--ink)" strokeWidth={1} opacity={0.5} /> | |
| 214 | + </svg> | |
| 215 | + <ul style={{ listStyle: "none", margin: 0, padding: 0, display: "grid", gap: 6, minWidth: 200, flex: 1 }}> | |
| 216 | + {rows.map((r, i) => ( | |
| 217 | + <li key={r.label} style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 12.5 }}> | |
| 218 | + <span style={{ width: 11, height: 11, borderRadius: 3, border: "1px solid var(--ink)", background: "var(--accent)", opacity: shades[i % shades.length] }} /> | |
| 219 | + <span style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{r.label}</span> | |
| 220 | + <b style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}>{((100 * r.value) / total).toFixed(1)} %</b> | |
| 221 | + </li> | |
| 222 | + ))} | |
| 223 | + </ul> | |
| 224 | + </div> | |
| 225 | + </figure> | |
| 226 | + ); | |
| 227 | +} | |
| 228 | + | |
| 229 | +/* ---------- Calendrier de chaleur ---------- */ | |
| 230 | +export function CalendarHeatmap({ title, cells }: { title: string; cells: { date: string; value: number }[] }) { | |
| 231 | + if (!cells?.length) return <EmptyBlock title={title} />; | |
| 232 | + const byDate = new Map(cells.map((c) => [c.date, c.value])); | |
| 233 | + const dates = cells.map((c) => c.date).sort(); | |
| 234 | + const end = new Date(dates[dates.length - 1] + "T12:00:00"); | |
| 235 | + const max = Math.max(...cells.map((c) => c.value), 1); | |
| 236 | + const weeks = 26, cols: { date: string; v: number }[][] = []; | |
| 237 | + const cur = new Date(end); | |
| 238 | + cur.setDate(cur.getDate() - (weeks * 7 - 1)); | |
| 239 | + for (let w = 0; w < weeks; w++) { | |
| 240 | + const col: { date: string; v: number }[] = []; | |
| 241 | + for (let d = 0; d < 7; d++) { | |
| 242 | + const iso = cur.toISOString().slice(0, 10); | |
| 243 | + col.push({ date: iso, v: byDate.get(iso) ?? 0 }); | |
| 244 | + cur.setDate(cur.getDate() + 1); | |
| 245 | + } | |
| 246 | + cols.push(col); | |
| 247 | + } | |
| 248 | + return ( | |
| 249 | + <figure className="card" style={{ margin: 0, padding: 16 }}> | |
| 250 | + <figcaption><b style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{title}</b> <span className="klabel">26 dernières semaines</span></figcaption> | |
| 251 | + <div className="tbl-wrap" style={{ marginTop: 12 }}> | |
| 252 | + <svg viewBox={`0 0 ${weeks * 14} ${7 * 14}`} style={{ minWidth: 480, width: "100%", height: "auto" }} role="img" aria-label={title}> | |
| 253 | + {cols.map((col, w) => col.map((c, d) => ( | |
| 254 | + <rect key={c.date} x={w * 14} y={d * 14} width={12} height={12} rx={2.5} | |
| 255 | + fill={c.v ? "var(--accent)" : "rgba(20,24,20,0.07)"} fillOpacity={c.v ? 0.25 + 0.75 * (c.v / max) : 1} | |
| 256 | + stroke="rgba(20,24,20,0.15)" strokeWidth={0.5}> | |
| 257 | + <title>{`${c.date} — ${fmtNum(c.v)}`}</title> | |
| 258 | + </rect> | |
| 259 | + )))} | |
| 260 | + </svg> | |
| 261 | + </div> | |
| 262 | + </figure> | |
| 263 | + ); | |
| 264 | +} | |
| 265 | + | |
| 266 | +/* ---------- Tableau : tri, recherche, pagination ---------- */ | |
| 267 | +export function DataTable({ spec, pageSize = 25 }: { spec: TableSpec; pageSize?: number }) { | |
| 268 | + const [q, setQ] = useState(""); | |
| 269 | + const [sort, setSort] = useState<{ col: number; dir: 1 | -1 } | null>(null); | |
| 270 | + const [page, setPage] = useState(0); | |
| 271 | + const rows = useMemo(() => { | |
| 272 | + let r = spec.rows ?? []; | |
| 273 | + if (q) r = r.filter((row) => row.some((c) => String(c).toLowerCase().includes(q.toLowerCase()))); | |
| 274 | + if (sort) r = [...r].sort((a, b) => { | |
| 275 | + const x = a[sort.col], y = b[sort.col]; | |
| 276 | + const nx = typeof x === "number" ? x : parseFloat(String(x).replace(/[^\d.,-]/g, "").replace(",", ".")); | |
| 277 | + const ny = typeof y === "number" ? y : parseFloat(String(y).replace(/[^\d.,-]/g, "").replace(",", ".")); | |
| 278 | + if (!Number.isNaN(nx) && !Number.isNaN(ny)) return (nx - ny) * sort.dir; | |
| 279 | + return String(x).localeCompare(String(y), "fr") * sort.dir; | |
| 280 | + }); | |
| 281 | + return r; | |
| 282 | + }, [spec.rows, q, sort]); | |
| 283 | + const pages = Math.max(1, Math.ceil(rows.length / pageSize)); | |
| 284 | + const cur = Math.min(page, pages - 1); | |
| 285 | + return ( | |
| 286 | + <section className="card" style={{ padding: 16 }}> | |
| 287 | + <div style={{ display: "flex", flexWrap: "wrap", gap: 10, justifyContent: "space-between", alignItems: "center" }}> | |
| 288 | + <b style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{spec.title}</b> | |
| 289 | + <input className="input" style={{ maxWidth: 240 }} placeholder="Rechercher…" value={q} | |
| 290 | + onChange={(e) => { setQ(e.target.value); setPage(0); }} aria-label={`Rechercher dans ${spec.title}`} /> | |
| 291 | + </div> | |
| 292 | + <div className="tbl-wrap" style={{ marginTop: 10 }}> | |
| 293 | + <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}> | |
| 294 | + <thead> | |
| 295 | + <tr> | |
| 296 | + {spec.columns.map((c, i) => ( | |
| 297 | + <th key={c} onClick={() => setSort((s) => ({ col: i, dir: s?.col === i && s.dir === 1 ? -1 : 1 }))} | |
| 298 | + style={{ cursor: "pointer", textAlign: "left", padding: "8px 10px", background: "var(--ink)", color: "var(--paper)", fontFamily: "var(--font-mono)", fontSize: 10.5, textTransform: "uppercase", letterSpacing: "0.06em", whiteSpace: "nowrap", userSelect: "none" }} | |
| 299 | + aria-sort={sort?.col === i ? (sort.dir === 1 ? "ascending" : "descending") : "none"}> | |
| 300 | + {c} {sort?.col === i ? (sort.dir === 1 ? "▲" : "▼") : "↕"} | |
| 301 | + </th> | |
| 302 | + ))} | |
| 303 | + </tr> | |
| 304 | + </thead> | |
| 305 | + <tbody> | |
| 306 | + {rows.slice(cur * pageSize, (cur + 1) * pageSize).map((row, ri) => ( | |
| 307 | + <tr key={ri} style={{ background: ri % 2 ? "var(--surface-2)" : "var(--surface)" }}> | |
| 308 | + {row.map((c, ci) => ( | |
| 309 | + <td key={ci} style={{ padding: "7px 10px", borderBottom: "1px solid var(--line)", whiteSpace: "nowrap" }}> | |
| 310 | + {typeof c === "number" ? fmtNum(c) : c} | |
| 311 | + </td> | |
| 312 | + ))} | |
| 313 | + </tr> | |
| 314 | + ))} | |
| 315 | + </tbody> | |
| 316 | + </table> | |
| 317 | + </div> | |
| 318 | + <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginTop: 10, flexWrap: "wrap", gap: 8 }}> | |
| 319 | + <span className="klabel">{fmtInt(rows.length)} lignes</span> | |
| 320 | + <span style={{ display: "flex", gap: 6 }}> | |
| 321 | + <button type="button" className="btn btn-ghost" disabled={cur === 0} onClick={() => setPage(cur - 1)}>←</button> | |
| 322 | + <span className="chip">{cur + 1} / {pages}</span> | |
| 323 | + <button type="button" className="btn btn-ghost" disabled={cur >= pages - 1} onClick={() => setPage(cur + 1)}>→</button> | |
| 324 | + </span> | |
| 325 | + </div> | |
| 326 | + </section> | |
| 327 | + ); | |
| 328 | +} | |
| 329 | + | |
| 330 | +/* ---------- Records / faits marquants ---------- */ | |
| 331 | +export function RecordCard({ r }: { r: RecordFact }) { | |
| 332 | + return ( | |
| 333 | + <article className="card" style={{ padding: "12px 16px", display: "flex", justifyContent: "space-between", gap: 12, alignItems: "baseline", background: "var(--surface-2)" }}> | |
| 334 | + <span style={{ fontSize: 13, color: "var(--ink-2)" }}>{r.label}</span> | |
| 335 | + <span style={{ textAlign: "right" }}> | |
| 336 | + <b style={{ fontFamily: "var(--font-display)", fontSize: 15 }}>{r.value}</b> | |
| 337 | + {r.date && <span className="klabel" style={{ display: "block" }}>{r.date}</span>} | |
| 338 | + </span> | |
| 339 | + </article> | |
| 340 | + ); | |
| 341 | +} | |
| 342 | + | |
| 343 | +/* ---------- Bouton PDF ---------- */ | |
| 344 | +export function PdfButton({ period, from, to, endpoint = "/api/stats/report" }: { period: string; from?: string; to?: string; endpoint?: string }) { | |
| 345 | + const [busy, setBusy] = useState(false); | |
| 346 | + const url = (mode: string) => { | |
| 347 | + const p = new URLSearchParams({ period, mode }); | |
| 348 | + if (from) p.set("from", from); | |
| 349 | + if (to) p.set("to", to); | |
| 350 | + return `${endpoint}?${p}`; | |
| 351 | + }; | |
| 352 | + const dl = (mode: string) => { | |
| 353 | + setBusy(true); | |
| 354 | + const a = document.createElement("a"); | |
| 355 | + a.href = url(mode); | |
| 356 | + a.download = ""; | |
| 357 | + document.body.appendChild(a); | |
| 358 | + a.click(); | |
| 359 | + a.remove(); | |
| 360 | + setTimeout(() => setBusy(false), 2500); | |
| 361 | + }; | |
| 362 | + return ( | |
| 363 | + <span style={{ display: "inline-flex", gap: 8, flexWrap: "wrap" }}> | |
| 364 | + <button type="button" className="btn btn-primary" onClick={() => dl("complet")} disabled={busy}> | |
| 365 | + {busy ? "Génération…" : "⬇ Télécharger le rapport PDF"} | |
| 366 | + </button> | |
| 367 | + <button type="button" className="btn btn-ghost" onClick={() => dl("synthese")} disabled={busy}> | |
| 368 | + Synthèse (2 p.) | |
| 369 | + </button> | |
| 370 | + </span> | |
| 371 | + ); | |
| 372 | +} | |
| 373 | + | |
| 374 | +/* ---------- États ---------- */ | |
| 375 | +export function EmptyBlock({ title }: { title: string }) { | |
| 376 | + return ( | |
| 377 | + <div className="card" style={{ padding: 20, background: "var(--surface-2)", borderStyle: "dashed" }}> | |
| 378 | + <b style={{ fontFamily: "var(--font-display)", fontSize: 14 }}>{title}</b> | |
| 379 | + <p className="klabel" style={{ margin: "6px 0 0" }}>Pas encore mesuré — aucune donnée disponible pour cette période.</p> | |
| 380 | + </div> | |
| 381 | + ); | |
| 382 | +} | |
| 383 | + | |
| 384 | +export function Fraicheur({ updated, onRefresh }: { updated: string; onRefresh: () => void }) { | |
| 385 | + return ( | |
| 386 | + <p style={{ display: "flex", gap: 10, alignItems: "center", flexWrap: "wrap", margin: 0 }}> | |
| 387 | + <span className="klabel">Mis à jour le {new Date(updated).toLocaleString("fr-CA", { dateStyle: "medium", timeStyle: "short" })}</span> | |
| 388 | + <button type="button" className="btn btn-ghost" onClick={onRefresh}>↻ Rafraîchir</button> | |
| 389 | + </p> | |
| 390 | + ); | |
| 391 | +} | |
added
frontend/src/ka/stats/kapdf.py
+559 −0
@@ -0,0 +1,559 @@ | ||
| 1 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +# ka-ui/stats/kapdf.py — moteur PDF commun Groupe KA (fpdf2). | |
| 3 | +# Consomme le JSON du contrat /api/stats/dashboard (voir SPEC.md) et produit | |
| 4 | +# le rapport estampillé Groupe-KA : couverture, sommaire, KPI, graphiques | |
| 5 | +# VECTORIELS (courbes/barres/anneaux), tableaux paginés, records, page de fin. | |
| 6 | +# Usage : | |
| 7 | +# from kapdf import GroupeKAReport | |
| 8 | +# pdf_bytes = GroupeKAReport(site={"wordmark":"Lou·Ka","accent":"#ff6a00", | |
| 9 | +# "domain":"www.lou-ka.com","tagline":"…"}, dashboard=dash_json, | |
| 10 | +# mode="complet").build() | |
| 11 | +# Dépendance : pip install fpdf2 (aucune autre) | |
| 12 | +from __future__ import annotations | |
| 13 | + | |
| 14 | +import math | |
| 15 | +from datetime import datetime | |
| 16 | +from zoneinfo import ZoneInfo | |
| 17 | + | |
| 18 | +from fpdf import FPDF | |
| 19 | + | |
| 20 | +INK = (20, 24, 20) | |
| 21 | +INK2 = (77, 85, 81) | |
| 22 | +INK3 = (139, 146, 140) | |
| 23 | +PAPER = (245, 243, 238) | |
| 24 | +SURFACE2 = (250, 249, 245) | |
| 25 | +GREEN = (28, 92, 65) | |
| 26 | +DANGER = (179, 66, 58) | |
| 27 | +WHITE = (255, 255, 255) | |
| 28 | + | |
| 29 | +EMAILS = [ | |
| 30 | + ("contact@groupe-ka.com", "Projets, partenariats & données"), | |
| 31 | + ("info@groupe-ka.com", "Médias & questions générales"), | |
| 32 | + ("admin@groupe-ka.com", "Légal, vie privée & Loi 25"), | |
| 33 | +] | |
| 34 | +DISCLAIMER = ( | |
| 35 | + "Groupe KA est un agrégateur de contenu : nous ne vendons rien, ne louons " | |
| 36 | + "rien et ne sommes partie à aucune transaction. Données lues à la source, " | |
| 37 | + "rien d'inventé, tout est traçable." | |
| 38 | +) | |
| 39 | + | |
| 40 | + | |
| 41 | +def _hex(c: str) -> tuple[int, int, int]: | |
| 42 | + c = c.lstrip("#") | |
| 43 | + return tuple(int(c[i : i + 2], 16) for i in (0, 2, 4)) # type: ignore | |
| 44 | + | |
| 45 | + | |
| 46 | +def _fr(n) -> str: | |
| 47 | + if isinstance(n, float) and not n.is_integer(): | |
| 48 | + return f"{n:,.2f}".replace(",", " ").replace(".", ",") | |
| 49 | + return f"{int(n):,}".replace(",", " ") | |
| 50 | + | |
| 51 | + | |
| 52 | +_SUBST = { | |
| 53 | + "—": "-", "–": "-", "→": "->", "▲": "+", "▼": "-", | |
| 54 | + "…": "...", "’": "'", "‘": "'", "“": '"', "”": '"', | |
| 55 | + "œ": "oe", "Œ": "OE", "−": "-", " ": " ", " ": " ", | |
| 56 | + "≤": "<=", "≥": ">=", | |
| 57 | +} | |
| 58 | + | |
| 59 | + | |
| 60 | +def _latin1(s: str) -> str: | |
| 61 | + for k, v in _SUBST.items(): | |
| 62 | + s = s.replace(k, v) | |
| 63 | + return s.encode("latin-1", "replace").decode("latin-1") | |
| 64 | + | |
| 65 | + | |
| 66 | +class _PDF(FPDF): | |
| 67 | + """FPDF avec en-tête/pied Groupe-KA sur chaque page (sauf couverture). | |
| 68 | + Les polices core sont latin-1 : normalize_text sanitise en amont.""" | |
| 69 | + | |
| 70 | + def normalize_text(self, text): | |
| 71 | + return super().normalize_text(_latin1(text)) | |
| 72 | + | |
| 73 | + def __init__(self, brand: str, accent: tuple, period_label: str): | |
| 74 | + super().__init__(orientation="P", unit="mm", format="A4") | |
| 75 | + self.brand = brand | |
| 76 | + self.accent = accent | |
| 77 | + self.period_label = period_label | |
| 78 | + self.cover_mode = False | |
| 79 | + self.set_margins(18, 20, 18) | |
| 80 | + self.set_auto_page_break(True, margin=22) | |
| 81 | + | |
| 82 | + def header(self): | |
| 83 | + if self.cover_mode: | |
| 84 | + return | |
| 85 | + self.set_font("helvetica", "B", 8.5) | |
| 86 | + self.set_text_color(*INK) | |
| 87 | + self.set_xy(18, 9) | |
| 88 | + self.cell(0, 5, f"Groupe KA · {self.brand}") | |
| 89 | + self.set_font("helvetica", "", 8) | |
| 90 | + self.set_text_color(*INK3) | |
| 91 | + self.set_xy(18, 9) | |
| 92 | + self.cell(0, 5, "Rapport statistique", align="R") | |
| 93 | + self.set_draw_color(*INK) | |
| 94 | + self.set_line_width(0.5) | |
| 95 | + self.line(18, 15.5, 192, 15.5) | |
| 96 | + self.set_y(20) | |
| 97 | + | |
| 98 | + def footer(self): | |
| 99 | + if self.cover_mode: | |
| 100 | + return | |
| 101 | + self.set_y(-15) | |
| 102 | + self.set_draw_color(*INK3) | |
| 103 | + self.set_line_width(0.2) | |
| 104 | + self.line(18, self.get_y() - 1.5, 192, self.get_y() - 1.5) | |
| 105 | + self.set_font("helvetica", "", 7.5) | |
| 106 | + self.set_text_color(*INK3) | |
| 107 | + year = datetime.now(ZoneInfo("America/Toronto")).year | |
| 108 | + self.cell(130, 5, f"© Groupe-KA — {year} — groupe-ka.com · {self.period_label}") | |
| 109 | + self.cell(0, 5, f"p. {self.page_no()}/{{nb}}", align="R") | |
| 110 | + | |
| 111 | + | |
| 112 | +class GroupeKAReport: | |
| 113 | + def __init__(self, site: dict, dashboard: dict, mode: str = "complet"): | |
| 114 | + self.site = site | |
| 115 | + self.d = dashboard | |
| 116 | + self.mode = mode | |
| 117 | + self.accent = _hex(site.get("accent", "#d9f26b")) | |
| 118 | + period = dashboard.get("period", {}) or {} | |
| 119 | + self.period_label = period.get("label") or "toute la période" | |
| 120 | + self.pdf = _PDF(site.get("wordmark", ""), self.accent, self.period_label) | |
| 121 | + self.toc: list[tuple[str, int]] = [] | |
| 122 | + | |
| 123 | + # ---------- primitives ---------- | |
| 124 | + def _card(self, x, y, w, h, fill=WHITE): | |
| 125 | + p = self.pdf | |
| 126 | + p.set_draw_color(*INK) | |
| 127 | + p.set_line_width(0.45) | |
| 128 | + p.set_fill_color(*fill) | |
| 129 | + p.rect(x, y, w, h, style="DF", round_corners=True, corner_radius=2.2) | |
| 130 | + | |
| 131 | + def _kicker(self, text): | |
| 132 | + p = self.pdf | |
| 133 | + p.set_font("helvetica", "B", 8) | |
| 134 | + p.set_text_color(*GREEN) | |
| 135 | + p.set_draw_color(*GREEN) | |
| 136 | + p.set_line_width(0.6) | |
| 137 | + y = p.get_y() + 2 | |
| 138 | + p.line(p.l_margin, y, p.l_margin + 7, y) | |
| 139 | + p.set_xy(p.l_margin + 9, y - 2.5) | |
| 140 | + p.cell(0, 5, text.upper()) | |
| 141 | + p.ln(8) | |
| 142 | + | |
| 143 | + def _section_title(self, title): | |
| 144 | + if self.pdf.get_y() > 240: | |
| 145 | + self.pdf.add_page() | |
| 146 | + self._kicker("Groupe KA · " + self.site.get("wordmark", "")) | |
| 147 | + self.pdf.set_font("helvetica", "B", 15) | |
| 148 | + self.pdf.set_text_color(*INK) | |
| 149 | + self.pdf.set_x(self.pdf.l_margin) | |
| 150 | + self.pdf.cell(0, 8, title) | |
| 151 | + self.toc.append((title, self.pdf.page_no())) | |
| 152 | + self.pdf.ln(11) | |
| 153 | + | |
| 154 | + # ---------- pages ---------- | |
| 155 | + def _cover(self): | |
| 156 | + p = self.pdf | |
| 157 | + p.cover_mode = True | |
| 158 | + p.set_auto_page_break(False) | |
| 159 | + p.add_page() | |
| 160 | + p.set_fill_color(*PAPER) | |
| 161 | + p.rect(0, 0, 210, 297, style="F") | |
| 162 | + p.set_draw_color(*INK) | |
| 163 | + p.set_line_width(1.0) | |
| 164 | + p.rect(10, 10, 190, 277) | |
| 165 | + # kicker | |
| 166 | + p.set_font("helvetica", "B", 10) | |
| 167 | + p.set_text_color(*GREEN) | |
| 168 | + p.set_xy(24, 34) | |
| 169 | + p.cell(0, 6, "GROUPE KA · RAPPORT STATISTIQUE") | |
| 170 | + # wordmark : partie gauche + boîte encre/accent | |
| 171 | + wm = self.site.get("wordmark", "") | |
| 172 | + left, boxed = (wm.split("·") + [None])[:2] if "·" in wm else (wm, None) | |
| 173 | + p.set_xy(24, 70) | |
| 174 | + p.set_font("helvetica", "B", 40) | |
| 175 | + p.set_text_color(*INK) | |
| 176 | + p.cell(p.get_string_width(left) + 2, 20, left) | |
| 177 | + if boxed: | |
| 178 | + bw = p.get_string_width(boxed) + 12 | |
| 179 | + x = p.get_x() + 2 | |
| 180 | + p.set_fill_color(*INK) | |
| 181 | + p.rect(x, 68, bw, 22, style="F", round_corners=True, corner_radius=3) | |
| 182 | + p.set_text_color(*self.accent) | |
| 183 | + p.set_xy(x + 6, 70) | |
| 184 | + p.cell(bw - 12, 18, boxed) | |
| 185 | + p.set_xy(24, 100) | |
| 186 | + p.set_font("helvetica", "", 13) | |
| 187 | + p.set_text_color(*INK2) | |
| 188 | + p.multi_cell(150, 7, f"Rapport statistique — {wm}") | |
| 189 | + now = datetime.now(ZoneInfo("America/Toronto")) | |
| 190 | + per = self.d.get("period", {}) or {} | |
| 191 | + p.set_xy(24, 125) | |
| 192 | + p.set_font("helvetica", "", 10.5) | |
| 193 | + rows = [ | |
| 194 | + ("Période couverte", self.period_label + (f" ({per.get('from')} → {per.get('to')})" if per.get("from") else "")), | |
| 195 | + ("Généré le", now.strftime("%Y-%m-%d à %H:%M") + " (heure de l'Est)"), | |
| 196 | + ("Plateforme", "https://" + self.site.get("domain", "")), | |
| 197 | + ("Mode", "Rapport complet" if self.mode == "complet" else "Synthèse"), | |
| 198 | + ] | |
| 199 | + y = 128 | |
| 200 | + for k, v in rows: | |
| 201 | + p.set_xy(24, y) | |
| 202 | + p.set_text_color(*INK3) | |
| 203 | + p.cell(40, 6, k) | |
| 204 | + p.set_text_color(*INK) | |
| 205 | + p.set_font("helvetica", "B", 10.5) | |
| 206 | + p.cell(0, 6, str(v)) | |
| 207 | + p.set_font("helvetica", "", 10.5) | |
| 208 | + y += 8 | |
| 209 | + # bande encre au pied | |
| 210 | + p.set_fill_color(*INK) | |
| 211 | + p.rect(10, 262, 190, 25, style="F") | |
| 212 | + p.set_xy(24, 270) | |
| 213 | + p.set_font("helvetica", "B", 12) | |
| 214 | + p.set_text_color(*WHITE) | |
| 215 | + p.cell(60, 8, "par Groupe ") | |
| 216 | + p.set_text_color(*self.accent) | |
| 217 | + p.set_xy(24 + p.get_string_width("par Groupe ") + 1, 270) | |
| 218 | + p.cell(20, 8, "KA") | |
| 219 | + p.set_font("helvetica", "B", 10) | |
| 220 | + p.set_xy(24, 270) | |
| 221 | + p.set_text_color(*self.accent) | |
| 222 | + p.cell(162, 8, "groupe-ka.com", align="R") | |
| 223 | + p.set_auto_page_break(True, margin=22) | |
| 224 | + p.cover_mode = False | |
| 225 | + | |
| 226 | + def _kpis(self): | |
| 227 | + kpis = self.d.get("kpis") or [] | |
| 228 | + if not kpis: | |
| 229 | + return | |
| 230 | + self._section_title("Synthèse des indicateurs") | |
| 231 | + p = self.pdf | |
| 232 | + cols, gw, gh, gap = 3, 56, 26, 3 | |
| 233 | + x0, y = p.l_margin, p.get_y() | |
| 234 | + for i, k in enumerate(kpis[:9]): | |
| 235 | + x = x0 + (i % cols) * (gw + gap) | |
| 236 | + if i and i % cols == 0: | |
| 237 | + y += gh + gap | |
| 238 | + if y > 250: | |
| 239 | + p.add_page(); y = p.get_y() | |
| 240 | + self._card(x, y, gw, gh) | |
| 241 | + p.set_xy(x + 4, y + 4) | |
| 242 | + p.set_font("helvetica", "B", 14) | |
| 243 | + p.set_text_color(*INK) | |
| 244 | + val = k.get("value") | |
| 245 | + p.cell(gw - 8, 7, (_fr(val) if isinstance(val, (int, float)) else str(val)) + (" " + k["unit"] if k.get("unit") else "")) | |
| 246 | + p.set_xy(x + 4, y + 12) | |
| 247 | + p.set_font("helvetica", "", 7.6) | |
| 248 | + p.set_text_color(*INK2) | |
| 249 | + p.multi_cell(gw - 8, 3.6, str(k.get("label", ""))[:70]) | |
| 250 | + if k.get("delta_pct") is not None: | |
| 251 | + up = (k.get("direction") or ("up" if k["delta_pct"] >= 0 else "down")) == "up" | |
| 252 | + p.set_xy(x + 4, y + gh - 6.5) | |
| 253 | + p.set_font("helvetica", "B", 8) | |
| 254 | + p.set_text_color(*(GREEN if up else DANGER)) | |
| 255 | + arrow = "+" if k["delta_pct"] >= 0 else "" | |
| 256 | + p.cell(gw - 8, 4, f"{'▲' if up else '▼'} {arrow}{str(k['delta_pct']).replace('.', ',')} % vs période préc.") | |
| 257 | + p.set_y(y + gh + 8) | |
| 258 | + | |
| 259 | + def _line_chart(self, s): | |
| 260 | + p = self.pdf | |
| 261 | + pts = s.get("points") or [] | |
| 262 | + if len(pts) < 2: | |
| 263 | + return | |
| 264 | + if p.get_y() > 200: | |
| 265 | + p.add_page() | |
| 266 | + p.set_font("helvetica", "B", 10) | |
| 267 | + p.set_text_color(*INK) | |
| 268 | + p.cell(0, 6, s.get("title", "")) | |
| 269 | + p.ln(7) | |
| 270 | + x0, y0, w, h = p.l_margin, p.get_y(), 174, 52 | |
| 271 | + self._card(x0, y0, w, h, fill=WHITE) | |
| 272 | + cx, cy, cw, ch = x0 + 12, y0 + 6, w - 20, h - 16 | |
| 273 | + vals = [pt["v"] for pt in pts] + [c["v"] for c in (s.get("compare") or [])] | |
| 274 | + vmax = max(vals) or 1 | |
| 275 | + vmin = min(0, min(vals)) | |
| 276 | + rng = (vmax - vmin) or 1 | |
| 277 | + # grille + graduations | |
| 278 | + p.set_font("helvetica", "", 6.3) | |
| 279 | + p.set_text_color(*INK3) | |
| 280 | + p.set_draw_color(200, 200, 195) | |
| 281 | + p.set_line_width(0.15) | |
| 282 | + for g in range(5): | |
| 283 | + gy = cy + ch - ch * g / 4 | |
| 284 | + p.line(cx, gy, cx + cw, gy) | |
| 285 | + p.set_xy(x0 + 1, gy - 1.6) | |
| 286 | + p.cell(10, 3, _fr(vmin + rng * g / 4), align="R") | |
| 287 | + | |
| 288 | + def draw(series, color, width, dash=None): | |
| 289 | + n = len(series) | |
| 290 | + p.set_draw_color(*color) | |
| 291 | + p.set_line_width(width) | |
| 292 | + if dash: | |
| 293 | + p.set_dash_pattern(dash=1.2, gap=1.2) | |
| 294 | + last = None | |
| 295 | + for i, pt in enumerate(series): | |
| 296 | + px = cx + cw * (i / (n - 1)) | |
| 297 | + py = cy + ch - ch * ((pt["v"] - vmin) / rng) | |
| 298 | + if last: | |
| 299 | + p.line(last[0], last[1], px, py) | |
| 300 | + last = (px, py) | |
| 301 | + p.set_dash_pattern() | |
| 302 | + | |
| 303 | + if s.get("compare"): | |
| 304 | + draw(s["compare"], INK3, 0.35, dash=True) | |
| 305 | + draw(pts, self.accent, 0.7) | |
| 306 | + # libellés d'axe X (premier / milieu / dernier) | |
| 307 | + p.set_text_color(*INK3) | |
| 308 | + for frac, idx in ((0, 0), (0.5, len(pts) // 2), (1, -1)): | |
| 309 | + p.set_xy(cx + cw * frac - 9, cy + ch + 1.5) | |
| 310 | + p.cell(18, 3, str(pts[idx].get("t", ""))[:10], align="C") | |
| 311 | + p.set_y(y0 + h + 4) | |
| 312 | + if s.get("compare"): | |
| 313 | + p.set_font("helvetica", "", 6.8) | |
| 314 | + p.set_text_color(*INK3) | |
| 315 | + p.cell(0, 4, "— période courante (accent) · ---- période comparée") | |
| 316 | + p.ln(6) | |
| 317 | + else: | |
| 318 | + p.ln(2) | |
| 319 | + | |
| 320 | + def _bars(self, title, items, unit=""): | |
| 321 | + p = self.pdf | |
| 322 | + items = [it for it in (items or []) if isinstance(it.get("value"), (int, float))][:12] | |
| 323 | + if not items: | |
| 324 | + return | |
| 325 | + need = 10 + len(items) * 7 | |
| 326 | + if p.get_y() + need > 265: | |
| 327 | + p.add_page() | |
| 328 | + p.set_font("helvetica", "B", 10) | |
| 329 | + p.set_text_color(*INK) | |
| 330 | + p.cell(0, 6, title) | |
| 331 | + p.ln(8) | |
| 332 | + vmax = max(it["value"] for it in items) or 1 | |
| 333 | + for it in items: | |
| 334 | + y = p.get_y() | |
| 335 | + p.set_font("helvetica", "", 7.6) | |
| 336 | + p.set_text_color(*INK) | |
| 337 | + p.set_x(p.l_margin) | |
| 338 | + p.cell(46, 5, str(it["label"])[:34]) | |
| 339 | + bw = 96 * (it["value"] / vmax) | |
| 340 | + p.set_fill_color(*self.accent) | |
| 341 | + p.set_draw_color(*INK) | |
| 342 | + p.set_line_width(0.25) | |
| 343 | + p.rect(p.l_margin + 48, y + 0.7, max(bw, 0.8), 3.6, style="DF") | |
| 344 | + p.set_xy(p.l_margin + 148, y) | |
| 345 | + p.set_font("helvetica", "B", 7.6) | |
| 346 | + p.cell(26, 5, _fr(it["value"]) + (" " + unit if unit else ""), align="R") | |
| 347 | + p.ln(6.4) | |
| 348 | + p.ln(3) | |
| 349 | + | |
| 350 | + def _donut(self, b): | |
| 351 | + # anneau vectoriel simple (arcs) + légende | |
| 352 | + p = self.pdf | |
| 353 | + items = [it for it in (b.get("items") or []) if it.get("value")][:8] | |
| 354 | + total = sum(it["value"] for it in items) | |
| 355 | + if not items or not total: | |
| 356 | + return | |
| 357 | + if p.get_y() > 210: | |
| 358 | + p.add_page() | |
| 359 | + p.set_font("helvetica", "B", 10) | |
| 360 | + p.set_text_color(*INK) | |
| 361 | + p.cell(0, 6, b.get("title", "")) | |
| 362 | + p.ln(8) | |
| 363 | + cx, cy, r = p.l_margin + 26, p.get_y() + 24, 20 | |
| 364 | + shades = [1.0, 0.78, 0.58, 0.42, 0.30, 0.22, 0.15, 0.10] | |
| 365 | + start = -90.0 | |
| 366 | + for i, it in enumerate(items): | |
| 367 | + frac = it["value"] / total | |
| 368 | + f = shades[i % len(shades)] | |
| 369 | + col = tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) for j in range(3)) | |
| 370 | + steps = max(2, int(72 * frac)) | |
| 371 | + p.set_fill_color(*col) | |
| 372 | + p.set_draw_color(*col) | |
| 373 | + for st in range(steps): | |
| 374 | + a0 = math.radians(start + 360 * frac * st / steps) | |
| 375 | + a1 = math.radians(start + 360 * frac * (st + 1) / steps) | |
| 376 | + p.polygon( | |
| 377 | + [(cx, cy), | |
| 378 | + (cx + r * math.cos(a0), cy + r * math.sin(a0)), | |
| 379 | + (cx + r * math.cos(a1), cy + r * math.sin(a1))], | |
| 380 | + style="DF", | |
| 381 | + ) | |
| 382 | + start += 360 * frac | |
| 383 | + p.set_fill_color(*WHITE) | |
| 384 | + p.set_draw_color(*INK) | |
| 385 | + p.set_line_width(0.4) | |
| 386 | + p.ellipse(cx - 11, cy - 11, 22, 22, style="DF") | |
| 387 | + p.ellipse(cx - r, cy - r, 2 * r, 2 * r, style="D") | |
| 388 | + # légende | |
| 389 | + ly = cy - 22 | |
| 390 | + for i, it in enumerate(items): | |
| 391 | + f = shades[i % len(shades)] | |
| 392 | + col = tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) for j in range(3)) | |
| 393 | + p.set_fill_color(*col) | |
| 394 | + p.set_draw_color(*INK) | |
| 395 | + p.rect(p.l_margin + 60, ly + 0.8, 4, 4, style="DF") | |
| 396 | + p.set_xy(p.l_margin + 66, ly) | |
| 397 | + p.set_font("helvetica", "", 7.6) | |
| 398 | + p.set_text_color(*INK) | |
| 399 | + pct = 100 * it["value"] / total | |
| 400 | + p.cell(0, 5.6, f"{str(it['label'])[:40]} — {_fr(it['value'])} ({pct:.1f} %)".replace(".", ",")) | |
| 401 | + ly += 5.6 | |
| 402 | + p.set_y(max(cy + r, ly) + 6) | |
| 403 | + | |
| 404 | + def _table(self, t): | |
| 405 | + p = self.pdf | |
| 406 | + cols = t.get("columns") or [] | |
| 407 | + rows = t.get("rows") or [] | |
| 408 | + if not cols or not rows: | |
| 409 | + return | |
| 410 | + self._section_title(t.get("title", "Tableau")) | |
| 411 | + w = 174 / len(cols) | |
| 412 | + def head(): | |
| 413 | + p.set_font("helvetica", "B", 7.6) | |
| 414 | + p.set_fill_color(*INK) | |
| 415 | + p.set_text_color(*WHITE) | |
| 416 | + for c in cols: | |
| 417 | + p.cell(w, 6, " " + str(c)[:30], fill=True) | |
| 418 | + p.ln(6) | |
| 419 | + head() | |
| 420 | + p.set_text_color(*INK) | |
| 421 | + for i, row in enumerate(rows[:200]): | |
| 422 | + if p.get_y() > 262: | |
| 423 | + p.add_page() | |
| 424 | + head() | |
| 425 | + p.set_text_color(*INK) | |
| 426 | + p.set_font("helvetica", "", 7.4) | |
| 427 | + p.set_fill_color(*(SURFACE2 if i % 2 else WHITE)) | |
| 428 | + for cell in row: | |
| 429 | + txt = _fr(cell) if isinstance(cell, (int, float)) else str(cell) | |
| 430 | + p.cell(w, 5.4, " " + txt[:34], fill=True) | |
| 431 | + p.ln(5.4) | |
| 432 | + if len(rows) > 200: | |
| 433 | + p.set_font("helvetica", "", 7) | |
| 434 | + p.set_text_color(*INK3) | |
| 435 | + p.cell(0, 5, f"… {len(rows) - 200} lignes supplémentaires non imprimées") | |
| 436 | + p.ln(6) | |
| 437 | + | |
| 438 | + def _records(self): | |
| 439 | + recs = self.d.get("records") or [] | |
| 440 | + if not recs: | |
| 441 | + return | |
| 442 | + self._section_title("Records & faits marquants") | |
| 443 | + p = self.pdf | |
| 444 | + for r in recs[:10]: | |
| 445 | + if p.get_y() > 258: | |
| 446 | + p.add_page() | |
| 447 | + y = p.get_y() | |
| 448 | + self._card(p.l_margin, y, 174, 11, fill=SURFACE2) | |
| 449 | + p.set_xy(p.l_margin + 4, y + 2) | |
| 450 | + p.set_font("helvetica", "", 8.6) | |
| 451 | + p.set_text_color(*INK2) | |
| 452 | + p.cell(96, 7, str(r.get("label", ""))[:70]) | |
| 453 | + p.set_font("helvetica", "B", 9) | |
| 454 | + p.set_text_color(*INK) | |
| 455 | + p.cell(52, 7, str(r.get("value", ""))[:36], align="R") | |
| 456 | + p.set_font("helvetica", "", 7.6) | |
| 457 | + p.set_text_color(*INK3) | |
| 458 | + p.cell(20, 7, str(r.get("date", "") or ""), align="R") | |
| 459 | + p.set_y(y + 13.5) | |
| 460 | + p.ln(4) | |
| 461 | + | |
| 462 | + def _final_page(self): | |
| 463 | + p = self.pdf | |
| 464 | + p.add_page() | |
| 465 | + self._kicker("Groupe KA · contact") | |
| 466 | + p.set_font("helvetica", "B", 15) | |
| 467 | + p.set_text_color(*INK) | |
| 468 | + p.cell(0, 8, "Coordonnées du Groupe KA") | |
| 469 | + p.ln(12) | |
| 470 | + for email, role in EMAILS: | |
| 471 | + p.set_font("helvetica", "B", 10.5) | |
| 472 | + p.set_text_color(*INK) | |
| 473 | + p.cell(0, 6, email) | |
| 474 | + p.ln(5.5) | |
| 475 | + p.set_font("helvetica", "", 8.6) | |
| 476 | + p.set_text_color(*INK3) | |
| 477 | + p.cell(0, 5, role) | |
| 478 | + p.ln(8) | |
| 479 | + p.ln(2) | |
| 480 | + p.set_font("helvetica", "B", 10) | |
| 481 | + p.set_text_color(*GREEN) | |
| 482 | + p.cell(0, 6, "groupe-ka.com — le portail de l'écosystème ·Ka") | |
| 483 | + p.ln(10) | |
| 484 | + p.set_draw_color(*self.accent) | |
| 485 | + p.set_line_width(0.8) | |
| 486 | + p.line(p.l_margin, p.get_y(), p.l_margin + 30, p.get_y()) | |
| 487 | + p.ln(4) | |
| 488 | + p.set_font("helvetica", "", 8.6) | |
| 489 | + p.set_text_color(*INK2) | |
| 490 | + p.multi_cell(160, 4.6, DISCLAIMER) | |
| 491 | + p.ln(4) | |
| 492 | + p.set_font("helvetica", "", 7.6) | |
| 493 | + p.set_text_color(*INK3) | |
| 494 | + p.multi_cell( | |
| 495 | + 160, 4.2, | |
| 496 | + "Mentions : rapport généré automatiquement à partir des données réelles de la " | |
| 497 | + "plateforme au moment indiqué en couverture. Conditions d'utilisation, politique " | |
| 498 | + "de confidentialité et protection des renseignements personnels (Loi 25) : " | |
| 499 | + "groupe-ka.com/conditions · /confidentialite · /loi-25.", | |
| 500 | + ) | |
| 501 | + | |
| 502 | + def _toc_page(self): | |
| 503 | + # insérée après coup ? fpdf ne réordonne pas : on écrit le sommaire en | |
| 504 | + # page 2 en réservant la page lors du build (voir build()). | |
| 505 | + pass | |
| 506 | + | |
| 507 | + def build(self) -> bytes: | |
| 508 | + p = self.pdf | |
| 509 | + p.alias_nb_pages() | |
| 510 | + self._cover() | |
| 511 | + if self.mode == "synthese": | |
| 512 | + p.add_page() | |
| 513 | + self._kpis() | |
| 514 | + self._records() | |
| 515 | + self._final_page() | |
| 516 | + else: | |
| 517 | + p.add_page() | |
| 518 | + toc_page_no = p.page_no() | |
| 519 | + p.add_page() | |
| 520 | + self._kpis() | |
| 521 | + for s in self.d.get("series") or []: | |
| 522 | + if s.get("kind") == "bar": | |
| 523 | + self._bars(s.get("title", ""), [{"label": pt.get("t"), "value": pt.get("v")} for pt in (s.get("points") or [])], s.get("unit", "")) | |
| 524 | + else: | |
| 525 | + self._line_chart(s) | |
| 526 | + for b in self.d.get("breakdowns") or []: | |
| 527 | + if b.get("kind") == "donut": | |
| 528 | + self._donut(b) | |
| 529 | + else: | |
| 530 | + self._bars(b.get("title", ""), b.get("items")) | |
| 531 | + geo = self.d.get("geo") | |
| 532 | + if geo: | |
| 533 | + self._bars(geo.get("title", "Répartition géographique"), geo.get("items")) | |
| 534 | + for t in self.d.get("tables") or []: | |
| 535 | + self._table(t) | |
| 536 | + self._records() | |
| 537 | + self._final_page() | |
| 538 | + # sommaire écrit sur la page réservée (page 2) | |
| 539 | + last_page = p.page | |
| 540 | + p.page = toc_page_no | |
| 541 | + p.set_y(22) | |
| 542 | + p.set_font("helvetica", "B", 15) | |
| 543 | + p.set_text_color(*INK) | |
| 544 | + p.cell(0, 8, "Sommaire") | |
| 545 | + p.ln(12) | |
| 546 | + p.set_font("helvetica", "", 9.5) | |
| 547 | + for title, page_no in self.toc: | |
| 548 | + p.set_text_color(*INK) | |
| 549 | + p.cell(140, 6.5, title[:80]) | |
| 550 | + p.set_text_color(*INK3) | |
| 551 | + p.cell(0, 6.5, str(page_no), align="R") | |
| 552 | + p.ln(6.5) | |
| 553 | + p.page = last_page | |
| 554 | + return bytes(p.output()) | |
| 555 | + | |
| 556 | + | |
| 557 | +def filename(platform_id: str, period: str) -> str: | |
| 558 | + today = datetime.now(ZoneInfo("America/Toronto")).strftime("%Y-%m-%d") | |
| 559 | + return f"groupe-ka_{platform_id}_stats_{period}_{today}.pdf" | |
modified
frontend/src/pages/Stats.tsx
+126 −148
@@ -1,54 +1,64 @@ | ||
| 1 | 1 | // ----------------------------------------------------------------------------- |
| 2 | 2 | // Auto-Ka — Agrégateur de voitures usagées à vendre (province de Québec) |
| 3 | 3 | // Auteur : Simon-Pierre Boucher — contact@spboucher.ai |
| 4 | −// Stats.tsx : tableau de bord du marché — héros, distributions, classements, | |
| 5 | −// parts de marché, aubaines + rapport PDF téléchargeable. | |
| 4 | +// Stats.tsx : tableau de bord analytique du marché — module Stats commun | |
| 5 | +// Groupe KA (contrat ka/stats/SPEC.md) : KPI + deltas, sélecteur | |
| 6 | +// de période, courbes/anneaux/barres/heatmap, répartition | |
| 7 | +// géographique, tableaux détaillés, records et export PDF. | |
| 6 | 8 | // ----------------------------------------------------------------------------- |
| 7 | −import { useEffect, useState } from "react"; | |
| 8 | −import { Link } from "react-router-dom"; | |
| 9 | −import { fmtPrice } from "../api"; | |
| 10 | −import { Datum, Donut, HBars, VBars } from "../components/Charts"; | |
| 11 | − | |
| 12 | −interface Detailed { | |
| 13 | − total: number; | |
| 14 | − sources: number; | |
| 15 | − regions: number; | |
| 16 | − avg_price: number | null; | |
| 17 | − median_price: number | null; | |
| 18 | − avg_km: number | null; | |
| 19 | − median_km: number | null; | |
| 20 | − avg_year: number | null; | |
| 21 | − new_7d: number; | |
| 22 | − electrified_pct: number; | |
| 23 | − price_hist: Datum[]; | |
| 24 | − km_hist: Datum[]; | |
| 25 | − year_hist: Datum[]; | |
| 26 | − by_make: { label: string; n: number; avg_price: number | null }[]; | |
| 27 | − by_region: { label: string; n: number; avg_price: number | null }[]; | |
| 28 | − by_body: Datum[]; | |
| 29 | − by_fuel: Datum[]; | |
| 30 | − top_models: { label: string; n: number; avg_price: number | null; avg_km: number | null }[]; | |
| 31 | − top_dealers: { label: string; n: number; avg_price: number | null }[]; | |
| 32 | − price_drops: { | |
| 33 | − uid: string; title: string; year: number | null; price: number; | |
| 34 | − prev_price: number; dealer_name: string; city: string; | |
| 35 | − }[]; | |
| 9 | +import { useCallback, useEffect, useState } from "react"; | |
| 10 | +import type { CSSProperties } from "react"; | |
| 11 | +import { | |
| 12 | + BarChart, CalendarHeatmap, DataTable, Donut, EmptyBlock, Fraicheur, | |
| 13 | + KpiCard, LineChart, PdfButton, PeriodSelector, RecordCard, | |
| 14 | +} from "../ka/stats/kacharts"; | |
| 15 | +import type { Kpi, RecordFact, Serie, TableSpec } from "../ka/stats/kacharts"; | |
| 16 | + | |
| 17 | +interface Breakdown { | |
| 18 | + id: string; title: string; kind: "donut" | "bar"; | |
| 19 | + items: { label: string; value: number }[]; | |
| 20 | +} | |
| 21 | +interface Dashboard { | |
| 22 | + updated: string; | |
| 23 | + period: { from: string; to: string; label: string }; | |
| 24 | + kpis: Kpi[]; | |
| 25 | + series: Serie[]; | |
| 26 | + breakdowns: Breakdown[]; | |
| 27 | + geo?: { title: string; items: { label: string; value: number }[] }; | |
| 28 | + heatmap?: { title: string; cells: { date: string; value: number }[] }; | |
| 29 | + tables: TableSpec[]; | |
| 30 | + records: RecordFact[]; | |
| 36 | 31 | } |
| 37 | 32 | |
| 38 | −const fmtK = (n: number | null) => | |
| 39 | − n == null ? "—" : `${Math.round(n / 1000)}k km`; | |
| 33 | +const grid = (min: number): CSSProperties => ({ | |
| 34 | + display: "grid", gap: 16, | |
| 35 | + gridTemplateColumns: `repeat(auto-fit, minmax(min(${min}px, 100%), 1fr))`, | |
| 36 | +}); | |
| 40 | 37 | |
| 41 | 38 | export default function StatsPage() { |
| 42 | − const [s, setS] = useState<Detailed | null>(null); | |
| 39 | + const [period, setPeriod] = useState("30j"); | |
| 40 | + const [custom, setCustom] = useState<{ from: string; to: string }>({ from: "", to: "" }); | |
| 41 | + const [dash, setDash] = useState<Dashboard | null>(null); | |
| 42 | + const [loading, setLoading] = useState(true); | |
| 43 | + const [error, setError] = useState(false); | |
| 44 | + | |
| 45 | + const useCustom = Boolean(custom.from && custom.to); | |
| 43 | 46 | |
| 44 | − useEffect(() => { | |
| 45 | − fetch("/api/stats/detailed") | |
| 46 | − .then((r) => r.json()) | |
| 47 | − .then(setS) | |
| 48 | − .catch(() => {}); | |
| 49 | − }, []); | |
| 47 | + const load = useCallback(() => { | |
| 48 | + setLoading(true); | |
| 49 | + setError(false); | |
| 50 | + const q = new URLSearchParams({ period }); | |
| 51 | + if (useCustom) { q.set("from", custom.from); q.set("to", custom.to); } | |
| 52 | + fetch(`/api/stats/dashboard?${q}`) | |
| 53 | + .then((r) => { if (!r.ok) throw new Error(String(r.status)); return r.json(); }) | |
| 54 | + .then((d: Dashboard) => setDash(d)) | |
| 55 | + .catch(() => setError(true)) | |
| 56 | + .finally(() => setLoading(false)); | |
| 57 | + }, [period, custom.from, custom.to, useCustom]); | |
| 50 | 58 | |
| 51 | − if (!s) | |
| 59 | + useEffect(load, [load]); | |
| 60 | + | |
| 61 | + if (!dash && loading) | |
| 52 | 62 | return ( |
| 53 | 63 | <div className="container page"> |
| 54 | 64 | <div className="skeleton" style={{ height: 120, marginBottom: 20 }} /> |
@@ -56,130 +66,98 @@ export default function StatsPage() { | ||
| 56 | 66 | </div> |
| 57 | 67 | ); |
| 58 | 68 | |
| 59 | − const withPrice = (rows: { label: string; n: number; avg_price: number | null }[]): Datum[] => | |
| 60 | − rows.map((r) => ({ | |
| 61 | − label: r.label, n: r.n, | |
| 62 | − extra: r.avg_price ? `moy. ${fmtPrice(r.avg_price)}` : undefined, | |
| 63 | − })); | |
| 69 | + if (!dash) | |
| 70 | + return ( | |
| 71 | + <div className="container page"> | |
| 72 | + <EmptyBlock title="Statistiques du marché" /> | |
| 73 | + </div> | |
| 74 | + ); | |
| 75 | + | |
| 76 | + const donuts = dash.breakdowns.filter((b) => b.kind === "donut"); | |
| 77 | + const bars = dash.breakdowns.filter((b) => b.kind !== "donut"); | |
| 64 | 78 | |
| 65 | 79 | return ( |
| 66 | − <div className="container page"> | |
| 80 | + <div className="container page" style={{ display: "grid", gap: 22 }}> | |
| 81 | + {/* --- 1. entête : titre + PDF + fraîcheur ------------------------------ */} | |
| 67 | 82 | <div className="stats-head"> |
| 68 | 83 | <div> |
| 69 | − <span className="kicker">Marché</span> | |
| 70 | − <h1>Le marché de l'occasion, en direct</h1> | |
| 71 | − <p className="lead" style={{ marginBottom: 0 }}> | |
| 72 | − Calculé en temps réel sur l'inventaire actif de {s.sources} concessionnaires | |
| 73 | − dans {s.regions} régions du Québec. | |
| 84 | + <span className="kicker">Statistiques · {dash.period.label}</span> | |
| 85 | + <h1>Le marché de l'occasion, en chiffres</h1> | |
| 86 | + <p className="lead" style={{ marginBottom: 10 }}> | |
| 87 | + Tableau de bord calculé sur les données réelles d'Auto-Ka — | |
| 88 | + inventaire des concessionnaires du Québec, du {dash.period.from} au{" "} | |
| 89 | + {dash.period.to}. | |
| 74 | 90 | </p> |
| 91 | + <Fraicheur updated={dash.updated} onRefresh={load} /> | |
| 75 | 92 | </div> |
| 76 | − <a className="btn pdf-btn" href="/api/stats/rapport.pdf"> | |
| 77 | − ⬇ Télécharger le rapport PDF | |
| 78 | − </a> | |
| 79 | − </div> | |
| 80 | − | |
| 81 | − <div className="stat-grid" style={{ marginTop: 26 }}> | |
| 82 | − <div className="stat-tile hero-tile"> | |
| 83 | − <b>{s.total.toLocaleString("fr-CA")}</b> | |
| 84 | − <span>véhicules en vente</span> | |
| 85 | − </div> | |
| 86 | − <div className="stat-tile"> | |
| 87 | − <b>{fmtPrice(s.avg_price)}</b> | |
| 88 | − <span>prix moyen</span> | |
| 89 | − </div> | |
| 90 | − <div className="stat-tile"> | |
| 91 | − <b>{fmtPrice(s.median_price)}</b> | |
| 92 | − <span>prix médian</span> | |
| 93 | − </div> | |
| 94 | − <div className="stat-tile"> | |
| 95 | − <b>{fmtK(s.avg_km)}</b> | |
| 96 | − <span>km moyen</span> | |
| 97 | − </div> | |
| 98 | − <div className="stat-tile"> | |
| 99 | − <b>{s.new_7d.toLocaleString("fr-CA")}</b> | |
| 100 | − <span>arrivages · 7 jours</span> | |
| 101 | − </div> | |
| 102 | − <div className="stat-tile"> | |
| 103 | − <b>{s.electrified_pct} %</b> | |
| 104 | − <span>électrifiés (VÉ + hybrides)</span> | |
| 93 | + <div style={{ marginLeft: "auto" }}> | |
| 94 | + <PdfButton period={useCustom ? "perso" : period} | |
| 95 | + from={useCustom ? custom.from : undefined} | |
| 96 | + to={useCustom ? custom.to : undefined} /> | |
| 105 | 97 | </div> |
| 106 | 98 | </div> |
| 107 | 99 | |
| 108 | − <div className="panel"> | |
| 109 | − <h3>💰 Distribution des prix</h3> | |
| 110 | − <VBars data={s.price_hist} color="#d94f1e" /> | |
| 100 | + {/* --- 2. sélecteur de période global ----------------------------------- */} | |
| 101 | + <div className="card" style={{ padding: "12px 16px" }}> | |
| 102 | + <PeriodSelector | |
| 103 | + value={useCustom ? "" : period} | |
| 104 | + onChange={(p) => { setCustom({ from: "", to: "" }); setPeriod(p); }} | |
| 105 | + custom={custom} | |
| 106 | + onCustom={(from, to) => setCustom({ from, to })} | |
| 107 | + /> | |
| 111 | 108 | </div> |
| 112 | 109 | |
| 113 | − <div className="vd-cols"> | |
| 114 | − <div className="panel"> | |
| 115 | − <h3>🛣 Kilométrage (milliers de km)</h3> | |
| 116 | − <VBars data={s.km_hist} color="#1f6fb5" height={170} /> | |
| 117 | − </div> | |
| 118 | − <div className="panel"> | |
| 119 | − <h3>📅 Années-modèles</h3> | |
| 120 | − <VBars data={s.year_hist} color="#b58500" height={170} /> | |
| 121 | − </div> | |
| 122 | − </div> | |
| 110 | + {error && ( | |
| 111 | + <p className="chip" style={{ background: "var(--danger-soft)" }}> | |
| 112 | + Impossible de charger les statistiques — réessayez. | |
| 113 | + </p> | |
| 114 | + )} | |
| 123 | 115 | |
| 124 | − <div className="vd-cols"> | |
| 125 | − <div className="panel"> | |
| 126 | − <h3>🏷 Top marques</h3> | |
| 127 | − <HBars data={withPrice(s.by_make)} color="#d94f1e" /> | |
| 128 | − </div> | |
| 129 | − <div className="panel"> | |
| 130 | − <h3>📍 Par région</h3> | |
| 131 | − <HBars data={withPrice(s.by_region)} color="#1f6fb5" /> | |
| 132 | − </div> | |
| 133 | − </div> | |
| 116 | + <div style={{ display: "grid", gap: 22, opacity: loading ? 0.55 : 1, transition: "opacity 0.2s" }}> | |
| 117 | + {/* --- 3. bandeau KPI -------------------------------------------------- */} | |
| 118 | + {dash.kpis.length ? ( | |
| 119 | + <div style={grid(210)}> | |
| 120 | + {dash.kpis.map((k) => <KpiCard key={k.id} k={k} />)} | |
| 121 | + </div> | |
| 122 | + ) : ( | |
| 123 | + <EmptyBlock title="Indicateurs clés" /> | |
| 124 | + )} | |
| 134 | 125 | |
| 135 | − <div className="vd-cols"> | |
| 136 | − <div className="panel"> | |
| 137 | − <h3>🚙 Carrosseries</h3> | |
| 138 | − <Donut data={s.by_body} /> | |
| 139 | − </div> | |
| 140 | − <div className="panel"> | |
| 141 | − <h3>⛽ Carburants</h3> | |
| 142 | − <Donut data={s.by_fuel} /> | |
| 126 | + {/* --- 4. courbes d'évolution ------------------------------------------ */} | |
| 127 | + {dash.series.length ? ( | |
| 128 | + dash.series.map((s) => <LineChart key={s.id} serie={s} />) | |
| 129 | + ) : ( | |
| 130 | + <EmptyBlock title="Évolution quotidienne" /> | |
| 131 | + )} | |
| 132 | + | |
| 133 | + {/* --- 5. répartitions : anneau + barres ------------------------------- */} | |
| 134 | + <div style={grid(340)}> | |
| 135 | + {donuts.map((b) => <Donut key={b.id} title={b.title} items={b.items} />)} | |
| 136 | + {bars.map((b) => <BarChart key={b.id} title={b.title} items={b.items} />)} | |
| 143 | 137 | </div> |
| 144 | − </div> | |
| 145 | 138 | |
| 146 | − <div className="panel"> | |
| 147 | − <h3>🚗 Modèles les plus offerts</h3> | |
| 148 | − <HBars | |
| 149 | − data={s.top_models.map((m) => ({ | |
| 150 | − label: m.label, n: m.n, | |
| 151 | − extra: `moy. ${fmtPrice(m.avg_price)} · ${fmtK(m.avg_km)}`, | |
| 152 | − }))} | |
| 153 | − color="#1c7a4d" | |
| 154 | − /> | |
| 155 | − </div> | |
| 139 | + {/* --- 6. répartition géographique -------------------------------------- */} | |
| 140 | + {dash.geo?.items?.length | |
| 141 | + ? <BarChart title={dash.geo.title} items={dash.geo.items} unit="véhicules" /> | |
| 142 | + : <EmptyBlock title="Par région" />} | |
| 156 | 143 | |
| 157 | − {s.price_drops.length > 0 && ( | |
| 158 | − <section style={{ margin: "30px 0" }}> | |
| 159 | − <h2 style={{ marginBottom: 14 }}>📉 Baisses de prix récentes</h2> | |
| 160 | − <div className="src-grid"> | |
| 161 | − {s.price_drops.map((d) => ( | |
| 162 | − <Link key={d.uid} to={`/vehicule/${encodeURIComponent(d.uid)}`} className="src-card"> | |
| 163 | − <h3>{d.title}</h3> | |
| 164 | − <div className="meta">{d.dealer_name} · {d.city}</div> | |
| 165 | − <div className="n"> | |
| 166 | − {fmtPrice(d.price)}{" "} | |
| 167 | − <span style={{ fontSize: 13, color: "var(--ink-3)", textDecoration: "line-through" }}> | |
| 168 | − {fmtPrice(d.prev_price)} | |
| 169 | − </span> | |
| 170 | − </div> | |
| 171 | − <div className="meta" style={{ color: "var(--good)", fontWeight: 700 }}> | |
| 172 | − −{fmtPrice(d.prev_price - d.price)} | |
| 173 | − </div> | |
| 174 | − </Link> | |
| 175 | − ))} | |
| 176 | − </div> | |
| 177 | − </section> | |
| 178 | − )} | |
| 144 | + {/* --- 7. calendrier de chaleur ------------------------------------------ */} | |
| 145 | + {dash.heatmap?.cells?.length | |
| 146 | + ? <CalendarHeatmap title={dash.heatmap.title} cells={dash.heatmap.cells} /> | |
| 147 | + : <EmptyBlock title="Activité quotidienne" />} | |
| 148 | + | |
| 149 | + {/* --- 8. tableaux détaillés --------------------------------------------- */} | |
| 150 | + {dash.tables.map((t) => <DataTable key={t.id} spec={t} />)} | |
| 179 | 151 | |
| 180 | − <div className="panel"> | |
| 181 | − <h3>🏢 Plus grands inventaires</h3> | |
| 182 | − <HBars data={withPrice(s.top_dealers)} color="#7d4fc9" /> | |
| 152 | + {/* --- 9. records & faits marquants --------------------------------------- */} | |
| 153 | + {dash.records.length > 0 && ( | |
| 154 | + <section> | |
| 155 | + <h2 style={{ marginBottom: 12 }}>Records & faits marquants</h2> | |
| 156 | + <div style={grid(280)}> | |
| 157 | + {dash.records.map((r, i) => <RecordCard key={i} r={r} />)} | |
| 158 | + </div> | |
| 159 | + </section> | |
| 160 | + )} | |
| 183 | 161 | </div> |
| 184 | 162 | </div> |
| 185 | 163 | ); |
modified
requirements.txt
+1 −0
@@ -6,3 +6,4 @@ requests>=2.31 | ||
| 6 | 6 | beautifulsoup4>=4.12 |
| 7 | 7 | reportlab>=4.0 |
| 8 | 8 | pillow>=10.0 |
| 9 | +fpdf2>=2.8 | |
| 9 | 10 | |