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%
15.1 KB · 161 lines tsx
Raw Blame History
1// Vrai-Prix — méthode du coût : fiche d'un assemblage (/cout/assemblage/[code]) — description, schéma, composition,2// prix + provenance, main-d'œuvre, sources, historique 8 trimestres, variations régionales, voisins.3"use client";4import { useState } from "react";5import Link from "next/link";6import { useLang } from "@/components/LangContext";7import type { Assembly, AssemblyUnitCost, CostLocation } from "@/lib/cost/types";8import { categoryLabel, MASTERFORMAT, QUALITIES, tradeLabel } from "@/lib/cost/taxonomy";9import { UNIT_LABELS } from "@/lib/cost/units";10import { num } from "@/components/avendre/fmt";11import { AssemblySketch, StackBar, UnitCostHistory } from "./Charts";12import { fmtDate, KindIcon, KindLegend, kindLabel, LayerBadge } from "./Provenance";13import { Drawer, PriceSourceCard } from "./SourceDrawer";1415export interface AssemblyDetailData {16  asOf: string;17  location: CostLocation;18  assembly: Assembly;19  unit: AssemblyUnitCost;20  regional: { code: string; nameFr: string; nameEn: string; direct: number; materialFactor: number; labourFactor: number; equipmentFactor: number }[];21  history: { date: string; direct: number; material: number; labour: number; equipment: number; kind: string }[];22  neighbours: { code: string; nameFr: string; nameEn: string; unit: string; direct: number }[];23}2425export default function AssemblyDetail({ data }: { data: AssemblyDetailData }) {26  const { lang } = useLang();27  const fr = lang === "fr";28  const a = data.assembly;29  const u = data.unit;30  const unitL = (x: string) => UNIT_LABELS[x as keyof typeof UNIT_LABELS]?.[fr ? "fr" : "en"] ?? x;31  const [src, setSrc] = useState<AssemblyUnitCost["components"][number] | null>(null);32  const maxRegional = Math.max(...data.regional.map((r) => r.direct), 0.01);33  const observedShare = Math.round(u.observedShare * 100);34  return (35    <div className="space-y-12 py-8">36      <section className="rise">37        <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 border-b-2 border-ink pb-2.5">38          <nav className="vp-mono flex flex-wrap items-center gap-2 text-[10.5px] uppercase tracking-[0.08em] text-ink-3">39            <Link href="/" className="text-ink-2 hover:text-accent-deep">Vrai&nbsp;Prix</Link><span aria-hidden="true">/</span>40            <Link href="/cout" className="text-ink-2 hover:text-accent-deep">{fr ? "Coût" : "Cost"}</Link><span aria-hidden="true">/</span>41            <Link href="/cout/assemblage" className="text-ink-2 hover:text-accent-deep">{fr ? "Assemblages" : "Assemblies"}</Link><span aria-hidden="true">/</span>42            <span>{a.code}</span>43          </nav>44          <span className="vp-mono text-[10px] uppercase tracking-[0.08em] text-ink-3">{fr ? "prix au" : "prices as of"} {data.asOf} · {fr ? data.location.nameFr : data.location.nameEn} · v{a.version}</span>45        </div>46        <div className="mt-6 grid gap-x-12 gap-y-6 lg:grid-cols-[1.5fr_1fr]">47          <div>48            <span className="kicker">{categoryLabel(a.category, fr)} · MasterFormat {a.masterformat} — {MASTERFORMAT[a.masterformat]?.[fr ? "fr" : "en"] ?? ""}</span>49            <h1 className="vp-display mt-2 text-[clamp(24px,3.8vw,40px)] font-bold uppercase leading-[1.02] tracking-[-0.03em]">{fr ? a.nameFr : a.nameEn}</h1>50            <p className="mt-3 max-w-2xl text-[14.5px] leading-relaxed text-ink-2">{fr ? a.descriptionFr : a.descriptionEn}</p>51            <div className="mt-4 flex flex-wrap gap-x-6 gap-y-2">52              <div className="kv-cell"><p className="k">{fr ? "Unité" : "Unit"}</p><p className="v">{unitL(a.unit)}</p></div>53              <div className="kv-cell"><p className="k">{fr ? "Qualité" : "Quality"}</p><p className="v">{a.quality ? (QUALITIES.find((q) => q.key === a.quality)?.[fr ? "fr" : "en"] ?? a.quality) : (fr ? "toutes" : "all")}</p></div>54              <div className="kv-cell"><p className="k">{fr ? "Vie économique" : "Economic life"}</p><p className="v">{a.economicLife ?? "—"} {fr ? "ans" : "yrs"}</p></div>55              <div className="kv-cell"><p className="k">{fr ? "Groupe de condition" : "Condition group"}</p><p className="v">{a.conditionGroup ?? "—"}</p></div>56              <div className="kv-cell"><p className="k">{fr ? "Mise à jour des prix" : "Price update"}</p><p className="v">{u.freshestObservation ? fmtDate(u.freshestObservation, fr) : (fr ? "référence seulement" : "reference only")}</p></div>57            </div>58          </div>59          <aside className="lg:border-l lg:border-[var(--line)] lg:pl-10">60            <p className="klabel">{fr ? "Coût direct par unité (Montréal, avant facteur régional)" : "Direct cost per unit (Montréal, before regional factor)"}</p>61            <p className="vp-display mt-1 text-[clamp(30px,4vw,44px)] font-bold tracking-[-0.03em]">{num(u.direct, lang, 2)} $ <span className="vp-mono text-[11px] font-medium text-ink-3">/ {unitL(a.unit)}</span></p>62            <div className="mt-3"><StackBar lang={lang} parts={[{ label: fr ? "Matériaux" : "Materials", value: u.material }, { label: fr ? "Main-d'œuvre" : "Labour", value: u.labour }, { label: fr ? "Équipement" : "Equipment", value: u.equipment }]} /></div>63            <p className="vp-mono mt-3 text-[10.5px] uppercase tracking-[0.05em] text-ink-2">{fr ? "part observée des matériaux" : "observed share of materials"} : <b className="text-ink">{observedShare} %</b> · σ ≈ {Math.round(u.sigmaPct * 100)} %</p>64            <div className="mt-4"><AssemblySketch category={a.category} /></div>65            <p className="vp-mono mt-1 text-[9.5px] uppercase tracking-[0.05em] text-ink-3">{fr ? "schéma de principe (coupe générique)" : "schematic (generic section)"}</p>66          </aside>67        </div>68      </section>6970      <section>71        <div className="sec"><span className="sec-num">01</span> <span className="kicker">{fr ? "Composition" : "Composition"}</span>72          <h2 className="vp-display mt-2 text-[clamp(20px,3vw,28px)] font-bold uppercase leading-[1.05] tracking-[-0.025em]">{fr ? "Composants, quantités, prix et main-d'œuvre" : "Components, quantities, prices and labour"}</h2>73          <p className="mt-2 max-w-3xl text-[14px] text-ink-2">{fr ? "Par unité d'assemblage : matériau = quantité × (1 + pertes) × prix canonique ; main-d'œuvre = heures × coût employeur du métier ; équipement = forfait par unité. Cliquez une ligne pour la fiche source." : "Per assembly unit: material = quantity × (1 + waste) × canonical price; labour = hours × trade employer cost; equipment = lump per unit. Click a row for the source card."}</p>74        </div>75        <div className="src-wrap mt-5">76          <table className="src-table">77            <thead><tr><th>{fr ? "Composant" : "Component"}</th><th className="text-right">{fr ? "Qté / unité" : "Qty / unit"}</th><th className="text-right">{fr ? "Pertes" : "Waste"}</th><th className="text-right">{fr ? "Prix unitaire" : "Unit price"}</th><th>{fr ? "Provenance" : "Provenance"}</th><th className="text-right">{fr ? "Matériau" : "Material"}</th><th className="text-right">{fr ? "Heures" : "Hours"}</th><th className="text-right">{fr ? "Taux" : "Rate"}</th><th className="text-right">MO</th><th className="text-right">É</th></tr></thead>78            <tbody>79              {u.components.map((c, i) => (80                <tr key={i} className="cursor-pointer" onClick={() => setSrc(c)}>81                  <td><span className="font-semibold">{fr ? c.nameFr : c.nameEn}</span>{c.itemCode && <><br /><span className="vp-mono text-[10px] text-ink-3">{c.itemCode}</span></>}{c.trade && c.labourHours > 0 && <><br /><span className="vp-mono text-[10px] text-ink-3">{tradeLabel(c.trade, fr)}</span></>}</td>82                  <td className="vp-mono text-right text-[12px]">{c.itemCode ? `${num(c.quantity, lang, 4)} ${unitL(c.unit)}` : "—"}</td>83                  <td className="vp-mono text-right text-[12px]">{c.itemCode ? `${Math.round(c.wasteFactor * 100)} %` : "—"}</td>84                  <td className="vp-mono text-right text-[12px]">{c.itemCode ? `${num(c.unitPrice, lang, 2)} $` : "—"}</td>85                  <td className="text-[12px]">{c.itemCode ? <span className="inline-flex items-center gap-1.5"><KindIcon kind={c.provenance.kind} fr={fr} source={c.provenance.source} observedAt={c.provenance.observedAt} note={c.provenance.note} /> {kindLabel(c.provenance.kind, fr)}{c.provenance.observedAt ? ` · ${c.provenance.observedAt}` : ""}</span> : <LayerBadge layer="assumption" fr={fr} />}</td>86                  <td className="vp-mono text-right text-[12px]">{c.materialCost ? `${num(c.materialCost, lang, 2)} $` : "—"}</td>87                  <td className="vp-mono text-right text-[12px]">{c.labourHours ? num(c.labourHours, lang, 3) : "—"}</td>88                  <td className="vp-mono text-right text-[12px]">{c.hourlyRate != null ? <span className="inline-flex items-center gap-1">{num(c.hourlyRate, lang, 2)} $/h <KindIcon kind={c.rateProvenance?.kind ?? "assumption"} fr={fr} source={c.rateProvenance?.source} observedAt={c.rateProvenance?.effectiveDate} note={c.rateProvenance?.note} /></span> : "—"}</td>89                  <td className="vp-mono text-right text-[12px]">{c.labourCost ? `${num(c.labourCost, lang, 2)} $` : "—"}</td>90                  <td className="vp-mono text-right text-[12px]">{c.equipmentCost ? `${num(c.equipmentCost, lang, 2)} $` : "—"}</td>91                </tr>92              ))}93              <tr className="bg-surface-2 font-bold"><td>{fr ? "Total par unité" : "Total per unit"}</td><td colSpan={4} /><td className="vp-mono text-right">{num(u.material, lang, 2)} $</td><td colSpan={2} className="vp-mono text-right">{num(u.components.reduce((s, c) => s + c.labourHours, 0), lang, 3)} h</td><td className="vp-mono text-right">{num(u.labour, lang, 2)} $</td><td className="vp-mono text-right">{num(u.equipment, lang, 2)} $</td></tr>94            </tbody>95          </table>96        </div>97        <div className="mt-3"><KindLegend fr={fr} /></div>98      </section>99100      <section className="grid gap-x-12 gap-y-10 lg:grid-cols-2">101        <div>102          <div className="sec"><span className="sec-num">02</span> <span className="kicker">{fr ? "Historique" : "History"}</span>103            <h2 className="vp-display mt-2 text-[clamp(20px,3vw,28px)] font-bold uppercase leading-[1.05] tracking-[-0.025em]">{fr ? "Coût unitaire — 8 trimestres" : "Unit cost — 8 quarters"}</h2>104            <p className="mt-2 text-[13.5px] text-ink-2">{fr ? "Recalculé avec les prix disponibles à chaque date (observé ●, indexé ○ ou référence ◇). Une base de référence stable donne une ligne plate : ce n'est pas une observation." : "Recomputed with the prices available at each date (observed ●, indexed ○ or reference ◇). A stable reference base gives a flat line: it is not an observation."}</p>105          </div>106          <div className="mt-4"><UnitCostHistory rows={data.history} lang={lang} unit={unitL(a.unit)} /></div>107        </div>108        <div>109          <div className="sec"><span className="sec-num">03</span> <span className="kicker">{fr ? "Régions" : "Regions"}</span>110            <h2 className="vp-display mt-2 text-[clamp(20px,3vw,28px)] font-bold uppercase leading-[1.05] tracking-[-0.025em]">{fr ? "Variations régionales" : "Regional variations"}</h2>111            <p className="mt-2 text-[13.5px] text-ink-2">{fr ? "Direct × facteurs matériaux / main-d'œuvre / équipement de chaque région (conventions CCQ uniformes → main-d'œuvre 1,00 hors régions éloignées)." : "Direct × material / labour / equipment factors of each region (uniform CCQ agreements → labour 1.00 outside remote regions)."}</p>112          </div>113          <div className="mt-4 space-y-1.5">114            {data.regional.map((r) => (115              <div key={r.code} className="grid grid-cols-[150px_1fr_90px] items-center gap-2 text-[12px]">116                <span className="truncate" title={fr ? r.nameFr : r.nameEn}>{fr ? r.nameFr : r.nameEn}</span>117                <span className="h-[8px] bg-[var(--line)]"><span className={`block h-full ${r.code === data.location.code ? "bg-accent" : "bg-ink"}`} style={{ width: `${(r.direct / maxRegional) * 100}%` }} /></span>118                <span className="vp-mono text-right">{num(r.direct, lang, 2)} $</span>119              </div>120            ))}121          </div>122        </div>123      </section>124125      <section>126        <div className="sec"><span className="sec-num">04</span> <span className="kicker">{fr ? "Voisins" : "Neighbours"}</span>127          <h2 className="vp-display mt-2 text-[clamp(20px,3vw,28px)] font-bold uppercase leading-[1.05] tracking-[-0.025em]">{fr ? "Assemblages de la même catégorie" : "Assemblies of the same category"}</h2>128        </div>129        <div className="mt-5 grid gap-x-8 gap-y-4 sm:grid-cols-2 lg:grid-cols-4">130          {data.neighbours.map((n) => (131            <Link key={n.code} href={`/cout/assemblage/${encodeURIComponent(n.code)}`} className="group border-t border-[var(--line)] pt-3">132              <p className="vp-mono text-[10px] uppercase tracking-[0.08em] text-ink-3">{n.code}</p>133              <p className="text-[13.5px] font-semibold leading-snug group-hover:text-accent-deep">{fr ? n.nameFr : n.nameEn}</p>134              <p className="vp-mono mt-1 text-[12px]">{num(n.direct, lang, 2)} $ / {unitL(n.unit)}</p>135            </Link>136          ))}137        </div>138        <Link href="/cout/assemblage" className="vp-mono mt-5 inline-block text-[11px] font-bold uppercase tracking-[0.08em] text-accent">{fr ? "Tout le catalogue" : "Full catalogue"} →</Link>139      </section>140141      <Drawer open={!!src} onClose={() => setSrc(null)} title={fr ? "Source" : "Source"} fr={fr}>142        {src && (143          <div className="space-y-6">144            {src.itemCode ? <PriceSourceCard fr={fr} itemName={fr ? src.nameFr : src.nameEn} unit={src.unit} price={src.provenance} locationFactor={data.location.materialFactor} locationName={fr ? data.location.nameFr : data.location.nameEn} /> : null}145            {src.rateProvenance && (146              <div>147                <p className="klabel">{fr ? "Taux de main-d'œuvre" : "Labour rate"}</p>148                <p className="vp-display mt-1 text-[16px] font-bold">{tradeLabel(src.trade, fr)} — {num(src.hourlyRate ?? 0, lang, 2)} $/h</p>149                <p className="mt-1 inline-flex items-center gap-2 text-[12.5px] text-ink-2"><KindIcon kind={src.rateProvenance.kind} fr={fr} /> {kindLabel(src.rateProvenance.kind, fr)} · {src.rateProvenance.source}{src.rateProvenance.effectiveDate ? ` · ${src.rateProvenance.effectiveDate}` : ""}</p>150                {src.rateProvenance.note && <p className="mt-1 text-[12.5px] text-ink-2">{src.rateProvenance.note}</p>}151                {src.rateProvenance.sourceUrl && <a href={src.rateProvenance.sourceUrl} target="_blank" rel="noopener noreferrer nofollow" className="vp-mono mt-1 inline-block text-[10.5px] font-bold uppercase tracking-[0.08em] text-accent">{fr ? "Document source" : "Source document"} ↗</a>}152              </div>153            )}154            {!src.itemCode && !src.rateProvenance && <p className="text-[13px] text-ink-2">{fr ? "Équipement : forfait par unité d'assemblage (hypothèse documentée)." : "Equipment: lump per assembly unit (documented assumption)."}{src.provenance.note ? ` ${src.provenance.note}` : ""}</p>}155          </div>156        )}157      </Drawer>158    </div>159  );160}161