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%
31.8 KB · 284 lines tsx
Raw Blame History
1// Vrai-Prix — méthode du coût : 06 Dépréciation, 07 Terrain, 08 Résultat (tableau, fourchette, confiance, benchmarks,2// trois lectures, graphiques, exports, mode exercice), 09 Sources et transparence.3"use client";4import { useState } from "react";5import type { Condition, CostEstimate, CostInput, DepreciationInput, LandInput, PriceKind } from "@/lib/cost/types";6import { CONDITIONS, CONDITION_GROUPS, categoryLabel } from "@/lib/cost/taxonomy";7import { money, num, pct } from "@/components/avendre/fmt";8import { ConfidenceDial } from "@/components/MetricViz";9import { Block, Field, NumField, SelectField, Toggle } from "./fields";10import { CompareBars, HBarsMoney, RangeDist, StackBar, Waterfall } from "./Charts";11import { fmtDate, KindIcon, kindLabel, KindLegend, LayerBadge } from "./Provenance";1213/* ------------------------------------------------------------ 06 dépréciation */1415export function DepreciationPanel({ est, dep, fr, patchD, hide }: { est: CostEstimate | null; dep: DepreciationInput; fr: boolean; patchD: (p: Partial<DepreciationInput>) => void; hide: boolean }) {16  const lang = fr ? "fr" : "en";17  const d = est?.depreciation;18  const f = (v: number | null | undefined) => (hide ? "•••" : money(v, lang));19  const condOpts = [{ key: "", label: fr ? "— non renseignée (âge chronologique)" : "— not entered (chronological age)" }, ...CONDITIONS.map((c) => ({ key: c.key, label: `${fr ? c.fr : c.en} (âge eff. ${Math.round(c.effectiveAgeRatio * 100)} % de la vie)` }))];20  return (21    <Block num="06" title={fr ? "Dépréciation" : "Depreciation"} id="s06">22      <div className="grid grid-cols-2 gap-2 sm:max-w-md">23        {(["age_life", "components"] as const).map((m) => (24          <button key={m} type="button" aria-pressed={dep.method === m} onClick={() => patchD({ method: m })} className={`border p-3 text-left ${dep.method === m ? "border-ink bg-ink text-paper" : "border-[var(--line)] bg-surface"}`}>25            <p className="vp-display text-[13px] font-bold uppercase">{m === "age_life" ? (fr ? "Âge-vie (simple)" : "Age-life (simple)") : (fr ? "Par composante (avancée)" : "By component (advanced)")}</p>26            <p className={`mt-0.5 text-[11px] ${dep.method === m ? "text-paper/70" : "text-ink-3"}`}>{m === "age_life" ? (fr ? "% = âge effectif ÷ vie économique" : "% = effective age ÷ economic life") : (fr ? "vie et condition propres à chaque groupe" : "own life and condition per group")}</p>27          </button>28        ))}29      </div>30      <div className="grid gap-x-6 gap-y-4 sm:grid-cols-4">31        <div className="kv-cell"><p className="k">{fr ? "Âge chronologique" : "Chronological age"}</p><p className="v">{d?.chronologicalAge != null ? `${d.chronologicalAge} ${fr ? "ans" : "yrs"}` : "—"}</p></div>32        <Field label={fr ? "Âge effectif (années)" : "Effective age (years)"} hint={fr ? "Vide = âge chronologique. Une suggestion IA serait affichée comme telle." : "Empty = chronological age."} source={dep.effectiveAge != null ? "user" : "derived"} fr={fr}><NumField value={dep.effectiveAge} min={0} max={200} placeholder={d?.chronologicalAge != null ? String(d.chronologicalAge) : ""} onChange={(v) => patchD({ effectiveAge: v, effectiveAgeSource: v == null ? "derived" : "user" })} /></Field>33        <Field label={fr ? "Vie économique (années)" : "Economic life (years)"} source="assumed" fr={fr}><NumField value={dep.economicLife} min={10} max={150} onChange={(v) => patchD({ economicLife: v ?? 60 })} /></Field>34        <div className="kv-cell"><p className="k">{fr ? "Détérioration physique" : "Physical deterioration"}</p><p className="v">{d ? `${num(d.physicalPct, lang, 1)} % · ${f(d.physical)}` : "—"}</p></div>35      </div>36      {dep.method === "components" && d && (37        <div className="src-wrap">38          <table className="src-table">39            <thead><tr><th>{fr ? "Groupe" : "Group"}</th><th className="text-right">RCN</th><th className="text-right">{fr ? "Vie" : "Life"}</th><th>{fr ? "Condition" : "Condition"}</th><th className="text-right">{fr ? "Âge eff." : "Eff. age"}</th><th className="text-right">%</th><th className="text-right">{fr ? "Dépréciation" : "Depreciation"}</th><th className="text-right">{fr ? "Restant" : "Remaining"}</th></tr></thead>40            <tbody>41              {d.components.map((c) => (42                <tr key={c.conditionGroup}>43                  <td className="font-semibold">{fr ? c.labelFr : c.labelEn}<br /><span className="text-[10.5px] font-normal text-ink-3">{c.rule}</span></td>44                  <td className="vp-mono text-right">{f(c.rcn)}</td>45                  <td className="vp-mono text-right">{c.economicLife}</td>46                  <td><select value={dep.componentConditions[c.conditionGroup] ?? ""} onChange={(e) => patchD({ componentConditions: { ...dep.componentConditions, [c.conditionGroup]: (e.target.value || undefined) as Condition | undefined } })} className="vp-input !min-h-0 !py-1 text-[12px]">{condOpts.map((o) => (<option key={o.key} value={o.key}>{o.label}</option>))}</select></td>47                  <td className="vp-mono text-right">{num(c.effectiveAge, lang, 1)}</td>48                  <td className="vp-mono text-right">{num(c.depreciationPct, lang, 1)}</td>49                  <td className="vp-mono text-right text-[var(--danger)]">−{f(c.depreciation)}</td>50                  <td className="vp-mono text-right font-bold">{f(c.remaining)}</td>51                </tr>52              ))}53            </tbody>54          </table>55        </div>56      )}57      {dep.method === "components" && (58        <p className="vp-mono text-[10px] uppercase leading-relaxed tracking-[0.05em] text-ink-3">{fr ? "Règles condition → âge effectif (table component_condition_rules, hypothèses documentées) : " : "Condition → effective-age rules (component_condition_rules table, documented assumptions): "}{CONDITIONS.map((c) => `${fr ? c.fr : c.en} ${Math.round(c.effectiveAgeRatio * 100)} %`).join(" · ")}. {fr ? "Groupes : " : "Groups: "}{CONDITION_GROUPS.map((g) => `${fr ? g.fr : g.en} ${g.economicLife} ans`).join(" · ")}.</p>59      )}60      <div className="grid gap-x-12 gap-y-6 lg:grid-cols-2">61        <div>62          <div className="flex items-baseline justify-between"><p className="klabel">{fr ? "Désuétude fonctionnelle" : "Functional obsolescence"}</p><button type="button" className="vp-mono text-[10.5px] font-bold uppercase tracking-[0.08em] text-accent" onClick={() => patchD({ functional: [...dep.functional, { id: `${Date.now()}`, type: "", curable: true, costToCure: 0, valueLoss: 0, notes: "" }] })}>+ {fr ? "Ajouter une déficience" : "Add a deficiency"}</button></div>63          {dep.functional.length === 0 && <p className="mt-1 text-[12.5px] text-ink-3">{fr ? "Aucune déficience saisie. Exemples : nombre insuffisant de salles de bain, configuration dépassée, absence de garage, hauteur sous plafond, cuisine inadéquate, suramélioration, mécanique inadéquate." : "No deficiency entered. Examples: too few bathrooms, outdated layout, no garage, ceiling height, inadequate kitchen, over-improvement, inadequate mechanical."}</p>}64          <div className="mt-2 space-y-3">65            {dep.functional.map((fo, i) => {66              const upd = (p: Partial<typeof fo>) => patchD({ functional: dep.functional.map((x, j) => (j === i ? { ...x, ...p } : x)) });67              return (68                <div key={fo.id} className="grid grid-cols-2 gap-x-4 gap-y-2 border-t border-[var(--line)] pt-2 sm:grid-cols-[1.4fr_1fr_1fr_1fr_auto]">69                  <input value={fo.type} onChange={(e) => upd({ type: e.target.value })} placeholder={fr ? "Type (ex. 1 seule salle de bain)" : "Type (e.g. single bathroom)"} className="vp-input !min-h-0 !py-1 text-[13px] sm:col-span-1" />70                  <select value={fo.curable ? "c" : "i"} onChange={(e) => upd({ curable: e.target.value === "c" })} className="vp-input !min-h-0 !py-1 text-[13px]"><option value="c">{fr ? "Curable" : "Curable"}</option><option value="i">{fr ? "Incurable" : "Incurable"}</option></select>71                  <NumField value={fo.curable ? fo.costToCure : fo.valueLoss} min={0} step={500} unit="$" onChange={(v) => upd(fo.curable ? { costToCure: v ?? 0 } : { valueLoss: v ?? 0 })} />72                  <input value={fo.notes} onChange={(e) => upd({ notes: e.target.value })} placeholder={fr ? "Notes" : "Notes"} className="vp-input !min-h-0 !py-1 text-[13px]" />73                  <button type="button" onClick={() => patchD({ functional: dep.functional.filter((_, j) => j !== i) })} className="vp-mono text-[10px] uppercase text-ink-3 hover:text-[var(--danger)]">{fr ? "retirer" : "remove"}</button>74                </div>75              );76            })}77          </div>78          <p className="vp-mono mt-2 text-[10px] uppercase tracking-[0.05em] text-ink-3">{fr ? "Curable = coût de correction · Incurable = perte de valeur" : "Curable = cost to cure · Incurable = value loss"}{d ? ` · ${fr ? "total" : "total"} ${f(d.functional)}` : ""}</p>79        </div>80        <div>81          <p className="klabel">{fr ? "Désuétude externe" : "External obsolescence"}</p>82          <div className="mt-1 grid grid-cols-[140px_1fr] gap-3">83            <NumField value={dep.externalValueLoss} min={0} step={1000} unit="$" onChange={(v) => patchD({ externalValueLoss: v ?? 0 })} />84            <input value={dep.externalNote} onChange={(e) => patchD({ externalNote: e.target.value })} placeholder={fr ? "Motif (nuisance, proximité industrielle, marché faible…)" : "Reason (nuisance, industrial proximity, weak market…)"} className="vp-input text-[13px]" />85          </div>86          <p className="vp-mono mt-2 text-[10px] uppercase leading-relaxed tracking-[0.05em] text-ink-3">{fr ? "Jamais calculée automatiquement sans preuve : 0 $ par défaut." : "Never computed automatically without evidence: 0 $ by default."}</p>87          {d && !hide && (88            <div className="mt-4">89              <HBarsMoney lang={lang} rows={[{ label: fr ? "Détérioration physique" : "Physical deterioration", value: -d.physical }, { label: fr ? "Désuétude fonctionnelle" : "Functional obsolescence", value: -d.functional }, { label: fr ? "Désuétude externe" : "External obsolescence", value: -d.external }, { label: fr ? "Valeur dépréciée du bâtiment" : "Depreciated building value", value: d.depreciatedImprovementValue }]} />90            </div>91          )}92        </div>93      </div>94    </Block>95  );96}9798/* ------------------------------------------------------------------ 07 terrain */99100export function LandPanel({ input, fr, patchL }: { input: CostInput; fr: boolean; patchL: (p: Partial<LandInput>) => void }) {101  const lang = fr ? "fr" : "en";102  const l = input.land;103  const roll = input.roll?.landValue ?? null;104  return (105    <Block num="07" title={fr ? "Valeur du terrain" : "Land value"} id="s07">106      <div className="grid gap-x-10 gap-y-5 lg:grid-cols-[1fr_1.2fr]">107        <div>108          <p className="klabel">{fr ? "Valeur du terrain utilisée" : "Land value used"}</p>109          <p className={`vp-display mt-1 text-[clamp(26px,3.5vw,36px)] font-bold tracking-[-0.03em] ${l.value == null ? "text-ink-3" : ""}`}>{l.value != null ? money(l.value, lang) : (fr ? "Donnée non disponible" : "Data not available")}</p>110          <p className="vp-mono mt-1 text-[11px] uppercase tracking-[0.05em] text-ink-2">{fr ? "Source" : "Source"} : {l.source === "role" ? (fr ? `Rôle d'évaluation foncière ${l.rollYear ?? 2026} (MAMH)` : `Assessment roll ${l.rollYear ?? 2026} (MAMH)`) : l.source === "user" ? (fr ? "Saisie de l'utilisateur" : "User entry") : l.source === "market" ? (fr ? "Analyse de marché (saisie)" : "Market analysis (entered)") : l.source === "residual" ? (fr ? "Technique résiduelle (saisie)" : "Residual technique (entered)") : (fr ? "aucune" : "none")}</p>111          <p className="mt-3 border-l-2 border-[var(--amber)] pl-3 text-[13px] leading-relaxed text-ink-2">⚠ {fr ? "La valeur du terrain au rôle n'est pas nécessairement la valeur marchande actuelle du terrain : elle est fixée 18 à 24 mois avant l'entrée en vigueur du rôle et vise l'équité fiscale." : "The land value on the roll is not necessarily the land's current market value: it is set 18 to 24 months before the roll takes effect and aims at fiscal equity."}</p>112        </div>113        <div className="grid gap-4 sm:grid-cols-2">114          <Field label={fr ? "Méthode" : "Method"}>115            <SelectField value={l.source} onChange={(v) => patchL({ source: v, value: v === "role" ? roll : l.value, method: v === "role" ? (fr ? "Valeur du terrain au rôle 2026 (MAMH)" : "Land value on the 2026 roll (MAMH)") : l.method })} options={[{ key: "role" as const, label: fr ? "Conserver la valeur au rôle" : "Keep the roll value" }, { key: "user" as const, label: fr ? "Remplacer par une valeur saisie" : "Replace with an entered value" }, { key: "market" as const, label: fr ? "Comparables de terrains (saisie)" : "Land comparables (entered)" }, { key: "residual" as const, label: fr ? "Technique résiduelle (saisie)" : "Residual technique (entered)" }, { key: "none" as const, label: fr ? "Aucune (0 $)" : "None (0 $)" }].filter((o) => roll != null || o.key !== "role")} />116          </Field>117          <Field label={fr ? "Valeur ($)" : "Value ($)"}>118            <NumField value={l.value} min={0} step={1000} unit="$" disabled={l.source === "role" || l.source === "none"} onChange={(v) => patchL({ value: v })} />119          </Field>120          <Field label={fr ? "Justification / méthode" : "Rationale / method"}>121            <input value={l.method} onChange={(e) => patchL({ method: e.target.value })} className="vp-input text-[13px]" placeholder={fr ? "ex. 3 ventes de terrains vacants ajustées…" : "e.g. 3 adjusted vacant-land sales…"} disabled={l.source === "role"} />122          </Field>123          {roll != null && <div className="kv-cell"><p className="k">{fr ? "Rappel — terrain au rôle" : "Reminder — roll land value"}</p><p className="v">{money(roll, lang)}</p></div>}124        </div>125      </div>126    </Block>127  );128}129130/* ---------------------------------------------------------------- 08 résultat */131132export function ResultPanel({ est, fr, hide, exercise, onSave, saving, savedId, onRefreshPrices, structureAnswer }: { est: CostEstimate | null; fr: boolean; hide: boolean; exercise: boolean; onSave: () => void; saving: boolean; savedId: string | null; onRefreshPrices: () => void; structureAnswer: React.ReactNode }) {133  const lang = fr ? "fr" : "en";134  const [showJson, setShowJson] = useState(false);135  if (!est) return <Block num="08" title={fr ? "Résultat" : "Result"} id="s08"><p className="text-ink-2">{fr ? "Calcul en cours…" : "Computing…"}</p></Block>;136  const f = (v: number | null | undefined) => (hide ? "•••" : money(v, lang));137  const d = est.depreciation;138  const c = est.confidence;139  const o = est.otherReadings;140  const rows: [string, string, boolean?][] = [141    [fr ? "Coûts directs" : "Direct costs", f(est.directCost)],142    [fr ? "Coûts indirects" : "Indirect costs", f(est.indirectCost)],143    [fr ? "Frais généraux entrepreneur" : "Contractor overhead", f(est.contractorOverhead)],144    [fr ? "Profit entrepreneur" : "Contractor profit", f(est.contractorProfit)],145    [fr ? "Contingence" : "Contingency", f(est.contingency)],146    [fr ? "COÛT DE REMPLACEMENT À NEUF" : "REPLACEMENT COST NEW", f(est.replacementCostNew), true],147    [fr ? "Détérioration physique" : "Physical deterioration", hide ? "•••" : `−${money(d.physical, lang)}`],148    [fr ? "Désuétude fonctionnelle" : "Functional obsolescence", hide ? "•••" : `−${money(d.functional, lang)}`],149    [fr ? "Désuétude externe" : "External obsolescence", hide ? "•••" : `−${money(d.external, lang)}`],150    [fr ? "Valeur dépréciée du bâtiment" : "Depreciated building value", f(d.depreciatedImprovementValue), true],151    [fr ? "Valeur du terrain" : "Land value", f(est.landValue)],152    [fr ? "INDICATION PAR LE COÛT" : "COST INDICATION", f(est.costApproachValue), true],153  ];154  const conf = [[fr ? "Fraîcheur" : "Freshness", c.freshness, 20], [fr ? "Couverture" : "Coverage", c.coverage, 20], [fr ? "Localisation" : "Location", c.location, 15], [fr ? "Main-d'œuvre" : "Labour", c.labour, 15], [fr ? "Benchmarks" : "Benchmarks", c.benchmarks, 10], [fr ? "Bâtiment" : "Building", c.building, 20]] as [string, number, number][];155  return (156    <Block num="08" title={fr ? "Résultat" : "Result"} id="s08" aside={<span className="vp-mono text-[10px] uppercase tracking-[0.06em] text-ink-3">{fr ? "méthode" : "method"} v{est.methodVersion} · {fr ? "base" : "db"} {est.costDatabaseVersion.split("|")[0]} · {est.priceDate}</span>}>157      {exercise && structureAnswer}158      <div className="grid gap-x-12 gap-y-8 lg:grid-cols-[1.1fr_1fr]">159        <div>160          <div className="src-wrap">161            <table className="src-table">162              <tbody>163                {rows.map(([k, v, b], i) => (164                  <tr key={i} className={b ? "bg-surface-2 font-bold" : ""}><td className={b ? "vp-display uppercase tracking-[-0.01em]" : ""}>{k}</td><td className="vp-mono text-right">{v}</td></tr>165                ))}166              </tbody>167            </table>168          </div>169          <div className="mt-5 border-t-2 border-ink pt-3">170            <p className="klabel">{fr ? "Indication de valeur par la méthode du coût" : "Value indication by the cost approach"}</p>171            <p className="vp-display mt-1 text-[clamp(32px,5vw,52px)] font-bold tracking-[-0.035em]">{f(est.costApproachValue)}</p>172            <p className="vp-mono text-[11px] uppercase tracking-[0.05em] text-ink-2">RCN {hide ? "•••" : `${money(est.range.p10, lang)} – ${money(est.range.p90, lang)}`} (P10 – P90) · {hide ? "•••" : `${num(est.perSqft, lang, 0)} $/pi²`}</p>173            {est.warnings.filter((w) => w !== "land_missing" && !w.startsWith("localisation")).length > 0 && <p className="mt-2 text-[12px] text-ink-2">{est.warnings.filter((w) => w !== "land_missing" && !w.startsWith("localisation")).map((w) => ({ condo_common_areas: fr ? "Condo : quote-part des parties communes non incluse." : "Condo: share of common areas not included.", geothermal_as_heatpump: fr ? "Géothermie chiffrée comme thermopompe centrale." : "Geothermal priced as central heat pump.", hydronic_as_furnace: fr ? "Hydronique chiffré comme fournaise électrique + conduits." : "Hydronic priced as electric furnace + ducts.", wood_as_baseboard: fr ? "Chauffage au bois : plinthes d'appoint chiffrées." : "Wood heating: backup baseboards priced." } as Record<string, string>)[w] ?? w).join(" ")}</p>}174            {est.warnings.includes("land_missing") && <p className="mt-2 border-l-2 border-[var(--amber)] pl-3 text-[12.5px] text-ink-2">{fr ? "Aucune valeur de terrain : l'indication ne comprend que le bâtiment déprécié (section 07)." : "No land value: the indication only includes the depreciated building (section 07)."}</p>}175          </div>176          {!hide && <div className="mt-5"><Waterfall lang={lang} steps={[{ label: "RCN", value: est.replacementCostNew, kind: "total" }, { label: fr ? "Dépréciation" : "Depreciation", value: d.total, kind: "minus" }, { label: fr ? "Terrain" : "Land", value: est.landValue, kind: "plus" }, { label: fr ? "Indication" : "Indication", value: est.costApproachValue, kind: "total" }]} /></div>}177          <div className="mt-5 flex flex-wrap gap-2.5">178            <button type="button" onClick={onSave} disabled={saving} className="btn btn-primary">{saving ? "…" : savedId ? (fr ? "Enregistré ✓ — ré-enregistrer" : "Saved ✓ — save again") : (fr ? "Enregistrer et partager" : "Save and share")}</button>179            {savedId && <a href={`/api/cost/report?estimate=${encodeURIComponent(savedId)}`} className="btn btn-accent" target="_blank" rel="noopener">{fr ? "Télécharger le rapport PDF" : "Download PDF report"}</a>}180            {!savedId && <button type="button" onClick={onSave} disabled={saving} className="btn btn-ghost" title={fr ? "Enregistre d'abord l'estimation" : "Save the estimate first"}>{fr ? "Rapport PDF (enregistrer d'abord)" : "PDF report (save first)"}</button>}181            <button type="button" onClick={() => setShowJson((v) => !v)} className="btn btn-ghost">{showJson ? (fr ? "Masquer le JSON" : "Hide JSON") : (fr ? "Voir le JSON" : "View JSON")}</button>182            <button type="button" onClick={onRefreshPrices} className="btn btn-ghost">{fr ? "Recalculer avec les coûts d'aujourd'hui" : "Recompute with today's costs"}</button>183          </div>184          {savedId && <p className="vp-mono mt-2 text-[10px] uppercase tracking-[0.05em] text-ink-3">{fr ? "Lien partageable" : "Shareable link"} : /cout?estimate={savedId}</p>}185          {showJson && <pre className="vp-mono mt-3 max-h-[420px] overflow-auto border border-[var(--line)] bg-surface p-3 text-[10.5px] leading-snug">{JSON.stringify({ id: est.id, priceDate: est.priceDate, methodVersion: est.methodVersion, costDatabaseVersion: est.costDatabaseVersion, assemblyVersion: est.assemblyVersion, location: est.location, directCost: est.directCost, indirectCost: est.indirectCost, contractorOverhead: est.contractorOverhead, contractorProfit: est.contractorProfit, contingency: est.contingency, replacementCostNew: est.replacementCostNew, range: est.range, depreciation: est.depreciation, landValue: est.landValue, costApproachValue: est.costApproachValue, confidence: est.confidence, coverage: est.coverage, benchmarks: est.benchmarks, categories: est.categories, quantities: est.quantities, input: est.input }, null, 2)}</pre>}186        </div>187        <div className="space-y-7">188          <div>189            <p className="klabel mb-2">{fr ? "Indice de confiance" : "Confidence index"}</p>190            <div className="flex flex-wrap items-start gap-6">191              <ConfidenceDial pct={c.total} level={c.letter} label={fr ? "Confiance" : "Confidence"} />192              <div className="flex-1 min-w-[220px]">193                {conf.map(([k, v, max]) => (194                  <div key={k} className="grid grid-cols-[110px_1fr_48px] items-center gap-2 py-1 text-[12px]">195                    <span>{k}</span>196                    <span className="h-[6px] bg-[var(--line)]"><span className="block h-full bg-ink" style={{ width: `${(v / max) * 100}%` }} /></span>197                    <span className="vp-mono text-right">{v}/{max}</span>198                  </div>199                ))}200                <p className="vp-mono mt-1 text-[11px] font-bold">{c.total} / 100 · <span className={`pill pill-conf-${c.letter}`}>{c.letter}</span></p>201              </div>202            </div>203            {(fr ? c.notesFr : c.notesEn).length > 0 && <ul className="mt-2 space-y-1 text-[12px] text-ink-2">{(fr ? c.notesFr : c.notesEn).map((n) => (<li key={n} className="border-l-2 border-[var(--line-strong)] pl-2">{n}</li>))}</ul>}204            <p className="vp-mono mt-2 text-[10px] uppercase tracking-[0.05em] text-ink-3">{fr ? "couverture observée des matériaux" : "observed material coverage"} {Math.round(est.coverage.materialObservedShare * 100)} % · {est.coverage.assembliesPriced}/{est.coverage.assembliesTotal} {fr ? "assemblages chiffrés" : "assemblies priced"}</p>205          </div>206          <div>207            <p className="klabel mb-2">{fr ? "Validation externe" : "External validation"}</p>208            {est.benchmarks.length === 0 ? <p className="text-[13px] text-ink-2">{fr ? "Aucun benchmark externe disponible pour ce type et ce marché (Altus Group : import licencié requis). Le moteur n'est jamais recalé sur un benchmark." : "No external benchmark available for this type and market (Altus Group: licensed import required). The engine is never forced onto a benchmark."}</p> : (209              <div className="src-wrap"><table className="src-table"><thead><tr><th>{fr ? "Source" : "Source"}</th><th className="text-right">{fr ? "Benchmark" : "Benchmark"}</th><th className="text-right">Vrai-Prix</th><th>{fr ? "État" : "Status"}</th></tr></thead><tbody>210                {est.benchmarks.map((b, i) => (211                  <tr key={i}><td>{b.source} {b.year}<br /><span className="text-[10.5px] text-ink-3">{b.market} · {b.notes ?? ""}</span></td><td className="vp-mono text-right">{num(b.low, lang)}–{num(b.high, lang)} {b.unit}</td><td className="vp-mono text-right">{hide ? "•••" : `${num(b.estimatePerUnit, lang)} $/pi²`}</td><td className={b.status === "within" ? "text-ink" : "font-bold text-[var(--danger)]"}>{b.status === "within" ? (fr ? "Dans la plage" : "Within range") : `⚠ ${fr ? "Écart significatif" : "Significant deviation"} ${b.deviationPct != null ? pct(b.deviationPct, lang, 0) : ""}`}</td></tr>212                ))}213              </tbody></table></div>214            )}215          </div>216          <div>217            <p className="klabel mb-2">{fr ? "Trois lectures de la valeur — jamais moyennées" : "Three readings of value — never averaged"}</p>218            {o ? (219              <>220                {!hide && <CompareBars lang={lang} rows={[{ label: fr ? "Modèle hédonique" : "Hedonic model", value: o.hedonic }, { label: fr ? "Comparables" : "Comparables", value: o.comparables }, { label: fr ? "Méthode du coût" : "Cost approach", value: est.costApproachValue, accent: true }, { label: fr ? "Rôle 2026" : "Roll 2026", value: o.rollValue }, ...(o.askingPrice ? [{ label: fr ? "Prix demandé" : "Asking price", value: o.askingPrice }] : [])]} />}221                <div className="grid grid-cols-2 gap-x-6 gap-y-1 sm:grid-cols-4">222                  <div className="kv-cell"><p className="k">{fr ? "Hédonique" : "Hedonic"}</p><p className="v">{money(o.hedonic, lang)}</p></div>223                  <div className="kv-cell"><p className="k">{fr ? "Comparables" : "Comparables"}</p><p className="v">{money(o.comparables, lang)}</p></div>224                  <div className="kv-cell"><p className="k">{fr ? "Coût" : "Cost"}</p><p className="v text-accent">{f(est.costApproachValue)}</p></div>225                  <div className="kv-cell"><p className="k">{fr ? "Mesure Vrai-Prix (hybride)" : "Vrai-Prix measure (hybrid)"}</p><p className="v">{money(o.hybrid, lang)}</p></div>226                </div>227                {o.rollBuilding != null && !hide && <div className="mt-3"><p className="klabel mb-1">{fr ? "RCN vs valeur du bâtiment au rôle" : "RCN vs building value on the roll"}</p><HBarsMoney lang={lang} rows={[{ label: fr ? "RCN (à neuf)" : "RCN (new)", value: est.replacementCostNew }, { label: fr ? "Bâtiment déprécié (coût)" : "Depreciated building (cost)", value: d.depreciatedImprovementValue }, { label: fr ? "Bâtiment au rôle 2026" : "Building on roll 2026", value: o.rollBuilding }]} /></div>}228                <p className="mt-2 text-[12px] leading-relaxed text-ink-2">{fr ? "L'objectif est de comparer les approches : un écart entre le coût et le marché s'explique (dépréciation sous- ou surestimée, terrain, marché tendu), il ne se moyenne pas." : "The teaching goal is to compare approaches: a gap between cost and market is explained (under- or over-estimated depreciation, land, tight market), not averaged."}</p>229              </>230            ) : <p className="text-[13px] text-ink-2">{fr ? "Mode construction : aucune lecture hédonique ou par comparables (bâtiment hypothétique)." : "Construction mode: no hedonic or comparables reading (hypothetical building)."}</p>}231          </div>232          {!hide && (233            <div className="space-y-5">234              <div><p className="klabel mb-1">{fr ? "RCN par catégorie" : "RCN by category"}</p><HBarsMoney lang={lang} total={est.directCost} rows={est.categories.map((k) => ({ label: fr ? k.labelFr : k.labelEn, value: k.adjusted }))} /></div>235              <div><p className="klabel mb-1">{fr ? "Matériaux · main-d'œuvre · équipement" : "Materials · labour · equipment"}</p><StackBar lang={lang} parts={[{ label: fr ? "Matériaux" : "Materials", value: est.directMaterial }, { label: fr ? "Main-d'œuvre" : "Labour", value: est.directLabour }, { label: fr ? "Équipement" : "Equipment", value: est.directEquipment }]} /></div>236              <div><p className="klabel mb-1">{fr ? "Distribution du RCN (P10 · central · P90)" : "RCN distribution (P10 · central · P90)"}</p><RangeDist low={est.range.p10} central={est.replacementCostNew} high={est.range.p90} lang={lang} /></div>237            </div>238          )}239        </div>240      </div>241    </Block>242  );243}244245/* ------------------------------------------------------------------ 09 sources */246247export function SourcesPanel({ est, fr }: { est: CostEstimate | null; fr: boolean }) {248  if (!est) return null;249  const agg = new Map<string, { kind: PriceKind; n: number; latest: string | null; url: string | null }>();250  for (const l of est.lines) for (const c of l.unitDetail.components) {251    for (const p of [c.provenance, c.rateProvenance].filter((x): x is NonNullable<typeof x> => !!x)) {252      if (p.kind === "assumption" && !c.itemCode && !c.labourHours) continue;253      const k = `${p.source}|${p.kind}`;254      const cur = agg.get(k) ?? { kind: p.kind, n: 0, latest: null, url: p.sourceUrl ?? null };255      cur.n++;256      const dte = p.observedAt ?? p.effectiveDate ?? null;257      if (dte && (!cur.latest || dte > cur.latest)) cur.latest = dte;258      agg.set(k, cur);259    }260  }261  const rows = [...agg.entries()].map(([k, v]) => ({ source: k.split("|")[0], ...v })).sort((a, b) => b.n - a.n);262  return (263    <Block num="09" title={fr ? "Sources et transparence" : "Sources and transparency"} id="s09">264      <div className="flex flex-wrap gap-2">{(["observed", "ai_inferred", "computed", "sourced_price", "assumption"] as const).map((l) => <LayerBadge key={l} layer={l} fr={fr} />)}</div>265      <p className="text-[13px] leading-relaxed text-ink-2">{fr ? "Quatre couches, toujours distinguées : OBSERVÉ (rôle, annonce, fournisseur), INFÉRÉ PAR IA (profil technique d'une annonce), CALCULÉ (géométrie, quantités, formules du moteur), PRIX SOURCÉ (observation détaillant, grille APCHQ/CCQ, indice StatCan). Les hypothèses (productivités, pourcentages, prix de référence) sont étiquetées comme telles." : "Four layers, always distinguished: OBSERVED (roll, listing, supplier), AI-INFERRED (technical profile of a listing), COMPUTED (geometry, quantities, engine formulas), SOURCED PRICE (retail observation, APCHQ/CCQ grid, StatCan index). Assumptions (productivities, percentages, reference prices) are labelled as such."}</p>266      <div className="src-wrap">267        <table className="src-table">268          <thead><tr><th>{fr ? "Source" : "Source"}</th><th>{fr ? "Nature" : "Nature"}</th><th className="text-right">{fr ? "Composants" : "Components"}</th><th>{fr ? "Date la plus récente" : "Most recent date"}</th></tr></thead>269          <tbody>270            {rows.map((r) => (271              <tr key={r.source + r.kind}><td className="font-semibold">{r.url ? <a href={r.url} target="_blank" rel="noopener noreferrer nofollow" className="hover:text-accent-deep">{r.source} ↗</a> : r.source}</td><td className="inline-flex items-center gap-1.5 text-[12.5px]"><KindIcon kind={r.kind} fr={fr} /> {kindLabel(r.kind, fr)}</td><td className="vp-mono text-right">{r.n}</td><td className="vp-mono text-[12px]">{r.latest ? fmtDate(r.latest, fr) : "—"}</td></tr>272            ))}273          </tbody>274        </table>275      </div>276      <KindLegend fr={fr} />277      <p className="vp-mono text-[10px] uppercase leading-relaxed tracking-[0.05em] text-ink-3">{fr ? `Localisation : ${est.location.nameFr} (M × ${est.location.materialFactor}, MO × ${est.location.labourFactor}, É × ${est.location.equipmentFactor}) — ${est.location.sourceMethod}. Version de méthode ${est.methodVersion}, base ${est.costDatabaseVersion}, assemblages v${est.assemblyVersion}, prix au ${est.priceDate} : l'estimation est reproductible à l'identique.` : `Location: ${est.location.nameEn} (M × ${est.location.materialFactor}, L × ${est.location.labourFactor}, E × ${est.location.equipmentFactor}) — ${est.location.sourceMethod}. Method ${est.methodVersion}, database ${est.costDatabaseVersion}, assemblies v${est.assemblyVersion}, prices as of ${est.priceDate}: the estimate is exactly reproducible.`}</p>278      <p className="vp-mono text-[10px] uppercase tracking-[0.05em] text-ink-3">{fr ? "Catégories chiffrées : " : "Categories priced: "}{est.categories.map((c) => categoryLabel(c.category, fr)).join(" · ")}</p>279    </Block>280  );281}282283export { Toggle };284