stats v3 : rapports PDF personnalisés (catalogue, rendu au choix, ReportBuilder)
4 changed files +713 −11
modified
creaka/kapdf.py
+398 −8
@@ -1,7 +1,7 @@ | ||
| 1 | 1 | # Auteur : Simon-Pierre Boucher — contact@spboucher.ai |
| 2 | −# ka-ui/stats/kapdf.py — moteur PDF commun Groupe KA (fpdf2). v2 | |
| 2 | +# ka-ui/stats/kapdf.py — moteur PDF commun Groupe KA (fpdf2). v3 | |
| 3 | 3 | # Consomme le JSON du contrat /api/stats/dashboard (voir SPEC.md) et produit |
| 4 | −# les rapports estampillés Groupe-KA. 5 modes : | |
| 4 | +# les rapports estampillés Groupe-KA. 5 modes fixes : | |
| 5 | 5 | # complet — toutes les sections (KPI, jauges, séries + stats, multi- |
| 6 | 6 | # séries, empilées, distributions, répartitions, géo, |
| 7 | 7 | # heatmap horaire, tableaux, records) |
@@ -9,12 +9,19 @@ | ||
| 9 | 9 | # tendances — KPI + toutes les séries temporelles + stats de séries |
| 10 | 10 | # repartitions — breakdowns, distributions, géo, activité horaire |
| 11 | 11 | # donnees — tous les tableaux en version longue (400 lignes max) |
| 12 | +# v3 : mode « personnalise » — l'utilisateur compose son rapport bloc par | |
| 13 | +# bloc (choix des données ET du rendu par bloc : courbe/aire/barres/anneau/ | |
| 14 | +# heatmap/tableau…). catalog(dash) expose les blocs disponibles ; le rapport | |
| 15 | +# suit une spec {"title": str, "blocks": [{"key": "series:ajouts", | |
| 16 | +# "render": "bar"}, …]} et respecte l'ordre demandé. | |
| 12 | 17 | # Graphiques VECTORIELS uniquement (primitives fpdf), accent de la marque. |
| 13 | 18 | # Usage : |
| 14 | −# from kapdf import GroupeKAReport, REPORT_MODES, filename | |
| 19 | +# from kapdf import GroupeKAReport, REPORT_MODES, catalog, filename | |
| 15 | 20 | # pdf_bytes = GroupeKAReport(site={"wordmark":"Lou·Ka","accent":"#ff6a00", |
| 16 | 21 | # "domain":"www.lou-ka.com","tagline":"…"}, dashboard=dash_json, |
| 17 | 22 | # mode="complet").build() |
| 23 | +# pdf_bytes = GroupeKAReport(site=SITE, dashboard=dash, mode="personnalise", | |
| 24 | +# spec={"title": "Mon rapport", "blocks": [...]}).build() | |
| 18 | 25 | # Dépendance : pip install fpdf2 (aucune autre) |
| 19 | 26 | from __future__ import annotations |
| 20 | 27 | |
@@ -40,6 +47,100 @@ REPORT_MODES = { | ||
| 40 | 47 | "repartitions": "Répartitions & géographie", |
| 41 | 48 | "donnees": "Données détaillées", |
| 42 | 49 | } |
| 50 | +# v3 — mode composé par l'utilisateur (jamais dans le menu des modes fixes) | |
| 51 | +CUSTOM_MODE = "personnalise" | |
| 52 | +CUSTOM_LABEL = "Rapport personnalisé" | |
| 53 | + | |
| 54 | +# v3 — rendus proposés par type de bloc (le 1er est le rendu par défaut ; | |
| 55 | +# « table » est toujours offert : toute donnée a un équivalent tableau) | |
| 56 | +RENDER_LABELS = { | |
| 57 | + "line": "Courbe", "area": "Aire", "bar": "Barres verticales", | |
| 58 | + "bars": "Barres horizontales", "donut": "Anneau", | |
| 59 | + "lines": "Multi-courbes", "stacked": "Barres empilées", | |
| 60 | + "histogram": "Histogramme", "heatmap": "Heatmap", | |
| 61 | + "cards": "Cartes", "gauges": "Jauges", "table": "Tableau", | |
| 62 | +} | |
| 63 | +SECTION_LABELS = { | |
| 64 | + "kpis": "Indicateurs", "gauges": "Taux & couvertures", | |
| 65 | + "series": "Évolution", "multiseries": "Comparaisons", | |
| 66 | + "stacked": "Compositions", "breakdowns": "Répartitions", | |
| 67 | + "distributions": "Distributions", "geo": "Géographie", | |
| 68 | + "heatmap": "Calendrier", "hourly": "Activité horaire", | |
| 69 | + "tables": "Tableaux", "records": "Records", | |
| 70 | +} | |
| 71 | + | |
| 72 | + | |
| 73 | +def catalog(dash: dict) -> list[dict]: | |
| 74 | + """v3 — blocs composables d'un dashboard : ce que le constructeur de | |
| 75 | + rapports personnalisés peut inclure, avec les rendus compatibles. | |
| 76 | + key = section[:id] ; l'ordre renvoyé = ordre naturel du dashboard.""" | |
| 77 | + out: list[dict] = [] | |
| 78 | + | |
| 79 | + def add(key, title, renders, default=None, count=None): | |
| 80 | + b = {"key": key, "section": key.split(":")[0], "title": title, | |
| 81 | + "renders": renders, "default_render": default or renders[0]} | |
| 82 | + if count is not None: | |
| 83 | + b["count"] = count | |
| 84 | + out.append(b) | |
| 85 | + | |
| 86 | + if dash.get("kpis"): | |
| 87 | + add("kpis", "Indicateurs clés (KPI)", ["cards", "table"], | |
| 88 | + count=len(dash["kpis"])) | |
| 89 | + gs = [g for g in (dash.get("gauges") or []) | |
| 90 | + if isinstance(g.get("value"), (int, float)) and g.get("max")] | |
| 91 | + if gs: | |
| 92 | + add("gauges", "Taux & couvertures (jauges)", ["gauges", "table"], | |
| 93 | + count=len(gs)) | |
| 94 | + for s in dash.get("series") or []: | |
| 95 | + if len(s.get("points") or []) < 2: | |
| 96 | + continue | |
| 97 | + kind = s.get("kind") or "line" | |
| 98 | + default = kind if kind in ("line", "area", "bar") else "line" | |
| 99 | + add(f"series:{s.get('id')}", s.get("title", ""), | |
| 100 | + ["line", "area", "bar", "table"], default, | |
| 101 | + len(s.get("points") or [])) | |
| 102 | + for ms in dash.get("multiseries") or []: | |
| 103 | + if not (ms.get("series") or []): | |
| 104 | + continue | |
| 105 | + add(f"multiseries:{ms.get('id')}", ms.get("title", ""), | |
| 106 | + ["lines", "table"], count=len(ms["series"])) | |
| 107 | + for st in dash.get("stacked") or []: | |
| 108 | + if not (st.get("points") or []): | |
| 109 | + continue | |
| 110 | + add(f"stacked:{st.get('id')}", st.get("title", ""), | |
| 111 | + ["stacked", "table"], count=len(st.get("keys") or [])) | |
| 112 | + for b in dash.get("breakdowns") or []: | |
| 113 | + if not (b.get("items") or []): | |
| 114 | + continue | |
| 115 | + default = "donut" if b.get("kind") == "donut" else "bars" | |
| 116 | + add(f"breakdowns:{b.get('id')}", b.get("title", ""), | |
| 117 | + ["donut", "bars", "table"], default, len(b["items"])) | |
| 118 | + for d in dash.get("distributions") or []: | |
| 119 | + if not (d.get("bins") or []): | |
| 120 | + continue | |
| 121 | + add(f"distributions:{d.get('id')}", d.get("title", ""), | |
| 122 | + ["histogram", "table"], count=len(d["bins"])) | |
| 123 | + geo = dash.get("geo") or {} | |
| 124 | + if geo.get("items"): | |
| 125 | + add("geo", geo.get("title", "Répartition géographique"), | |
| 126 | + ["bars", "table"], count=len(geo["items"])) | |
| 127 | + hm = dash.get("heatmap") or {} | |
| 128 | + if hm.get("cells"): | |
| 129 | + add("heatmap", hm.get("title", "Calendrier d'activité"), | |
| 130 | + ["heatmap", "table"]) | |
| 131 | + hr = dash.get("hourly") or {} | |
| 132 | + if hr.get("cells"): | |
| 133 | + add("hourly", hr.get("title", "Activité par jour et heure"), | |
| 134 | + ["heatmap", "table"]) | |
| 135 | + for t in dash.get("tables") or []: | |
| 136 | + if not (t.get("rows") or []): | |
| 137 | + continue | |
| 138 | + add(f"tables:{t.get('id')}", t.get("title", ""), ["table"], | |
| 139 | + count=len(t["rows"])) | |
| 140 | + if dash.get("records"): | |
| 141 | + add("records", "Records & faits marquants", ["cards", "table"], | |
| 142 | + count=len(dash["records"])) | |
| 143 | + return out | |
| 43 | 144 | |
| 44 | 145 | EMAILS = [ |
| 45 | 146 | ("contact@groupe-ka.com", "Projets, partenariats & données"), |
@@ -127,16 +228,25 @@ class _PDF(FPDF): | ||
| 127 | 228 | |
| 128 | 229 | |
| 129 | 230 | class GroupeKAReport: |
| 130 | − def __init__(self, site: dict, dashboard: dict, mode: str = "complet"): | |
| 231 | + def __init__(self, site: dict, dashboard: dict, mode: str = "complet", | |
| 232 | + spec: dict | None = None): | |
| 131 | 233 | self.site = site |
| 132 | 234 | self.d = dashboard |
| 133 | − self.mode = mode if mode in REPORT_MODES else "complet" | |
| 235 | + self.mode = mode if (mode in REPORT_MODES or mode == CUSTOM_MODE) else "complet" | |
| 236 | + self.spec = spec or {} | |
| 134 | 237 | self.accent = _hex(site.get("accent", "#d9f26b")) |
| 135 | 238 | period = dashboard.get("period", {}) or {} |
| 136 | 239 | self.period_label = period.get("label") or "toute la période" |
| 137 | 240 | self.pdf = _PDF(site.get("wordmark", ""), self.accent, self.period_label) |
| 138 | 241 | self.toc: list[tuple[str, int]] = [] |
| 139 | 242 | |
| 243 | + @property | |
| 244 | + def mode_label(self) -> str: | |
| 245 | + if self.mode == CUSTOM_MODE: | |
| 246 | + t = str(self.spec.get("title") or "").strip() | |
| 247 | + return f"{CUSTOM_LABEL} — {t}" if t else CUSTOM_LABEL | |
| 248 | + return REPORT_MODES[self.mode] | |
| 249 | + | |
| 140 | 250 | # ---------- primitives ---------- |
| 141 | 251 | def _card(self, x, y, w, h, fill=WHITE): |
| 142 | 252 | p = self.pdf |
@@ -213,7 +323,7 @@ class GroupeKAReport: | ||
| 213 | 323 | p.set_xy(24, 100) |
| 214 | 324 | p.set_font("helvetica", "", 13) |
| 215 | 325 | p.set_text_color(*INK2) |
| 216 | − p.multi_cell(150, 7, f"{REPORT_MODES[self.mode]} — {wm}") | |
| 326 | + p.multi_cell(150, 7, f"{self.mode_label} — {wm}") | |
| 217 | 327 | now = datetime.now(ZoneInfo("America/Toronto")) |
| 218 | 328 | per = self.d.get("period", {}) or {} |
| 219 | 329 | p.set_xy(24, 125) |
@@ -222,7 +332,7 @@ class GroupeKAReport: | ||
| 222 | 332 | ("Période couverte", self.period_label + (f" ({per.get('from')} → {per.get('to')})" if per.get("from") else "")), |
| 223 | 333 | ("Généré le", now.strftime("%Y-%m-%d à %H:%M") + " (heure de l'Est)"), |
| 224 | 334 | ("Plateforme", "https://" + self.site.get("domain", "")), |
| 225 | − ("Type de rapport", REPORT_MODES[self.mode]), | |
| 335 | + ("Type de rapport", self.mode_label), | |
| 226 | 336 | ] |
| 227 | 337 | y = 128 |
| 228 | 338 | for k, v in rows: |
@@ -685,6 +795,271 @@ class GroupeKAReport: | ||
| 685 | 795 | p.rect(x0 + lx + h * cw, y0 + ly + d * chh, cw - 0.5, chh - 0.5, style="DF") |
| 686 | 796 | p.set_y(y0 + ly + 7 * chh + 5) |
| 687 | 797 | |
| 798 | + def _calheat(self, hm): | |
| 799 | + """v3 — calendrier de chaleur 26 semaines (équivalent PDF du | |
| 800 | + CalendarHeatmap du kit front) : colonnes = semaines, lignes = jours.""" | |
| 801 | + from datetime import date as _date, timedelta as _td | |
| 802 | + cells = hm.get("cells") or [] | |
| 803 | + vals = {c.get("date"): c.get("value") or 0 for c in cells if c.get("date")} | |
| 804 | + if not vals: | |
| 805 | + return | |
| 806 | + p = self.pdf | |
| 807 | + if p.get_y() > 215: | |
| 808 | + p.add_page() | |
| 809 | + self._chart_title(hm.get("title", "Calendrier d'activité")) | |
| 810 | + try: | |
| 811 | + end = _date.fromisoformat(max(vals)) | |
| 812 | + except ValueError: | |
| 813 | + return | |
| 814 | + weeks = 26 | |
| 815 | + start = end - _td(days=weeks * 7 - 1) | |
| 816 | + start -= _td(days=start.weekday()) # lundi | |
| 817 | + vmax = max(vals.values()) or 1 | |
| 818 | + x0, y0 = p.l_margin, p.get_y() | |
| 819 | + cw, lx, ly = 6.3, 10, 4 | |
| 820 | + dows = ["Lun", "", "Mer", "", "Ven", "", "Dim"] | |
| 821 | + p.set_font("helvetica", "", 5.8) | |
| 822 | + p.set_text_color(*INK3) | |
| 823 | + for d in range(7): | |
| 824 | + if dows[d]: | |
| 825 | + p.set_xy(x0, y0 + ly + d * cw + 1.2) | |
| 826 | + p.cell(lx - 1, 3, dows[d], align="R") | |
| 827 | + for w in range(weeks): | |
| 828 | + monday = start + _td(days=7 * w) | |
| 829 | + if monday.day <= 7: # étiquette de mois à la 1re semaine du mois | |
| 830 | + p.set_xy(x0 + lx + w * cw, y0) | |
| 831 | + p.cell(cw * 4, 3, monday.strftime("%m")) | |
| 832 | + for d in range(7): | |
| 833 | + day = monday + _td(days=d) | |
| 834 | + v = vals.get(day.isoformat(), 0) | |
| 835 | + f = 0.15 + 0.85 * (v / vmax) if v else 0.0 | |
| 836 | + col = (tuple(int(PAPER[j] + (self.accent[j] - PAPER[j]) * f) | |
| 837 | + for j in range(3)) if v else (235, 233, 228)) | |
| 838 | + p.set_fill_color(*col) | |
| 839 | + p.set_draw_color(215, 213, 207) | |
| 840 | + p.set_line_width(0.1) | |
| 841 | + p.rect(x0 + lx + w * cw, y0 + ly + d * cw, cw - 0.5, cw - 0.5, | |
| 842 | + style="DF") | |
| 843 | + p.set_y(y0 + ly + 7 * cw + 5) | |
| 844 | + | |
| 845 | + # ---------- v3 : conversions bloc → tableau ---------- | |
| 846 | + @staticmethod | |
| 847 | + def _serie_as_table(s): | |
| 848 | + unit = s.get("unit") or "Valeur" | |
| 849 | + cols = ["Date", unit.capitalize()] | |
| 850 | + cmp_ = s.get("compare") or [] | |
| 851 | + if cmp_: | |
| 852 | + cols.append("Période comparée") | |
| 853 | + rows = [] | |
| 854 | + for i, pt in enumerate(s.get("points") or []): | |
| 855 | + row = [str(pt.get("t", "")), pt.get("v", "")] | |
| 856 | + if cmp_: | |
| 857 | + row.append(cmp_[i]["v"] if i < len(cmp_) else "") | |
| 858 | + rows.append(row) | |
| 859 | + return {"id": s.get("id"), "title": s.get("title", ""), | |
| 860 | + "columns": cols, "rows": rows} | |
| 861 | + | |
| 862 | + @staticmethod | |
| 863 | + def _multi_as_table(ms): | |
| 864 | + labels = [s.get("label", "") for s in (ms.get("series") or [])][:4] | |
| 865 | + by_t: dict[str, dict] = {} | |
| 866 | + for s in (ms.get("series") or [])[:4]: | |
| 867 | + for pt in s.get("points") or []: | |
| 868 | + by_t.setdefault(str(pt.get("t", "")), {})[s.get("label", "")] = pt.get("v") | |
| 869 | + rows = [[t] + [by_t[t].get(lbl, "") for lbl in labels] | |
| 870 | + for t in sorted(by_t)] | |
| 871 | + return {"id": ms.get("id"), "title": ms.get("title", ""), | |
| 872 | + "columns": ["Date"] + labels, "rows": rows} | |
| 873 | + | |
| 874 | + @staticmethod | |
| 875 | + def _stacked_as_table(st): | |
| 876 | + keys = (st.get("keys") or [])[:6] | |
| 877 | + rows = [] | |
| 878 | + for pt in st.get("points") or []: | |
| 879 | + vs = [(pt.get("values") or [])[j] if j < len(pt.get("values") or []) else 0 | |
| 880 | + for j in range(len(keys))] | |
| 881 | + rows.append([str(pt.get("t", ""))] + vs + [sum(v or 0 for v in vs)]) | |
| 882 | + return {"id": st.get("id"), "title": st.get("title", ""), | |
| 883 | + "columns": ["Date"] + list(keys) + ["Total"], "rows": rows} | |
| 884 | + | |
| 885 | + @staticmethod | |
| 886 | + def _items_as_table(id_, title, items, label_col="Libellé"): | |
| 887 | + items = items or [] | |
| 888 | + with_delta = any(it.get("delta_pct") is not None for it in items) | |
| 889 | + cols = [label_col, "Valeur"] + (["delta %"] if with_delta else []) | |
| 890 | + rows = [] | |
| 891 | + for it in items: | |
| 892 | + row = [str(it.get("label", "")), it.get("value", "")] | |
| 893 | + if with_delta: | |
| 894 | + d = it.get("delta_pct") | |
| 895 | + row.append("" if d is None else f"{'+' if d >= 0 else ''}{d} %") | |
| 896 | + rows.append(row) | |
| 897 | + return {"id": id_, "title": title, "columns": cols, "rows": rows} | |
| 898 | + | |
| 899 | + def _kpis_as_table(self): | |
| 900 | + rows = [] | |
| 901 | + for k in self.d.get("kpis") or []: | |
| 902 | + v = k.get("value") | |
| 903 | + val = (_fr(v) if isinstance(v, (int, float)) else str(v)) + \ | |
| 904 | + ((" " + k["unit"]) if k.get("unit") else "") | |
| 905 | + d = k.get("delta_pct") | |
| 906 | + rows.append([str(k.get("label", "")), val, | |
| 907 | + "" if d is None else f"{'+' if d >= 0 else ''}{d} %"]) | |
| 908 | + return {"id": "kpis", "title": "Indicateurs clés", | |
| 909 | + "columns": ["Indicateur", "Valeur", "delta %"], "rows": rows} | |
| 910 | + | |
| 911 | + def _gauges_as_table(self): | |
| 912 | + rows = [[str(g.get("label", "")), | |
| 913 | + f"{_fr(g['value'])}{' ' + g['unit'] if g.get('unit') else ''}", | |
| 914 | + _fr(g["max"]), f"{100.0 * g['value'] / g['max']:.0f} %"] | |
| 915 | + for g in self.d.get("gauges") or [] | |
| 916 | + if isinstance(g.get("value"), (int, float)) and g.get("max")] | |
| 917 | + return {"id": "gauges", "title": "Taux & couvertures", | |
| 918 | + "columns": ["Mesure", "Valeur", "Max", "Part"], "rows": rows} | |
| 919 | + | |
| 920 | + def _records_as_table(self): | |
| 921 | + rows = [[str(r.get("label", "")), str(r.get("value", "")), | |
| 922 | + str(r.get("date", "") or "")] | |
| 923 | + for r in self.d.get("records") or []] | |
| 924 | + return {"id": "records", "title": "Records & faits marquants", | |
| 925 | + "columns": ["Fait marquant", "Valeur", "Date"], "rows": rows} | |
| 926 | + | |
| 927 | + @staticmethod | |
| 928 | + def _heatmap_as_table(hm, title): | |
| 929 | + cells = sorted((hm.get("cells") or []), | |
| 930 | + key=lambda c: -(c.get("value") or 0))[:40] | |
| 931 | + return {"id": "heatmap", "title": title + " — jours les plus chargés", | |
| 932 | + "columns": ["Date", "Valeur"], | |
| 933 | + "rows": [[c.get("date", ""), c.get("value") or 0] for c in cells]} | |
| 934 | + | |
| 935 | + @staticmethod | |
| 936 | + def _hourly_as_table(hr, title): | |
| 937 | + days = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi", | |
| 938 | + "Dimanche"] | |
| 939 | + cells = sorted((hr.get("cells") or []), | |
| 940 | + key=lambda c: -(c.get("value") or 0))[:40] | |
| 941 | + return {"id": "hourly", "title": title + " — créneaux les plus actifs", | |
| 942 | + "columns": ["Jour", "Heure", "Valeur"], | |
| 943 | + "rows": [[days[c["dow"]] if 0 <= c.get("dow", -1) <= 6 else "?", | |
| 944 | + f"{c.get('hour', '?')} h", c.get("value") or 0] | |
| 945 | + for c in cells]} | |
| 946 | + | |
| 947 | + # ---------- v3 : rendu d'un bloc du rapport personnalisé ---------- | |
| 948 | + def _find(self, coll: str, id_: str): | |
| 949 | + for it in self.d.get(coll) or []: | |
| 950 | + if str(it.get("id")) == id_: | |
| 951 | + return it | |
| 952 | + return None | |
| 953 | + | |
| 954 | + def _toc_mark(self, title: str): | |
| 955 | + """Blocs graphiques du mode personnalisé : entrée de sommaire sans | |
| 956 | + _section_title (le graphique porte déjà son titre).""" | |
| 957 | + if self.pdf.get_y() > 235: | |
| 958 | + self.pdf.add_page() | |
| 959 | + self.toc.append((title, self.pdf.page_no())) | |
| 960 | + | |
| 961 | + def _render_block(self, key: str, render: str): | |
| 962 | + section, _, id_ = key.partition(":") | |
| 963 | + if section == "kpis": | |
| 964 | + self._table(self._kpis_as_table()) if render == "table" else self._kpis() | |
| 965 | + elif section == "gauges": | |
| 966 | + self._table(self._gauges_as_table()) if render == "table" else self._gauges() | |
| 967 | + elif section == "records": | |
| 968 | + self._table(self._records_as_table()) if render == "table" else self._records() | |
| 969 | + elif section == "series": | |
| 970 | + s = self._find("series", id_) | |
| 971 | + if not s: | |
| 972 | + return | |
| 973 | + if render == "table": | |
| 974 | + self._table(self._serie_as_table(s), max_rows=400) | |
| 975 | + else: | |
| 976 | + s2 = dict(s) | |
| 977 | + if render in ("line", "area", "bar"): | |
| 978 | + s2["kind"] = render | |
| 979 | + self._toc_mark(s2.get("title", "")) | |
| 980 | + if s2.get("kind") == "bar": | |
| 981 | + self._vbars(s2) | |
| 982 | + else: | |
| 983 | + self._line_chart(s2, with_stats=True) | |
| 984 | + elif section == "multiseries": | |
| 985 | + ms = self._find("multiseries", id_) | |
| 986 | + if not ms: | |
| 987 | + return | |
| 988 | + if render == "table": | |
| 989 | + self._table(self._multi_as_table(ms), max_rows=400) | |
| 990 | + else: | |
| 991 | + self._toc_mark(ms.get("title", "")) | |
| 992 | + self._multiline(ms) | |
| 993 | + elif section == "stacked": | |
| 994 | + st = self._find("stacked", id_) | |
| 995 | + if not st: | |
| 996 | + return | |
| 997 | + if render == "table": | |
| 998 | + self._table(self._stacked_as_table(st), max_rows=400) | |
| 999 | + else: | |
| 1000 | + self._toc_mark(st.get("title", "")) | |
| 1001 | + self._stacked(st) | |
| 1002 | + elif section == "breakdowns": | |
| 1003 | + b = self._find("breakdowns", id_) | |
| 1004 | + if not b: | |
| 1005 | + return | |
| 1006 | + if render == "table": | |
| 1007 | + self._table(self._items_as_table(id_, b.get("title", ""), | |
| 1008 | + b.get("items")), max_rows=400) | |
| 1009 | + else: | |
| 1010 | + self._toc_mark(b.get("title", "")) | |
| 1011 | + if render == "donut": | |
| 1012 | + self._donut(b) | |
| 1013 | + else: | |
| 1014 | + self._bars(b.get("title", ""), b.get("items")) | |
| 1015 | + elif section == "distributions": | |
| 1016 | + d = self._find("distributions", id_) | |
| 1017 | + if not d: | |
| 1018 | + return | |
| 1019 | + if render == "table": | |
| 1020 | + bins = [{"label": bn.get("label"), "value": bn.get("value")} | |
| 1021 | + for bn in d.get("bins") or []] | |
| 1022 | + self._table(self._items_as_table(id_, d.get("title", ""), bins, | |
| 1023 | + label_col="Tranche")) | |
| 1024 | + else: | |
| 1025 | + self._toc_mark(d.get("title", "")) | |
| 1026 | + self._vbars(d) | |
| 1027 | + elif section == "geo": | |
| 1028 | + geo = self.d.get("geo") or {} | |
| 1029 | + if not geo.get("items"): | |
| 1030 | + return | |
| 1031 | + title = geo.get("title", "Répartition géographique") | |
| 1032 | + if render == "table": | |
| 1033 | + self._table(self._items_as_table("geo", title, geo["items"], | |
| 1034 | + label_col="Zone"), max_rows=400) | |
| 1035 | + else: | |
| 1036 | + self._toc_mark(title) | |
| 1037 | + self._bars(title, geo["items"]) | |
| 1038 | + elif section == "heatmap": | |
| 1039 | + hm = self.d.get("heatmap") or {} | |
| 1040 | + if not hm.get("cells"): | |
| 1041 | + return | |
| 1042 | + title = hm.get("title", "Calendrier d'activité") | |
| 1043 | + if render == "table": | |
| 1044 | + self._table(self._heatmap_as_table(hm, title)) | |
| 1045 | + else: | |
| 1046 | + self._toc_mark(title) | |
| 1047 | + self._calheat(hm) | |
| 1048 | + elif section == "hourly": | |
| 1049 | + hr = self.d.get("hourly") or {} | |
| 1050 | + if not hr.get("cells"): | |
| 1051 | + return | |
| 1052 | + title = hr.get("title", "Activité par jour et heure") | |
| 1053 | + if render == "table": | |
| 1054 | + self._table(self._hourly_as_table(hr, title)) | |
| 1055 | + else: | |
| 1056 | + self._toc_mark(title) | |
| 1057 | + self._hourly() | |
| 1058 | + elif section == "tables": | |
| 1059 | + t = self._find("tables", id_) | |
| 1060 | + if t: | |
| 1061 | + self._table(t, max_rows=400) | |
| 1062 | + | |
| 688 | 1063 | def _table(self, t, max_rows=200): |
| 689 | 1064 | p = self.pdf |
| 690 | 1065 | cols = t.get("columns") or [] |
@@ -809,7 +1184,7 @@ class GroupeKAReport: | ||
| 809 | 1184 | p = self.pdf |
| 810 | 1185 | p.alias_nb_pages() |
| 811 | 1186 | self._cover() |
| 812 | − with_toc = self.mode in ("complet", "donnees") | |
| 1187 | + with_toc = self.mode in ("complet", "donnees", CUSTOM_MODE) | |
| 813 | 1188 | toc_page_no = None |
| 814 | 1189 | if self.mode == "synthese": |
| 815 | 1190 | p.add_page() |
@@ -835,6 +1210,21 @@ class GroupeKAReport: | ||
| 835 | 1210 | for t in self.d.get("tables") or []: |
| 836 | 1211 | self._table(t, max_rows=400) |
| 837 | 1212 | self._final_page() |
| 1213 | + elif self.mode == CUSTOM_MODE: | |
| 1214 | + p.add_page() | |
| 1215 | + toc_page_no = p.page_no() | |
| 1216 | + p.add_page() | |
| 1217 | + known = {b["key"]: b for b in catalog(self.d)} | |
| 1218 | + for blk in self.spec.get("blocks") or []: | |
| 1219 | + key = str(blk.get("key", "")) | |
| 1220 | + b = known.get(key) | |
| 1221 | + if not b: | |
| 1222 | + continue | |
| 1223 | + render = str(blk.get("render") or "") | |
| 1224 | + if render not in b["renders"]: | |
| 1225 | + render = b["default_render"] | |
| 1226 | + self._render_block(key, render) | |
| 1227 | + self._final_page() | |
| 838 | 1228 | else: # complet |
| 839 | 1229 | p.add_page() |
| 840 | 1230 | toc_page_no = p.page_no() |
modified
creaka/web.py
+40 −1
@@ -10,7 +10,7 @@ import json | ||
| 10 | 10 | import threading |
| 11 | 11 | from pathlib import Path |
| 12 | 12 | |
| 13 | −from fastapi import FastAPI, HTTPException, Query | |
| 13 | +from fastapi import Body, FastAPI, HTTPException, Query | |
| 14 | 14 | from fastapi.middleware.cors import CORSMiddleware |
| 15 | 15 | from fastapi.middleware.gzip import GZipMiddleware |
| 16 | 16 | from fastapi.responses import FileResponse, Response |
@@ -122,6 +122,45 @@ def stats_report(period: str = "30j", mode: str = "complet", | ||
| 122 | 122 | f'attachment; filename="{fname}"'}) |
| 123 | 123 | |
| 124 | 124 | |
| 125 | +@app.get("/api/stats/catalog") | |
| 126 | +def stats_catalog(period: str = "30j", | |
| 127 | + date_from: str = Query("", alias="from"), | |
| 128 | + date_to: str = Query("", alias="to")): | |
| 129 | + """v3 — blocs composables pour le constructeur de rapports personnalisés.""" | |
| 130 | + if period not in _PERIODS_OK and not (date_from and date_to): | |
| 131 | + raise HTTPException(400, "période inconnue") | |
| 132 | + dash = stats_mod.dashboard(_db(), period=period, | |
| 133 | + date_from=date_from, date_to=date_to) | |
| 134 | + return {"updated": dash.get("updated"), "period": dash.get("period"), | |
| 135 | + "blocks": kapdf.catalog(dash)} | |
| 136 | + | |
| 137 | + | |
| 138 | +@app.post("/api/stats/report/custom") | |
| 139 | +def stats_report_custom(spec: dict = Body(...)): | |
| 140 | + """v3 — rapport PDF personnalisé : {"title", "period", "from", "to", | |
| 141 | + "blocks": [{"key": "series:…", "render": "bar"}, …]} (SPEC.md §3bis).""" | |
| 142 | + period = str(spec.get("period") or "30j") | |
| 143 | + date_from = str(spec.get("from") or "") | |
| 144 | + date_to = str(spec.get("to") or "") | |
| 145 | + if period not in _PERIODS_OK and not (date_from and date_to): | |
| 146 | + period = "30j" | |
| 147 | + dash = stats_mod.dashboard(_db(), period=period, | |
| 148 | + date_from=date_from, date_to=date_to) | |
| 149 | + known = {b["key"] for b in kapdf.catalog(dash)} | |
| 150 | + blocks = [b for b in (spec.get("blocks") or []) | |
| 151 | + if isinstance(b, dict) and b.get("key") in known][:40] | |
| 152 | + if not blocks: | |
| 153 | + raise HTTPException(400, "Aucun bloc valide dans la composition") | |
| 154 | + pdf = kapdf.GroupeKAReport( | |
| 155 | + site=stats_mod.site_info(), dashboard=dash, mode=kapdf.CUSTOM_MODE, | |
| 156 | + spec={"title": str(spec.get("title") or "")[:80], "blocks": blocks}, | |
| 157 | + ).build() | |
| 158 | + fname = kapdf.filename("crea-ka", period, kapdf.CUSTOM_MODE) | |
| 159 | + return Response(content=pdf, media_type="application/pdf", | |
| 160 | + headers={"Content-Disposition": | |
| 161 | + f'attachment; filename="{fname}"'}) | |
| 162 | + | |
| 163 | + | |
| 125 | 164 | @app.get("/api/taxonomies") |
| 126 | 165 | def taxonomies(): |
| 127 | 166 | return {"niches": sorted(NICHES), "plateformes": sorted(PLATFORMS), |
modified
frontend/src/ka/stats/SPEC.md
+62 −1
@@ -1,4 +1,4 @@ | ||
| 1 | −# ka-stats — module Stats commun Groupe KA (spec v2) | |
| 1 | +# ka-stats — module Stats commun Groupe KA (spec v3) | |
| 2 | 2 | |
| 3 | 3 | Contrat partagé par les plateformes pour leurs pages **/stats** (tableau de |
| 4 | 4 | bord analytique) et les **exports PDF** estampillés Groupe-KA. Le visuel suit |
@@ -10,6 +10,15 @@ sur les répartitions, statistiques de séries (min/max/moy/méd/σ), et **5 | ||
| 10 | 10 | rapports PDF** au lieu de 2. Tous les nouveaux champs sont **optionnels** : |
| 11 | 11 | un dashboard v1 reste valide et se rend tel quel. |
| 12 | 12 | |
| 13 | +**v3 (2026-08-23)** : **rapports personnalisés** — l'utilisateur compose son | |
| 14 | +propre rapport PDF bloc par bloc : choix des données (catalogue dérivé du | |
| 15 | +dashboard), du **rendu par bloc** (courbe/aire/barres/anneau/histogramme/ | |
| 16 | +heatmap/tableau…), de l'ordre, avec **modèles sauvegardés** (localStorage du | |
| 17 | +site). Deux endpoints (`/api/stats/catalog`, `POST /api/stats/report/custom`, | |
| 18 | +voir §3bis) + un constructeur dans la page /stats (`ReportBuilder` du kit — | |
| 19 | +bouton « 🛠 Rapport personnalisé » à côté du menu PDF). Le PDF garde le | |
| 20 | +gabarit estampillé Groupe-KA avec l'accent de la plateforme. v2 inchangée. | |
| 21 | + | |
| 13 | 22 | ## 1. Page /stats — structure obligatoire (dans cet ordre) |
| 14 | 23 | |
| 15 | 24 | 1. **Bandeau KPI** : 6–10 grandes cartes (`KpiCard`) — valeur, libellé, |
@@ -135,6 +144,58 @@ gabarit en pdfkit) : | ||
| 135 | 144 | - A4 portrait, marges 18 mm, typo : Helvetica (fallback sûr) ou fonts TTF du |
| 136 | 145 | DS si présentes. |
| 137 | 146 | |
| 147 | +## 3bis. Rapports personnalisés (v3) | |
| 148 | + | |
| 149 | +### Catalogue | |
| 150 | + | |
| 151 | +`GET /api/stats/catalog?period=…&from=&to=` → | |
| 152 | + | |
| 153 | +```jsonc | |
| 154 | +{ "updated": "…", "period": { … }, | |
| 155 | + "blocks": [ { "key": "series:ajouts", // section[:id] — clé stable | |
| 156 | + "section": "series", | |
| 157 | + "title": "Événements ajoutés par jour", | |
| 158 | + "renders": ["line","area","bar","table"], // rendus compatibles | |
| 159 | + "default_render": "line", | |
| 160 | + "count": 30 } ] } // taille indicative (optionnel) | |
| 161 | +``` | |
| 162 | + | |
| 163 | +Sections → rendus : `kpis` cards|table · `gauges` gauges|table · | |
| 164 | +`series:<id>` line|area|bar|table · `multiseries:<id>` lines|table · | |
| 165 | +`stacked:<id>` stacked|table · `breakdowns:<id>` donut|bars|table · | |
| 166 | +`distributions:<id>` histogram|table · `geo` bars|table · | |
| 167 | +`heatmap` heatmap|table · `hourly` heatmap|table · `tables:<id>` table · | |
| 168 | +`records` cards|table. **Toute donnée a un équivalent tableau.** Le catalogue | |
| 169 | +est dérivé du dashboard (implémentation : `kapdf.catalog(dash)` / | |
| 170 | +`catalogFromDashboard()` en TS) — zéro maintenance quand une métrique s'ajoute. | |
| 171 | + | |
| 172 | +### Génération | |
| 173 | + | |
| 174 | +`POST /api/stats/report/custom` — corps JSON : | |
| 175 | + | |
| 176 | +```jsonc | |
| 177 | +{ "title": "Revue mensuelle", // ≤ 80 car., affiché en couverture | |
| 178 | + "period": "30j", "from": "", "to": "", // mêmes règles que le dashboard | |
| 179 | + "blocks": [ { "key": "kpis", "render": "cards" }, | |
| 180 | + { "key": "series:ajouts", "render": "bar" } ] } // ordre = ordre du PDF | |
| 181 | +``` | |
| 182 | + | |
| 183 | +→ `application/pdf`, filename `groupe-ka_<plateforme>_stats_<periode>_personnalise_<date>.pdf`. | |
| 184 | +Clés inconnues ignorées ; rendu incompatible → rendu par défaut ; aucun bloc | |
| 185 | +valide → **400**. Maximum 40 blocs. Couverture : type = « Rapport | |
| 186 | +personnalisé — {title} » ; sommaire ; page de fin habituelle. Un même bloc | |
| 187 | +peut apparaître plusieurs fois (ex. graphique + tableau). | |
| 188 | + | |
| 189 | +### Constructeur (front, kit) | |
| 190 | + | |
| 191 | +`ReportBuilder` (kacharts.tsx ; port vanilla pour les SPA sans React) : | |
| 192 | +panneau modal 2 colonnes — catalogue groupé par section à gauche, composition | |
| 193 | +ordonnée à droite (↑ ↓ ✕, sélecteur de rendu par bloc, titre). **Modèles** : | |
| 194 | +sauvegarde/chargement/suppression nommés en localStorage (clé | |
| 195 | +`ka-stats-rapports`, propre à l'origine du site). Bouton « Générer le PDF » | |
| 196 | +→ POST + téléchargement blob. États busy/erreur propres, tactile ≥ 44 px, | |
| 197 | +z-index `var(--z-modal, 900)`. | |
| 198 | + | |
| 138 | 199 | ## 4. Spécifique par plateforme (sections métier attendues) |
| 139 | 200 | |
| 140 | 201 | - **groupe-ka** : tableau de bord maître — consolidation des plateformes |
modified
frontend/src/ka/stats/kacharts.tsx
+213 −1
@@ -676,10 +676,11 @@ export function RecordCard({ r }: { r: RecordFact }) { | ||
| 676 | 676 | ); |
| 677 | 677 | } |
| 678 | 678 | |
| 679 | −/* ---------- Menu de rapports PDF (5 rapports) ---------- */ | |
| 679 | +/* ---------- Menu de rapports PDF (5 rapports + personnalisé v3) ---------- */ | |
| 680 | 680 | export function PdfButton({ period, from, to, endpoint = "/api/stats/report" }: { period: string; from?: string; to?: string; endpoint?: string }) { |
| 681 | 681 | const [open, setOpen] = useState(false); |
| 682 | 682 | const [busy, setBusy] = useState<string | null>(null); |
| 683 | + const [builder, setBuilder] = useState(false); | |
| 683 | 684 | const box = useRef<HTMLSpanElement>(null); |
| 684 | 685 | useEffect(() => { |
| 685 | 686 | if (!open) return; |
@@ -715,6 +716,13 @@ export function PdfButton({ period, from, to, endpoint = "/api/stats/report" }: | ||
| 715 | 716 | aria-haspopup="menu" aria-expanded={open}> |
| 716 | 717 | Autres rapports ▾ |
| 717 | 718 | </button> |
| 719 | + <button type="button" className="btn btn-ghost" onClick={() => setBuilder(true)} disabled={!!busy}> | |
| 720 | + 🛠 Rapport personnalisé | |
| 721 | + </button> | |
| 722 | + {builder && ( | |
| 723 | + <ReportBuilder period={period} from={from} to={to} endpoint={endpoint} | |
| 724 | + onClose={() => setBuilder(false)} /> | |
| 725 | + )} | |
| 718 | 726 | {open && ( |
| 719 | 727 | <div role="menu" className="card" style={{ position: "absolute", top: "calc(100% + 6px)", right: 0, zIndex: 50, minWidth: 300, padding: 6, background: "var(--surface)", boxShadow: "0 10px 28px rgba(20,24,20,0.18)" }}> |
| 720 | 728 | {REPORT_MODES.map((m) => ( |
@@ -732,6 +740,210 @@ export function PdfButton({ period, from, to, endpoint = "/api/stats/report" }: | ||
| 732 | 740 | ); |
| 733 | 741 | } |
| 734 | 742 | |
| 743 | +/* ---------- v3 : constructeur de rapports personnalisés ---------- | |
| 744 | + Compose un PDF bloc par bloc : catalogue dérivé du dashboard | |
| 745 | + (GET /api/stats/catalog), rendu au choix par bloc, ordre libre, modèles | |
| 746 | + sauvegardés en localStorage (clé ka-stats-rapports, propre au site). | |
| 747 | + Contrat : SPEC.md §3bis. Rendu dans PdfButton — aucune modif des pages. */ | |
| 748 | +export type CatalogBlock = { | |
| 749 | + key: string; section: string; title: string; | |
| 750 | + renders: string[]; default_render: string; count?: number; | |
| 751 | +}; | |
| 752 | +type BuilderSel = { key: string; render: string }; | |
| 753 | +type BuilderTpl = { name: string; title: string; blocks: BuilderSel[] }; | |
| 754 | + | |
| 755 | +const RENDER_LABELS: Record<string, string> = { | |
| 756 | + line: "Courbe", area: "Aire", bar: "Barres verticales", | |
| 757 | + bars: "Barres horizontales", donut: "Anneau", lines: "Multi-courbes", | |
| 758 | + stacked: "Barres empilées", histogram: "Histogramme", heatmap: "Heatmap", | |
| 759 | + cards: "Cartes", gauges: "Jauges", table: "Tableau", | |
| 760 | +}; | |
| 761 | +const SECTION_LABELS: Record<string, string> = { | |
| 762 | + kpis: "Indicateurs", gauges: "Jauges", series: "Évolution", | |
| 763 | + multiseries: "Multi-courbes", stacked: "Compositions", | |
| 764 | + breakdowns: "Répartitions", distributions: "Distributions", | |
| 765 | + geo: "Géographie", heatmap: "Calendrier", hourly: "Activité horaire", | |
| 766 | + tables: "Tableaux", records: "Records", | |
| 767 | +}; | |
| 768 | +const TPL_KEY = "ka-stats-rapports"; | |
| 769 | + | |
| 770 | +function loadTemplates(): BuilderTpl[] { | |
| 771 | + try { return JSON.parse(localStorage.getItem(TPL_KEY) ?? "[]"); } | |
| 772 | + catch { return []; } | |
| 773 | +} | |
| 774 | +function saveTemplates(t: BuilderTpl[]) { | |
| 775 | + try { localStorage.setItem(TPL_KEY, JSON.stringify(t)); } catch { /* plein/privé */ } | |
| 776 | +} | |
| 777 | + | |
| 778 | +export function ReportBuilder({ | |
| 779 | + period, from, to, endpoint = "/api/stats/report", onClose, | |
| 780 | +}: { | |
| 781 | + period: string; from?: string; to?: string; endpoint?: string; onClose: () => void; | |
| 782 | +}) { | |
| 783 | + const [cat, setCat] = useState<CatalogBlock[] | null>(null); | |
| 784 | + const [err, setErr] = useState(""); | |
| 785 | + const [sel, setSel] = useState<BuilderSel[]>([]); | |
| 786 | + const [title, setTitle] = useState(""); | |
| 787 | + const [busy, setBusy] = useState(false); | |
| 788 | + const [tpls, setTpls] = useState<BuilderTpl[]>(loadTemplates); | |
| 789 | + const catalogUrl = endpoint.replace(/\/report$/, "/catalog"); | |
| 790 | + | |
| 791 | + useEffect(() => { | |
| 792 | + const p = new URLSearchParams({ period }); | |
| 793 | + if (from) p.set("from", from); | |
| 794 | + if (to) p.set("to", to); | |
| 795 | + fetch(`${catalogUrl}?${p}`) | |
| 796 | + .then((r) => (r.ok ? r.json() : Promise.reject(r.status))) | |
| 797 | + .then((d) => setCat(d.blocks ?? [])) | |
| 798 | + .catch(() => setErr("Catalogue indisponible — réessayez plus tard.")); | |
| 799 | + }, [period, from, to, catalogUrl]); | |
| 800 | + | |
| 801 | + useEffect(() => { | |
| 802 | + const esc = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; | |
| 803 | + document.addEventListener("keydown", esc); | |
| 804 | + const prev = document.body.style.overflow; | |
| 805 | + document.body.style.overflow = "hidden"; | |
| 806 | + return () => { document.removeEventListener("keydown", esc); document.body.style.overflow = prev; }; | |
| 807 | + }, [onClose]); | |
| 808 | + | |
| 809 | + const add = (b: CatalogBlock) => | |
| 810 | + setSel((s) => s.some((x) => x.key === b.key && x.render === b.default_render) | |
| 811 | + ? s : [...s, { key: b.key, render: b.default_render }]); | |
| 812 | + const move = (i: number, d: number) => setSel((s) => { | |
| 813 | + const j = i + d; | |
| 814 | + if (j < 0 || j >= s.length) return s; | |
| 815 | + const n = [...s]; [n[i], n[j]] = [n[j], n[i]]; return n; | |
| 816 | + }); | |
| 817 | + | |
| 818 | + const generate = async () => { | |
| 819 | + if (busy || !sel.length) return; | |
| 820 | + setBusy(true); setErr(""); | |
| 821 | + try { | |
| 822 | + const body: Record<string, unknown> = { title, period, blocks: sel }; | |
| 823 | + if (from && to) { body.from = from; body.to = to; } | |
| 824 | + const r = await fetch(`${endpoint}/custom`, { | |
| 825 | + method: "POST", headers: { "Content-Type": "application/json" }, | |
| 826 | + body: JSON.stringify(body), | |
| 827 | + }); | |
| 828 | + if (!r.ok) throw new Error(String(r.status)); | |
| 829 | + const blob = await r.blob(); | |
| 830 | + const m = (r.headers.get("Content-Disposition") ?? "").match(/filename="?([^";]+)/); | |
| 831 | + const a = document.createElement("a"); | |
| 832 | + a.href = URL.createObjectURL(blob); | |
| 833 | + a.download = m ? m[1] : "rapport-personnalise.pdf"; | |
| 834 | + document.body.appendChild(a); a.click(); a.remove(); | |
| 835 | + setTimeout(() => URL.revokeObjectURL(a.href), 4000); | |
| 836 | + } catch { | |
| 837 | + setErr("La génération a échoué — réessayez."); | |
| 838 | + } | |
| 839 | + setBusy(false); | |
| 840 | + }; | |
| 841 | + | |
| 842 | + const groups: [string, CatalogBlock[]][] = []; | |
| 843 | + for (const b of cat ?? []) { | |
| 844 | + const g = groups.find(([s]) => s === b.section); | |
| 845 | + if (g) g[1].push(b); else groups.push([b.section, [b]]); | |
| 846 | + } | |
| 847 | + const selKeys = new Set(sel.map((s) => s.key)); | |
| 848 | + const mono: React.CSSProperties = { fontFamily: "var(--font-mono)", fontSize: 10.5, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.06em" }; | |
| 849 | + | |
| 850 | + return ( | |
| 851 | + <div role="dialog" aria-modal="true" aria-label="Rapport personnalisé" | |
| 852 | + onClick={(e) => { if (e.target === e.currentTarget) onClose(); }} | |
| 853 | + style={{ position: "fixed", inset: 0, zIndex: "var(--z-modal, 900)" as never, background: "rgba(20,24,20,0.45)", display: "flex", alignItems: "flex-start", justifyContent: "center", padding: "4vh 14px", overflow: "auto" }}> | |
| 854 | + <div className="card" style={{ width: "min(980px,100%)", maxHeight: "92vh", display: "flex", flexDirection: "column", background: "var(--surface)", padding: 0, textAlign: "left", cursor: "default" }}> | |
| 855 | + <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 10, padding: "16px 20px", borderBottom: "1px solid var(--line)" }}> | |
| 856 | + <b style={{ fontFamily: "var(--font-display)", fontSize: 18 }}> | |
| 857 | + Rapport personnalisé <span className="klabel">· période : {from && to ? `${from} → ${to}` : (PERIODS.find((p) => p.id === period)?.label ?? period)}</span> | |
| 858 | + </b> | |
| 859 | + <button type="button" className="btn btn-ghost" onClick={onClose}>✕ Fermer</button> | |
| 860 | + </div> | |
| 861 | + <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))", overflow: "auto", flex: 1 }}> | |
| 862 | + <div style={{ padding: "14px 20px", minWidth: 0, borderRight: "1px solid var(--line)" }}> | |
| 863 | + <h3 style={{ ...mono, color: "var(--ink-3)", margin: "4px 0 10px" }}>Blocs disponibles ({cat?.length ?? "…"})</h3> | |
| 864 | + {!cat && !err && <p className="klabel">Chargement du catalogue…</p>} | |
| 865 | + {groups.map(([secId, bs]) => ( | |
| 866 | + <div key={secId}> | |
| 867 | + <p style={{ ...mono, color: "var(--ink-2)", margin: "12px 0 6px" }}>{SECTION_LABELS[secId] ?? secId}</p> | |
| 868 | + {bs.map((b) => ( | |
| 869 | + <div key={b.key} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 8, padding: "7px 10px", border: "1px solid var(--line)", borderRadius: 8, marginBottom: 6, fontSize: 13, opacity: selKeys.has(b.key) ? 0.45 : 1 }}> | |
| 870 | + <span style={{ minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={b.title}>{b.title}</span> | |
| 871 | + <button type="button" className="btn btn-ghost" onClick={() => add(b)} aria-label={`Ajouter ${b.title}`} style={{ flex: "none" }}>+</button> | |
| 872 | + </div> | |
| 873 | + ))} | |
| 874 | + </div> | |
| 875 | + ))} | |
| 876 | + </div> | |
| 877 | + <div style={{ padding: "14px 20px", minWidth: 0 }}> | |
| 878 | + <h3 style={{ ...mono, color: "var(--ink-3)", margin: "4px 0 10px" }}>Composition du rapport ({sel.length})</h3> | |
| 879 | + <label className="klabel" htmlFor="rb-title">Titre du rapport</label> | |
| 880 | + <input id="rb-title" className="input" style={{ width: "100%", margin: "4px 0 12px", boxSizing: "border-box" }} | |
| 881 | + maxLength={80} placeholder="Ex. : Revue mensuelle" value={title} onChange={(e) => setTitle(e.target.value)} /> | |
| 882 | + {sel.length ? sel.map((s, i) => { | |
| 883 | + const b = (cat ?? []).find((x) => x.key === s.key) ?? { title: s.key, renders: [s.render] } as CatalogBlock; | |
| 884 | + return ( | |
| 885 | + <div key={`${s.key}:${s.render}:${i}`} style={{ display: "flex", alignItems: "center", gap: 8, padding: "8px 10px", border: "1px solid var(--ink)", borderRadius: 8, marginBottom: 6, background: "var(--surface-2)", fontSize: 13 }}> | |
| 886 | + <button type="button" onClick={() => move(i, -1)} disabled={i === 0} aria-label="Monter" style={{ border: 0, background: "none", cursor: "pointer", opacity: i === 0 ? 0.25 : 1 }}>▲</button> | |
| 887 | + <button type="button" onClick={() => move(i, 1)} disabled={i === sel.length - 1} aria-label="Descendre" style={{ border: 0, background: "none", cursor: "pointer", opacity: i === sel.length - 1 ? 0.25 : 1 }}>▼</button> | |
| 888 | + <span style={{ flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={b.title}><b>{i + 1}.</b> {b.title}</span> | |
| 889 | + {b.renders.length > 1 ? ( | |
| 890 | + <select className="input" value={s.render} aria-label="Rendu" style={{ maxWidth: 150, padding: "4px 6px", fontSize: 12 }} | |
| 891 | + onChange={(e) => setSel((xs) => xs.map((x, j) => j === i ? { ...x, render: e.target.value } : x))}> | |
| 892 | + {b.renders.map((r) => <option key={r} value={r}>{RENDER_LABELS[r] ?? r}</option>)} | |
| 893 | + </select> | |
| 894 | + ) : <span className="klabel">{RENDER_LABELS[s.render] ?? s.render}</span>} | |
| 895 | + <button type="button" onClick={() => setSel((xs) => xs.filter((_, j) => j !== i))} aria-label="Retirer" style={{ border: 0, background: "none", cursor: "pointer" }}>✕</button> | |
| 896 | + </div> | |
| 897 | + ); | |
| 898 | + }) : ( | |
| 899 | + <div style={{ border: "1px dashed var(--line)", borderRadius: 8, padding: 16, color: "var(--ink-3)", fontSize: 13, textAlign: "center" }}> | |
| 900 | + Aucun bloc — ajoutez des blocs depuis la colonne de gauche, ou chargez un modèle ci-dessous. | |
| 901 | + </div> | |
| 902 | + )} | |
| 903 | + <p style={{ display: "flex", gap: 8, margin: "10px 0 0" }}> | |
| 904 | + <button type="button" className="btn btn-ghost" disabled={!cat?.length} | |
| 905 | + onClick={() => setSel((cat ?? []).map((b) => ({ key: b.key, render: b.default_render })))}>Tout ajouter</button> | |
| 906 | + <button type="button" className="btn btn-ghost" disabled={!sel.length} onClick={() => setSel([])}>Vider</button> | |
| 907 | + </p> | |
| 908 | + {err && <p style={{ color: "var(--danger)", fontSize: 12.5, margin: "6px 0 0" }}>{err}</p>} | |
| 909 | + </div> | |
| 910 | + </div> | |
| 911 | + <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 10, flexWrap: "wrap", padding: "14px 20px", borderTop: "1px solid var(--line)" }}> | |
| 912 | + <span style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}> | |
| 913 | + <select className="input" aria-label="Modèles sauvegardés" style={{ maxWidth: 210 }} value="" | |
| 914 | + onChange={(e) => { | |
| 915 | + const t = tpls[Number(e.target.value)]; | |
| 916 | + if (!t) return; | |
| 917 | + setTitle(t.title || t.name); | |
| 918 | + setSel((t.blocks ?? []).filter((s) => (cat ?? []).some((b) => b.key === s.key)).map((s) => ({ ...s }))); | |
| 919 | + }}> | |
| 920 | + <option value="">Modèles ({tpls.length})…</option> | |
| 921 | + {tpls.map((t, i) => <option key={t.name} value={i}>{t.name}</option>)} | |
| 922 | + </select> | |
| 923 | + <button type="button" className="btn btn-ghost" disabled={!sel.length} | |
| 924 | + onClick={() => { | |
| 925 | + const name = window.prompt("Nom du modèle :", title || "Mon rapport"); | |
| 926 | + if (!name) return; | |
| 927 | + const next = [...tpls.filter((t) => t.name !== name), { name, title, blocks: sel.map((s) => ({ ...s })) }]; | |
| 928 | + setTpls(next); saveTemplates(next); | |
| 929 | + }}>💾 Sauvegarder</button> | |
| 930 | + <button type="button" className="btn btn-ghost" disabled={!tpls.length} | |
| 931 | + onClick={() => { | |
| 932 | + const name = window.prompt(`Nom du modèle à supprimer :\n${tpls.map((t) => `· ${t.name}`).join("\n")}`); | |
| 933 | + if (!name) return; | |
| 934 | + const next = tpls.filter((t) => t.name !== name); | |
| 935 | + setTpls(next); saveTemplates(next); | |
| 936 | + }}>🗑 Supprimer</button> | |
| 937 | + </span> | |
| 938 | + <button type="button" className="btn btn-primary" disabled={!sel.length || busy} onClick={generate}> | |
| 939 | + {busy ? "Génération…" : "⬇ Générer le PDF"} | |
| 940 | + </button> | |
| 941 | + </div> | |
| 942 | + </div> | |
| 943 | + </div> | |
| 944 | + ); | |
| 945 | +} | |
| 946 | + | |
| 735 | 947 | /* ---------- États ---------- */ |
| 736 | 948 | export function EmptyBlock({ title }: { title: string }) { |
| 737 | 949 | return ( |
| 738 | 950 | |