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// 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 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