HTML 82.1%
Python 14.6%
TypeScript 1.9%
CSS 1%
JavaScript 0.5%
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