SPB Git forge

spb/job-ka

Public
229commits 1branches 0releases
38.1 MBsize
maindefault branch
1 h agolast push
HTML 82.1% Python 14.6% TypeScript 1.9% CSS 1% JavaScript 0.5%
9.0 KB · 203 lines tsx
Raw Blame History
1/**2 * =============================================================================3 * Job·Ka — Groupe KA4 * Auteur  : Simon-Pierre Boucher5 * Contact : contact@spboucher.ai6 * Fichier : frontend/src/pages/Stats.tsx7 * Rôle    : Tableau de bord analytique — module Stats commun Groupe KA v28 *           (contrat ../ka/stats/SPEC.md, composants ../ka/stats/kacharts.tsx).9 *           PDF (5 rapports) + fraîcheur, KPI + sparklines, jauges, courbes10 *           (N-1) + stats de séries, multi-courbes, barres empilées,11 *           répartitions, distributions, géographie, calendrier + heatmap12 *           horaire, tableaux, records.13 * Créé    : 2026-08-17   Modifié : 2026-08-1914 * =============================================================================15 */16import { useCallback, useEffect, useState } from "react";17import {18  BarChart, BreakItem, CalendarHeatmap, DataTable, Distribution, Donut,19  EmptyBlock, Fraicheur, Gauge, GaugeCard, Histogram, HourCell, HourHeatmap,20  Kpi, KpiCard, LineChart, MultiLineChart, MultiSerie, PdfButton,21  PeriodSelector, RecordCard, RecordFact, Serie, StackedBarChart,22  StackedSerie, StatSummary, TableSpec,23} from "../ka/stats/kacharts";2425interface Dashboard {26  updated: string;27  period: { from: string | null; to: string | null; label: string };28  kpis: Kpi[];29  gauges?: Gauge[];30  series: Serie[];31  multiseries?: MultiSerie[];32  stacked?: StackedSerie[];33  breakdowns: { id: string; title: string; kind?: string; items: BreakItem[] }[];34  distributions?: Distribution[];35  geo?: { title: string; items: BreakItem[] };36  heatmap?: { title: string; cells: { date: string; value: number }[] };37  hourly?: { title: string; cells: HourCell[] };38  tables: TableSpec[];39  records: RecordFact[];40}4142function SectionTitle({ children }: { children: string }) {43  return (44    <h2 style={{ margin: "6px 0 0", fontFamily: "var(--font-display)", fontSize: 19 }}>45      {children}46    </h2>47  );48}4950export default function StatsPage() {51  const [period, setPeriod] = useState("30j");52  const [custom, setCustom] = useState<{ from: string; to: string }>({ from: "", to: "" });53  const [dash, setDash] = useState<Dashboard | null>(null);54  const [loading, setLoading] = useState(true);55  const [err, setErr] = useState<string | null>(null);5657  const customOk = Boolean(custom.from && custom.to);5859  const load = useCallback(() => {60    setLoading(true);61    setErr(null);62    const p = new URLSearchParams({ period });63    if (customOk) {64      p.set("from", custom.from);65      p.set("to", custom.to);66    }67    fetch(`/api/stats/dashboard?${p}`)68      .then((r) => {69        if (!r.ok) throw new Error(`API ${r.status}`);70        return r.json();71      })72      .then((d: Dashboard) => setDash(d))73      .catch((e) => setErr(String(e?.message ?? e)))74      .finally(() => setLoading(false));75  }, [period, custom.from, custom.to, customOk]);7677  useEffect(() => {78    document.title = "Statistiques de l'emploi — Job-Ka · Un service Groupe KA";79    load();80  }, [load]);8182  const donuts = dash?.breakdowns?.filter((b) => b.kind === "donut") ?? [];83  const barBreaks = dash?.breakdowns?.filter((b) => b.kind !== "donut") ?? [];8485  return (86    <div className="container" style={{ display: "grid", gap: 18, paddingBottom: 40 }}>87      {/* --- en-tête : titre + PDF (5 rapports) + fraîcheur ------------------ */}88      <header style={{ display: "flex", flexWrap: "wrap", gap: 14, alignItems: "flex-end", justifyContent: "space-between", marginTop: 24 }}>89        <div style={{ minWidth: 0 }}>90          <p className="klabel" style={{ margin: 0 }}>Groupe KA · Job·Ka</p>91          <h1 style={{ margin: "4px 0 0", fontFamily: "var(--font-display)", fontSize: "clamp(24px,3.4vw,34px)", letterSpacing: "-0.02em" }}>92            Statistiques du marché de l'emploi93          </h1>94          {dash && (95            <p className="klabel" style={{ margin: "6px 0 0" }}>96              Période : {dash.period.label}97              {dash.period.from ? ` (${dash.period.from} → ${dash.period.to})` : ""}98            </p>99          )}100        </div>101        <div style={{ display: "grid", gap: 8, justifyItems: "end" }}>102          <PdfButton period={period} from={customOk ? custom.from : undefined} to={customOk ? custom.to : undefined} />103          {dash && <Fraicheur updated={dash.updated} onRefresh={load} />}104        </div>105      </header>106107      {err && (108        <div className="card" style={{ padding: 18, borderColor: "var(--danger)" }}>109          <b>Impossible de charger les statistiques.</b>110          <p className="klabel" style={{ margin: "6px 0 10px" }}>{err}</p>111          <button type="button" className="btn btn-primary" onClick={load}>Réessayer</button>112        </div>113      )}114115      {/* --- bandeau KPI (sparklines) ---------------------------------------- */}116      {dash && dash.kpis.length > 0 && (117        <section aria-label="Indicateurs clés" style={{ display: "grid", gap: 12, gridTemplateColumns: "repeat(auto-fit, minmax(190px, 1fr))", opacity: loading ? 0.55 : 1, transition: "opacity .2s" }}>118          {dash.kpis.map((k) => <KpiCard key={k.id} k={k} />)}119        </section>120      )}121      {!dash && loading && (122        <div className="card" style={{ padding: 24, textAlign: "center" }}>123          <span className="klabel">Chargement des statistiques…</span>124        </div>125      )}126127      {/* --- sélecteur de période --------------------------------------------- */}128      <section className="card" style={{ padding: "14px 16px" }} aria-label="Période">129        <PeriodSelector130          value={period}131          onChange={(p) => { setPeriod(p); setCustom({ from: "", to: "" }); }}132          custom={custom}133          onCustom={(from, to) => setCustom({ from, to })}134        />135      </section>136137      {dash && (138        <div style={{ display: "grid", gap: 18, opacity: loading ? 0.55 : 1, transition: "opacity .2s" }}>139          {/* --- jauges (complétude des fiches) ------------------------------ */}140          {dash.gauges && dash.gauges.length > 0 && (141            <section aria-label="Complétude des fiches" style={{ display: "grid", gap: 12, gridTemplateColumns: "repeat(auto-fit, minmax(min(210px, 100%), 1fr))" }}>142              {dash.gauges.map((g) => <GaugeCard key={g.id} g={g} />)}143            </section>144          )}145146          {/* --- évolutions : courbes / barres + stats de séries -------------- */}147          {dash.series?.length ? (148            dash.series.map((s) => (149              <div key={s.id}>150                <LineChart serie={s} />151                <StatSummary serie={s} />152              </div>153            ))154          ) : (155            <EmptyBlock title="Évolution quotidienne" />156          )}157158          {/* --- multi-courbes (top secteurs) ---------------------------------- */}159          {dash.multiseries?.map((ms) => <MultiLineChart key={ms.id} ms={ms} />)}160161          {/* --- barres empilées (ajouts par source) --------------------------- */}162          {dash.stacked?.map((st) => <StackedBarChart key={st.id} st={st} />)}163164          {/* --- répartitions (anneaux + barres avec deltas) ------------------- */}165          {(donuts.length > 0 || barBreaks.length > 0) && (166            <div style={{ display: "grid", gap: 18, gridTemplateColumns: "repeat(auto-fit, minmax(min(340px, 100%), 1fr))" }}>167              {donuts.map((b) => <Donut key={b.id} title={b.title} items={b.items} />)}168              {barBreaks.map((b) => <BarChart key={b.id} title={b.title} items={b.items} unit="offres" />)}169            </div>170          )}171172          {/* --- distributions (histogrammes) ---------------------------------- */}173          {dash.distributions && dash.distributions.length > 0 && (174            <div style={{ display: "grid", gap: 18, gridTemplateColumns: "repeat(auto-fit, minmax(min(340px, 100%), 1fr))" }}>175              {dash.distributions.map((d) => <Histogram key={d.id} dist={d} />)}176            </div>177          )}178179          {/* --- géographie ------------------------------------------------------ */}180          {dash.geo && <BarChart title={dash.geo.title} items={dash.geo.items} unit="offres" />}181182          {/* --- calendrier de chaleur + activité horaire ------------------------ */}183          {dash.heatmap && <CalendarHeatmap title={dash.heatmap.title} cells={dash.heatmap.cells} />}184          {dash.hourly && <HourHeatmap title={dash.hourly.title} cells={dash.hourly.cells} />}185186          {/* --- tableaux détaillés ---------------------------------------------- */}187          {dash.tables?.map((t) => <DataTable key={t.id} spec={t} />)}188189          {/* --- records & faits marquants ---------------------------------------- */}190          {dash.records?.length > 0 && (191            <section aria-label="Records et faits marquants" style={{ display: "grid", gap: 10 }}>192              <SectionTitle>Records & faits marquants</SectionTitle>193              <div style={{ display: "grid", gap: 10, gridTemplateColumns: "repeat(auto-fit, minmax(min(280px, 100%), 1fr))" }}>194                {dash.records.map((r) => <RecordCard key={r.label} r={r} />)}195              </div>196            </section>197          )}198        </div>199      )}200    </div>201  );202}203