SPB Git forge

spb/vrai-prix

Public

Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.

60commits 1branches 0releases
12.3 MBsize
maindefault branch
17 days agolast push
TypeScript 90.2% JavaScript 3.5% Python 3.4% CSS 1.9% HTML 0.6%
7.0 KB · 181 lines tsx
Raw Blame History
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