// Vrai-Prix — méthode du coût : 06 Dépréciation, 07 Terrain, 08 Résultat (tableau, fourchette, confiance, benchmarks, // trois lectures, graphiques, exports, mode exercice), 09 Sources et transparence. "use client"; import { useState } from "react"; import type { Condition, CostEstimate, CostInput, DepreciationInput, LandInput, PriceKind } from "@/lib/cost/types"; import { CONDITIONS, CONDITION_GROUPS, categoryLabel } from "@/lib/cost/taxonomy"; import { money, num, pct } from "@/components/avendre/fmt"; import { ConfidenceDial } from "@/components/MetricViz"; import { Block, Field, NumField, SelectField, Toggle } from "./fields"; import { CompareBars, HBarsMoney, RangeDist, StackBar, Waterfall } from "./Charts"; import { fmtDate, KindIcon, kindLabel, KindLegend, LayerBadge } from "./Provenance"; /* ------------------------------------------------------------ 06 dépréciation */ export function DepreciationPanel({ est, dep, fr, patchD, hide }: { est: CostEstimate | null; dep: DepreciationInput; fr: boolean; patchD: (p: Partial) => void; hide: boolean }) { const lang = fr ? "fr" : "en"; const d = est?.depreciation; const f = (v: number | null | undefined) => (hide ? "•••" : money(v, lang)); 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)` }))]; return (
{(["age_life", "components"] as const).map((m) => ( ))}

{fr ? "Âge chronologique" : "Chronological age"}

{d?.chronologicalAge != null ? `${d.chronologicalAge} ${fr ? "ans" : "yrs"}` : "—"}

patchD({ effectiveAge: v, effectiveAgeSource: v == null ? "derived" : "user" })} /> patchD({ economicLife: v ?? 60 })} />

{fr ? "Détérioration physique" : "Physical deterioration"}

{d ? `${num(d.physicalPct, lang, 1)} % · ${f(d.physical)}` : "—"}

{dep.method === "components" && d && (
{d.components.map((c) => ( ))}
{fr ? "Groupe" : "Group"}RCN{fr ? "Vie" : "Life"}{fr ? "Condition" : "Condition"}{fr ? "Âge eff." : "Eff. age"}%{fr ? "Dépréciation" : "Depreciation"}{fr ? "Restant" : "Remaining"}
{fr ? c.labelFr : c.labelEn}
{c.rule}
{f(c.rcn)} {c.economicLife} {num(c.effectiveAge, lang, 1)} {num(c.depreciationPct, lang, 1)} −{f(c.depreciation)} {f(c.remaining)}
)} {dep.method === "components" && (

{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(" · ")}.

)}

{fr ? "Désuétude fonctionnelle" : "Functional obsolescence"}

{dep.functional.length === 0 &&

{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."}

}
{dep.functional.map((fo, i) => { const upd = (p: Partial) => patchD({ functional: dep.functional.map((x, j) => (j === i ? { ...x, ...p } : x)) }); return (
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" /> upd(fo.curable ? { costToCure: v ?? 0 } : { valueLoss: v ?? 0 })} /> upd({ notes: e.target.value })} placeholder={fr ? "Notes" : "Notes"} className="vp-input !min-h-0 !py-1 text-[13px]" />
); })}

{fr ? "Curable = coût de correction · Incurable = perte de valeur" : "Curable = cost to cure · Incurable = value loss"}{d ? ` · ${fr ? "total" : "total"} ${f(d.functional)}` : ""}

{fr ? "Désuétude externe" : "External obsolescence"}

patchD({ externalValueLoss: v ?? 0 })} /> patchD({ externalNote: e.target.value })} placeholder={fr ? "Motif (nuisance, proximité industrielle, marché faible…)" : "Reason (nuisance, industrial proximity, weak market…)"} className="vp-input text-[13px]" />

{fr ? "Jamais calculée automatiquement sans preuve : 0 $ par défaut." : "Never computed automatically without evidence: 0 $ by default."}

{d && !hide && (
)}
); } /* ------------------------------------------------------------------ 07 terrain */ export function LandPanel({ input, fr, patchL }: { input: CostInput; fr: boolean; patchL: (p: Partial) => void }) { const lang = fr ? "fr" : "en"; const l = input.land; const roll = input.roll?.landValue ?? null; return (

{fr ? "Valeur du terrain utilisée" : "Land value used"}

{l.value != null ? money(l.value, lang) : (fr ? "Donnée non disponible" : "Data not available")}

{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")}

⚠ {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."}

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")} /> patchL({ value: v })} /> 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"} /> {roll != null &&

{fr ? "Rappel — terrain au rôle" : "Reminder — roll land value"}

{money(roll, lang)}

}
); } /* ---------------------------------------------------------------- 08 résultat */ export 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 }) { const lang = fr ? "fr" : "en"; const [showJson, setShowJson] = useState(false); if (!est) return

{fr ? "Calcul en cours…" : "Computing…"}

; const f = (v: number | null | undefined) => (hide ? "•••" : money(v, lang)); const d = est.depreciation; const c = est.confidence; const o = est.otherReadings; const rows: [string, string, boolean?][] = [ [fr ? "Coûts directs" : "Direct costs", f(est.directCost)], [fr ? "Coûts indirects" : "Indirect costs", f(est.indirectCost)], [fr ? "Frais généraux entrepreneur" : "Contractor overhead", f(est.contractorOverhead)], [fr ? "Profit entrepreneur" : "Contractor profit", f(est.contractorProfit)], [fr ? "Contingence" : "Contingency", f(est.contingency)], [fr ? "COÛT DE REMPLACEMENT À NEUF" : "REPLACEMENT COST NEW", f(est.replacementCostNew), true], [fr ? "Détérioration physique" : "Physical deterioration", hide ? "•••" : `−${money(d.physical, lang)}`], [fr ? "Désuétude fonctionnelle" : "Functional obsolescence", hide ? "•••" : `−${money(d.functional, lang)}`], [fr ? "Désuétude externe" : "External obsolescence", hide ? "•••" : `−${money(d.external, lang)}`], [fr ? "Valeur dépréciée du bâtiment" : "Depreciated building value", f(d.depreciatedImprovementValue), true], [fr ? "Valeur du terrain" : "Land value", f(est.landValue)], [fr ? "INDICATION PAR LE COÛT" : "COST INDICATION", f(est.costApproachValue), true], ]; 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][]; return ( {fr ? "méthode" : "method"} v{est.methodVersion} · {fr ? "base" : "db"} {est.costDatabaseVersion.split("|")[0]} · {est.priceDate}}> {exercise && structureAnswer}
{rows.map(([k, v, b], i) => ( ))}
{k}{v}

{fr ? "Indication de valeur par la méthode du coût" : "Value indication by the cost approach"}

{f(est.costApproachValue)}

RCN {hide ? "•••" : `${money(est.range.p10, lang)} – ${money(est.range.p90, lang)}`} (P10 – P90) · {hide ? "•••" : `${num(est.perSqft, lang, 0)} $/pi²`}

{est.warnings.filter((w) => w !== "land_missing" && !w.startsWith("localisation")).length > 0 &&

{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)[w] ?? w).join(" ")}

} {est.warnings.includes("land_missing") &&

{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)."}

}
{!hide &&
}
{savedId && {fr ? "Télécharger le rapport PDF" : "Download PDF report"}} {!savedId && }
{savedId &&

{fr ? "Lien partageable" : "Shareable link"} : /cout?estimate={savedId}

} {showJson &&
{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)}
}

{fr ? "Indice de confiance" : "Confidence index"}

{conf.map(([k, v, max]) => (
{k} {v}/{max}
))}

{c.total} / 100 · {c.letter}

{(fr ? c.notesFr : c.notesEn).length > 0 &&
    {(fr ? c.notesFr : c.notesEn).map((n) => (
  • {n}
  • ))}
}

{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"}

{fr ? "Validation externe" : "External validation"}

{est.benchmarks.length === 0 ?

{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."}

: (
{est.benchmarks.map((b, i) => ( ))}
{fr ? "Source" : "Source"}{fr ? "Benchmark" : "Benchmark"}Vrai-Prix{fr ? "État" : "Status"}
{b.source} {b.year}
{b.market} · {b.notes ?? ""}
{num(b.low, lang)}–{num(b.high, lang)} {b.unit}{hide ? "•••" : `${num(b.estimatePerUnit, lang)} $/pi²`}{b.status === "within" ? (fr ? "Dans la plage" : "Within range") : `⚠ ${fr ? "Écart significatif" : "Significant deviation"} ${b.deviationPct != null ? pct(b.deviationPct, lang, 0) : ""}`}
)}

{fr ? "Trois lectures de la valeur — jamais moyennées" : "Three readings of value — never averaged"}

{o ? ( <> {!hide && }

{fr ? "Hédonique" : "Hedonic"}

{money(o.hedonic, lang)}

{fr ? "Comparables" : "Comparables"}

{money(o.comparables, lang)}

{fr ? "Coût" : "Cost"}

{f(est.costApproachValue)}

{fr ? "Mesure Vrai-Prix (hybride)" : "Vrai-Prix measure (hybrid)"}

{money(o.hybrid, lang)}

{o.rollBuilding != null && !hide &&

{fr ? "RCN vs valeur du bâtiment au rôle" : "RCN vs building value on the roll"}

}

{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."}

) :

{fr ? "Mode construction : aucune lecture hédonique ou par comparables (bâtiment hypothétique)." : "Construction mode: no hedonic or comparables reading (hypothetical building)."}

}
{!hide && (

{fr ? "RCN par catégorie" : "RCN by category"}

({ label: fr ? k.labelFr : k.labelEn, value: k.adjusted }))} />

{fr ? "Matériaux · main-d'œuvre · équipement" : "Materials · labour · equipment"}

{fr ? "Distribution du RCN (P10 · central · P90)" : "RCN distribution (P10 · central · P90)"}

)}
); } /* ------------------------------------------------------------------ 09 sources */ export function SourcesPanel({ est, fr }: { est: CostEstimate | null; fr: boolean }) { if (!est) return null; const agg = new Map(); for (const l of est.lines) for (const c of l.unitDetail.components) { for (const p of [c.provenance, c.rateProvenance].filter((x): x is NonNullable => !!x)) { if (p.kind === "assumption" && !c.itemCode && !c.labourHours) continue; const k = `${p.source}|${p.kind}`; const cur = agg.get(k) ?? { kind: p.kind, n: 0, latest: null, url: p.sourceUrl ?? null }; cur.n++; const dte = p.observedAt ?? p.effectiveDate ?? null; if (dte && (!cur.latest || dte > cur.latest)) cur.latest = dte; agg.set(k, cur); } } const rows = [...agg.entries()].map(([k, v]) => ({ source: k.split("|")[0], ...v })).sort((a, b) => b.n - a.n); return (
{(["observed", "ai_inferred", "computed", "sourced_price", "assumption"] as const).map((l) => )}

{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."}

{rows.map((r) => ( ))}
{fr ? "Source" : "Source"}{fr ? "Nature" : "Nature"}{fr ? "Composants" : "Components"}{fr ? "Date la plus récente" : "Most recent date"}
{r.url ? {r.source} ↗ : r.source} {kindLabel(r.kind, fr)}{r.n}{r.latest ? fmtDate(r.latest, fr) : "—"}

{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.`}

{fr ? "Catégories chiffrées : " : "Categories priced: "}{est.categories.map((c) => categoryLabel(c.category, fr)).join(" · ")}

); } export { Toggle };