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%
9.6 KB · 103 lines tsx
Raw Blame History
1// Vrai-Prix — méthode du coût : catalogue des assemblages (/cout/assemblage) avec filtre par catégorie / recherche2// et, si ?item=, la fiche d'un article (prix canonique, historique, assemblages qui l'utilisent).3"use client";4import { useState } from "react";5import Link from "next/link";6import { useLang } from "@/components/LangContext";7import type { CostItem, ItemPrice, UxCategory } from "@/lib/cost/types";8import { UX_CATEGORIES, categoryLabel } from "@/lib/cost/taxonomy";9import { UNIT_LABELS } from "@/lib/cost/units";10import { num } from "@/components/avendre/fmt";11import { KindIcon, KindLegend, fmtDate } from "./Provenance";12import { PriceSourceCard } from "./SourceDrawer";13import { Sparkline } from "./Charts";1415export interface CatalogRow { code: string; nameFr: string; nameEn: string; unit: string; category: UxCategory; quality: string | null; economicLife: number | null; material: number; labour: number; equipment: number; direct: number; observedShare: number; freshestObservation: string | null; components: number }16export interface ItemCard { item: CostItem; price: ItemPrice | null; history: { date: string; price: number; source: string; url: string | null; regular: boolean; kind: string; outlier: boolean }[]; usedBy: { code: string; nameFr: string; nameEn: string; unit: string; quantity: number }[] }1718export default function AssemblyCatalog({ rows, asOf, item }: { rows: CatalogRow[]; asOf: string; item: ItemCard | null }) {19  const { lang } = useLang();20  const fr = lang === "fr";21  const [q, setQ] = useState("");22  const [cat, setCat] = useState<UxCategory | "">("");23  const unitL = (x: string) => UNIT_LABELS[x as keyof typeof UNIT_LABELS]?.[fr ? "fr" : "en"] ?? x;24  const fold = (s: string) => s.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase();25  const list = rows.filter((r) => (!cat || r.category === cat) && (!q || fold(fr ? r.nameFr : r.nameEn).includes(fold(q)) || r.code.toLowerCase().includes(q.toLowerCase())));26  const cats = UX_CATEGORIES.filter((c) => rows.some((r) => r.category === c.key));27  const hist = item ? (() => { const byDate = new Map<string, number[]>(); for (const h of item.history) if (!h.outlier && h.kind !== "reference") byDate.set(h.date, [...(byDate.get(h.date) ?? []), h.price]); return [...byDate.entries()].map(([date, ps]) => ({ date, price: ps.sort((a, b) => a - b)[Math.floor(ps.length / 2)] })).sort((a, b) => a.date.localeCompare(b.date)); })() : [];28  return (29    <div className="space-y-12 py-8">30      <section className="rise">31        <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 border-b-2 border-ink pb-2.5">32          <nav className="vp-mono flex flex-wrap items-center gap-2 text-[10.5px] uppercase tracking-[0.08em] text-ink-3">33            <Link href="/" className="text-ink-2 hover:text-accent-deep">Vrai&nbsp;Prix</Link><span aria-hidden="true">/</span>34            <Link href="/cout" className="text-ink-2 hover:text-accent-deep">{fr ? "Coût" : "Cost"}</Link><span aria-hidden="true">/</span>35            <span>{fr ? "Assemblages" : "Assemblies"}</span>36          </nav>37          <span className="vp-mono text-[10px] uppercase tracking-[0.08em] text-ink-3">{rows.length} {fr ? "assemblages · prix au" : "assemblies · prices as of"} {asOf} · Montréal</span>38        </div>39        <span className="kicker mt-6 inline-flex">{fr ? "Catalogue" : "Catalogue"}</span>40        <h1 className="vp-display mt-2 text-[clamp(26px,4vw,42px)] font-bold uppercase leading-[1.02] tracking-[-0.03em]">{fr ? "Les assemblages de construction" : "Construction assemblies"}</h1>41        <p className="mt-3 max-w-2xl text-[14.5px] leading-relaxed text-ink-2">{fr ? "Chaque assemblage décompose une composante réelle (mur, toiture, fenêtre, cuisine…) en articles chiffrés, heures de métier et équipement. Le coût direct par unité est recalculé à chaque synchronisation des sources." : "Each assembly breaks a real component (wall, roof, window, kitchen…) down into priced items, trade hours and equipment. The direct cost per unit is recomputed at every source synchronisation."}</p>42      </section>4344      {item && (45        <section className="grid gap-x-12 gap-y-6 border border-[var(--line-strong)] bg-surface p-5 lg:grid-cols-[1.2fr_1fr]">46          <div>47            <p className="kicker">{fr ? "Article" : "Item"} · {item.item.code}</p>48            <h2 className="vp-display mt-2 text-[22px] font-bold leading-tight">{fr ? item.item.nameFr : item.item.nameEn}</h2>49            <p className="vp-mono mt-1 text-[10.5px] uppercase tracking-[0.05em] text-ink-3">MasterFormat {item.item.masterformat} · {item.item.division} · {categoryLabel(item.item.category, fr)} · {fr ? "pertes par défaut" : "default waste"} {Math.round(item.item.defaultWastePct * 100)} %</p>50            <div className="mt-4"><PriceSourceCard fr={fr} itemName={fr ? item.item.nameFr : item.item.nameEn} unit={item.item.unit} price={item.price} /></div>51          </div>52          <div>53            <p className="klabel">{fr ? "Historique (médiane par date, aberrations exclues)" : "History (median per date, outliers excluded)"}</p>54            <div className="mt-2 max-w-[360px]"><Sparkline points={hist} lang={lang} /></div>55            {item.history.length > 0 && (56              <div className="src-wrap mt-3 max-h-[240px] overflow-y-auto"><table className="src-table"><thead><tr><th>{fr ? "Date" : "Date"}</th><th>{fr ? "Source" : "Source"}</th><th className="text-right">{fr ? "Prix" : "Price"}</th><th /></tr></thead><tbody>57                {[...item.history].reverse().slice(0, 40).map((h, i) => (<tr key={i} className={h.outlier ? "opacity-50" : ""}><td className="vp-mono text-[12px]">{h.date}</td><td className="text-[12.5px]">{h.url ? <a href={h.url} target="_blank" rel="noopener noreferrer nofollow" className="hover:text-accent-deep">{h.source} ↗</a> : h.source}</td><td className="vp-mono text-right text-[12px]">{num(h.price, lang, 2)} $</td><td className="vp-mono text-[9.5px] uppercase text-ink-3">{h.outlier ? (fr ? "aberrant" : "outlier") : !h.regular ? (fr ? "promo" : "sale") : ""}</td></tr>))}58              </tbody></table></div>59            )}60            <p className="klabel mt-4">{fr ? "Utilisé par" : "Used by"}</p>61            <ul className="mt-1 flex flex-wrap gap-2">62              {item.usedBy.map((u) => (<li key={u.code}><Link href={`/cout/assemblage/${encodeURIComponent(u.code)}`} className="vp-mono inline-block border border-[var(--line)] px-2 py-1 text-[10.5px] uppercase tracking-[0.05em] hover:border-ink">{fr ? u.nameFr : u.nameEn} · {num(u.quantity, lang, 3)} / {fr ? "unité" : "unit"}</Link></li>))}63              {!item.usedBy.length && <li className="text-[12.5px] text-ink-3">—</li>}64            </ul>65          </div>66        </section>67      )}6869      <section>70        <div className="flex flex-wrap items-end gap-4">71          <input value={q} onChange={(e) => setQ(e.target.value)} placeholder={fr ? "Rechercher un assemblage…" : "Search an assembly…"} className="vp-input max-w-sm text-base" aria-label={fr ? "Rechercher" : "Search"} />72          <div className="flex flex-wrap gap-1">73            <button type="button" aria-pressed={cat === ""} onClick={() => setCat("")} className="vp-tab">{fr ? "Toutes" : "All"}</button>74            {cats.map((c) => (<button key={c.key} type="button" aria-pressed={cat === c.key} onClick={() => setCat(c.key)} className="vp-tab">{fr ? c.fr : c.en}</button>))}75          </div>76        </div>77        <div className="mt-3"><KindLegend fr={fr} /></div>78        <div className="src-wrap mt-4">79          <table className="src-table">80            <thead><tr><th>{fr ? "Assemblage" : "Assembly"}</th><th>{fr ? "Catégorie" : "Category"}</th><th className="text-right">{fr ? "Matériaux" : "Materials"}</th><th className="text-right">{fr ? "Main-d'œuvre" : "Labour"}</th><th className="text-right">{fr ? "Équip." : "Equip."}</th><th className="text-right">{fr ? "Direct / unité" : "Direct / unit"}</th><th className="text-right">{fr ? "Observé" : "Observed"}</th><th>{fr ? "Vie" : "Life"}</th><th>{fr ? "Mise à jour" : "Updated"}</th></tr></thead>81            <tbody>82              {list.map((r) => (83                <tr key={r.code}>84                  <td><Link href={`/cout/assemblage/${encodeURIComponent(r.code)}`} className="font-semibold hover:text-accent-deep">{fr ? r.nameFr : r.nameEn}</Link><br /><span className="vp-mono text-[10px] uppercase text-ink-3">{r.code} · {r.components} {fr ? "composants" : "components"}</span></td>85                  <td className="text-[12.5px]">{categoryLabel(r.category, fr)}</td>86                  <td className="vp-mono text-right text-[12px]">{num(r.material, lang, 2)}</td>87                  <td className="vp-mono text-right text-[12px]">{num(r.labour, lang, 2)}</td>88                  <td className="vp-mono text-right text-[12px]">{num(r.equipment, lang, 2)}</td>89                  <td className="vp-mono text-right font-bold">{num(r.direct, lang, 2)} $ <span className="text-[10px] font-normal text-ink-3">/ {unitL(r.unit)}</span></td>90                  <td className="vp-mono text-right text-[12px]"><KindIcon kind={r.observedShare >= 0.5 ? "observed" : r.observedShare > 0 ? "derived" : "reference"} fr={fr} /> {Math.round(r.observedShare * 100)} %</td>91                  <td className="vp-mono text-[12px]">{r.economicLife ?? "—"}</td>92                  <td className="vp-mono text-[11px]">{r.freshestObservation ? fmtDate(r.freshestObservation, fr) : (fr ? "référence" : "reference")}</td>93                </tr>94              ))}95              {!list.length && <tr><td colSpan={9} className="text-ink-2">{fr ? "Aucun assemblage." : "No assembly."}</td></tr>}96            </tbody>97          </table>98        </div>99      </section>100    </div>101  );102}103