Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.
TypeScript 90.2%
JavaScript 3.5%
Python 3.4%
CSS 1.9%
HTML 0.6%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2/**3 * Tableau de bord /stats v2 — module Stats commun Groupe KA (src/ka/stats/4 * SPEC.md §1), monté sur le kit kacharts : bandeau KPI (sparklines), jauges,5 * courbes + stats de séries, multi-courbes, barres empilées, répartitions,6 * distributions, géographie, calendrier d'activité, tableaux, records,7 * fraîcheur + PdfButton (5 rapports). Données : /api/stats/dashboard8 * (instantané du rôle 2026 + corpus de ventes réelles — period non applicable).9 */10"use client";11import { useCallback, useState } from "react";12import {13 KpiCard, GaugeCard, LineChart, MultiLineChart, StackedBarChart, BarChart,14 Donut, Histogram, CalendarHeatmap, StatSummary, DataTable, RecordCard,15 PdfButton, Fraicheur,16 type Kpi, type Serie, type MultiSerie, type StackedSerie, type BreakItem,17 type Distribution, type Gauge, type TableSpec, type RecordFact,18} from "@/ka/stats/kacharts";19import { useLang } from "./LangContext";2021export interface DashboardPayload {22 updated: string;23 period: { label: string; applicable?: boolean };24 kpis: Kpi[];25 gauges?: Gauge[];26 series?: Serie[];27 multiseries?: MultiSerie[];28 stacked?: StackedSerie[];29 breakdowns?: { id: string; title: string; kind: "donut" | "bars"; items: BreakItem[] }[];30 distributions?: Distribution[];31 geo?: { title: string; items: BreakItem[] };32 heatmap?: { title: string; cells: { date: string; value: number }[] };33 tables?: TableSpec[];34 records?: RecordFact[];35}3637function SectionTitle({ kicker, title }: { kicker: string; title: string }) {38 return (39 <div className="mb-4 mt-12">40 <span className="kicker">{kicker}</span>41 <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">{title}</h2>42 </div>43 );44}4546export default function StatsDashboard({ initial }: { initial: DashboardPayload }) {47 const { lang } = useLang();48 const fr = lang === "fr";49 const [data, setData] = useState<DashboardPayload>(initial);50 const refresh = useCallback(async () => {51 try {52 const r = await fetch("/api/stats/dashboard?period=tout", { cache: "no-store" });53 if (r.ok) setData(await r.json());54 } catch {55 /* on garde les données courantes */56 }57 }, []);5859 const keySeries = new Set(["valeur_millesime", "ventes_mois", "prix_median_mois"]);6061 return (62 <div>63 {/* ---- fraîcheur + rapports PDF (5 modes) ---- */}64 <div className="mb-2 mt-10 flex flex-wrap items-center justify-between gap-3 border-y border-[var(--line)] px-1 py-3">65 <div>66 <p className="vp-mono text-[10px] font-bold uppercase tracking-[0.08em] text-ink-3">67 {fr68 ? `${data.period.label} · corpus de ventes 2021-2026 · périodes non applicables (instantané)`69 : `${data.period.label} · 2021-2026 sales corpus · periods not applicable (snapshot)`}70 </p>71 <div className="mt-1.5">72 <Fraicheur updated={data.updated} onRefresh={refresh} />73 </div>74 </div>75 <PdfButton period="tout" />76 </div>7778 {/* ---- 1. bandeau KPI ---- */}79 <SectionTitle kicker={fr ? "Indicateurs" : "Indicators"} title={fr ? "Indicateurs clés" : "Key indicators"} />80 <div className="grid grid-cols-1 gap-3.5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">81 {data.kpis.map((k) => <KpiCard key={k.id} k={k} />)}82 </div>8384 {/* ---- 3. jauges ---- */}85 {!!data.gauges?.length && (86 <>87 <SectionTitle kicker={fr ? "Couverture" : "Coverage"} title={fr ? "Couverture & complétude du rôle" : "Roll coverage & completeness"} />88 <div className="grid grid-cols-2 gap-3.5 lg:grid-cols-4">89 {data.gauges.map((g) => <GaugeCard key={g.id} g={g} />)}90 </div>91 </>92 )}9394 {/* ---- 4. évolutions ---- */}95 {!!data.series?.length && (96 <>97 <SectionTitle kicker="2021 → 2026" title={fr ? "Évolution — millésimes & ventes réelles" : "Trends — vintages & real sales"} />98 <div className="grid gap-4">99 {data.series.map((s) => (100 <div key={s.id}>101 <LineChart serie={s} />102 {keySeries.has(s.id) && <StatSummary serie={s} />}103 </div>104 ))}105 </div>106 </>107 )}108 {!!data.multiseries?.length && (109 <>110 <SectionTitle kicker={fr ? "Marché" : "Market"} title={fr ? "Indices de marché & prix au m²" : "Market indices & price per m²"} />111 <div className="grid gap-4">112 {data.multiseries.map((ms) => <MultiLineChart key={ms.id} ms={ms} />)}113 </div>114 </>115 )}116 {!!data.stacked?.length && (117 <div className="mt-4 grid gap-4">118 {data.stacked.map((st) => <StackedBarChart key={st.id} st={st} />)}119 </div>120 )}121122 {/* ---- 5. répartitions & distributions ---- */}123 {(!!data.breakdowns?.length || !!data.distributions?.length) && (124 <>125 <SectionTitle kicker={fr ? "Répartitions" : "Breakdowns"} title={fr ? "Répartitions & distributions" : "Breakdowns & distributions"} />126 <div className="grid gap-4 lg:grid-cols-2">127 {data.breakdowns?.map((b) =>128 b.kind === "donut"129 ? <Donut key={b.id} title={b.title} items={b.items} />130 : <BarChart key={b.id} title={b.title} items={b.items} />131 )}132 {data.distributions?.map((d) => <Histogram key={d.id} dist={d} />)}133 </div>134 </>135 )}136137 {/* ---- 6. géographie ---- */}138 {!!data.geo?.items?.length && (139 <>140 <SectionTitle kicker={fr ? "Géographie" : "Geography"} title={data.geo.title} />141 <BarChart title={data.geo.title} items={data.geo.items} />142 </>143 )}144145 {/* ---- 7. calendrier d'activité ---- */}146 {!!data.heatmap?.cells?.length && (147 <>148 <SectionTitle kicker={fr ? "Activité" : "Activity"} title={fr ? "Ventes réelles au calendrier" : "Real sales calendar"} />149 <CalendarHeatmap title={data.heatmap.title} cells={data.heatmap.cells} />150 </>151 )}152153 {/* ---- 8. tableaux détaillés ---- */}154 {!!data.tables?.length && (155 <>156 <SectionTitle kicker={fr ? "Détails" : "Details"} title={fr ? "Tableaux détaillés" : "Detailed tables"} />157 <div className="grid gap-5">158 {data.tables.map((t) => <DataTable key={t.id} spec={t as TableSpec} />)}159 </div>160 </>161 )}162163 {/* ---- 9. records ---- */}164 {!!data.records?.length && (165 <>166 <SectionTitle kicker={fr ? "Faits marquants" : "Highlights"} title={fr ? "Records & faits marquants" : "Records & highlights"} />167 <div className="grid gap-3 sm:grid-cols-2">168 {data.records.map((r) => <RecordCard key={r.label} r={r} />)}169 </div>170 </>171 )}172173 {/* ---- 10. fraîcheur (rappel de bas de page) ---- */}174 <div className="mt-10 flex justify-between gap-3">175 <Fraicheur updated={data.updated} onRefresh={refresh} />176 <PdfButton period="tout" />177 </div>178 </div>179 );180}181