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// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2/**3 * Page « Analyse IA du bâtiment » d'une annonce (§138-142, 156-157, 174-176, 206-208).4 * Avant : rien n'est analysé tant que l'utilisateur ne le demande pas.5 * Pendant : étapes du pipeline (polling léger).6 * Après : 01 photos · 02 profil · 03 construction · 04 quantités · 05 assemblages7 * · 06 coût · 07 dépréciation · 08 terrain · 09 résultat · 10 sources · 11 JSON.8 * Chaque valeur porte sa couche (OBSERVÉ / INFÉRÉ PAR IA / CALCULÉ / PRIX SOURCÉ)9 * et peut être corrigée ; le coût est recalculé sans rappeler le modèle.10 * DOM = ordre visuel, une colonne sur mobile.11 */12"use client";13import { useCallback, useEffect, useMemo, useState } from "react";14import Link from "next/link";15import { useLang } from "@/components/LangContext";16import type { AnalysisView } from "@/lib/cost/ai/service";17import type { Fact } from "@/lib/cost/ai/merge";18import { CONDITIONS, QUALITIES, categoryLabel } from "@/lib/cost/taxonomy";19import { money, num, pct } from "../fmt";20import { ConfBar, CostTable, JsonViewer, LayerBadge, Sect, Stat, layerOf } from "./ui";2122export interface AiHead {23 uid: string; address: string | null; city: string | null; propertyType: string | null; price: number; images: string[]; source: string; yearBuilt: number | null; areaSqft: number | null;24 unitId: string | null; evalEst: number | null; modelEst: number | null; compsEst: number | null; roleValue: number | null; maxImages: number; model: string;25}2627type Status = "none" | "queued" | "fetching_images" | "analyzing" | "validating" | "embedding" | "mapping_assemblies" | "pricing" | "completed" | "failed";28interface Poll { id: string; status: Status; stage: string | null; version: number; error?: string | null }2930const STEPS: { key: Status[]; fr: string; en: string }[] = [31 { key: ["queued", "fetching_images"], fr: "Analyse des photos", en: "Analysing photos" },32 { key: ["analyzing"], fr: "Extraction des caractéristiques", en: "Extracting features" },33 { key: ["validating", "embedding"], fr: "Estimation des composantes", en: "Estimating components" },34 { key: ["mapping_assemblies"], fr: "Construction du profil technique", en: "Building the technical profile" },35 { key: ["pricing"], fr: "Association aux coûts actuels", en: "Applying current costs" },36];3738const EX_KEY = (uid: string) => `vrai-prix-ai-exercice:${uid}`;3940export default function AiCostAnalysis({ head, initial }: { head: AiHead; initial: AnalysisView | null }) {41 const { lang } = useLang();42 const fr = lang === "fr";43 const [view, setView] = useState<AnalysisView | null>(initial);44 const [poll, setPoll] = useState<Poll | null>(initial && initial.status !== "completed" && initial.status !== "failed" ? { id: initial.id, status: initial.status, stage: initial.stage, version: initial.version } : null);45 const [busy, setBusy] = useState(false);46 const [err, setErr] = useState<string | null>(null);47 const [exerciseMode, setExerciseMode] = useState(false);48 const [revealed, setRevealed] = useState(true);49 const base = `/api/listings/${encodeURIComponent(head.uid)}`;5051 // polling tant qu'une analyse est en cours52 useEffect(() => {53 if (!poll) return;54 const t = setInterval(async () => {55 try {56 const r = await fetch(`${base}/ai-cost-analysis?id=${poll.id}`, { cache: "no-store" });57 const d = (await r.json()) as Poll & Partial<AnalysisView>;58 if (d.status === "completed" || d.status === "failed") { setPoll(null); setView(d as AnalysisView); if (d.status === "failed") setErr(d.error ?? "échec"); }59 else setPoll({ id: d.id, status: d.status, stage: d.stage, version: d.version });60 } catch { /* réessai au prochain tick */ }61 }, 2500);62 return () => clearInterval(t);63 }, [poll, base]);6465 const start = useCallback(async (force: boolean) => {66 setBusy(true); setErr(null);67 try {68 const r = await fetch(`${base}/ai-cost-analysis${force ? "/reanalyze" : ""}`, { method: "POST" });69 const d = await r.json();70 if (!r.ok) throw new Error(d.error ?? r.statusText);71 if (d.status === "completed" && d.reused) { const v = await (await fetch(`${base}/ai-cost-analysis?id=${d.analysisId}`, { cache: "no-store" })).json(); setView(v as AnalysisView); }72 else setPoll({ id: d.analysisId, status: d.status, stage: d.status, version: d.version });73 } catch (e) { setErr((e as Error).message); } finally { setBusy(false); }74 }, [base]);7576 const recalc = useCallback(async () => {77 if (!view) return;78 setBusy(true); setErr(null);79 try {80 const r = await fetch(`${base}/cost-estimate`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ analysisId: view.id }) });81 const d = await r.json(); if (!r.ok) throw new Error(d.error ?? r.statusText); setView(d as AnalysisView);82 } catch (e) { setErr((e as Error).message); } finally { setBusy(false); }83 }, [base, view]);8485 const override = useCallback(async (fieldPath: string, value: unknown) => {86 if (!view) return;87 setBusy(true); setErr(null);88 try {89 const r = await fetch(`${base}/ai-cost-analysis/override`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ analysisId: view.id, field_path: fieldPath, new_value: value }) });90 const d = await r.json(); if (!r.ok) throw new Error(d.error ?? r.statusText); setView(d as AnalysisView);91 } catch (e) { setErr((e as Error).message); } finally { setBusy(false); }92 }, [base, view]);9394 const completed = view?.status === "completed" && view.analysis && view.merged && view.estimate ? view : null;95 const where = [head.address, head.city].filter(Boolean).join(", ");9697 return (98 <div className="space-y-12 py-8">99 <nav className="vp-mono flex flex-wrap items-center gap-2 border-b-2 border-ink pb-2.5 text-[10.5px] uppercase tracking-[0.08em] text-ink-3">100 <Link href="/" className="text-ink-2 hover:text-accent-deep">Vrai Prix</Link><span aria-hidden="true">/</span>101 <Link href="/a-vendre" className="text-ink-2 hover:text-accent-deep">{fr ? "À vendre" : "For sale"}</Link><span aria-hidden="true">/</span>102 <Link href={`/a-vendre/${encodeURIComponent(head.uid)}`} className="truncate text-ink-2 hover:text-accent-deep">{where || head.uid}</Link><span aria-hidden="true">/</span>103 <span>{fr ? "Analyse IA du bâtiment" : "AI building analysis"}</span>104 </nav>105106 {/* ---------------- en-tête ---------------- */}107 <section className="grid gap-x-12 gap-y-6 lg:grid-cols-[1.5fr_1fr]">108 <div>109 <span className="kicker">{fr ? "Des photos à la méthode du coût" : "From photos to the cost approach"}</span>110 <h1 className="vp-display mt-3 text-[clamp(26px,4vw,44px)] font-bold uppercase leading-[1.0] tracking-[-0.03em]">{fr ? "Analyse IA du bâtiment" : "AI building analysis"}</h1>111 <p className="mt-3 max-w-2xl text-[15px] leading-relaxed text-ink-2">112 {fr ? "L'IA transforme cette annonce réelle en profil technique de bâtiment (structure, enveloppe, finitions, mécanique, condition). Les coûts actuels du marché sont ensuite appliqués composante par composante par le moteur déterministe de la méthode du coût — l'IA ne fixe jamais un prix." : "AI turns this real listing into a technical building profile (structure, envelope, finishes, mechanical, condition). Current market costs are then applied component by component by the deterministic cost engine — the AI never sets a price."}113 </p>114 <p className="vp-mono mt-3 text-[11px] uppercase tracking-[0.06em] text-ink-2">{head.propertyType ?? "—"} · {money(head.price, lang)}{head.yearBuilt ? ` · ${fr ? "constr." : "built"} ${head.yearBuilt}` : ""}{head.areaSqft ? ` · ${num(head.areaSqft, lang)} pi²` : ""} · {head.images.length} {fr ? "photos" : "photos"}</p>115 </div>116 <aside className="lg:border-l lg:border-[var(--line)] lg:pl-10">117 <p className="klabel">{fr ? "Analyse du bâtiment" : "Building analysis"}</p>118 <p className="mt-2 text-[13px] leading-relaxed text-ink-2">{fr ? "Les inférences visuelles sont des estimations. Les coûts présentés ne constituent ni une soumission d'entrepreneur ni une évaluation professionnelle certifiée (OEAQ). Photos et textes appartiennent au vendeur ou au courtier ; ils ne sont utilisés que pour cette analyse." : "Visual inferences are estimates. Costs are neither a contractor quote nor a certified professional appraisal (OEAQ). Photos and texts belong to the seller or broker and are used only for this analysis."}</p>119 <label className="mt-4 flex items-center gap-2 text-[13px]"><input type="checkbox" checked={exerciseMode} onChange={(e) => { setExerciseMode(e.target.checked); setRevealed(!e.target.checked); }} /> ✎ {fr ? "Mode exercice : analysez vous-même avant de révéler l'IA" : "Exercise mode: analyse it yourself before revealing the AI"}</label>120 </aside>121 </section>122123 {exerciseMode && <ExerciseBlock uid={head.uid} fr={fr} view={completed} revealed={revealed} onReveal={() => setRevealed(true)} />}124125 {/* ---------------- état : avant / pendant / échec ---------------- */}126 {!completed && (127 <section className="border border-[var(--line)] bg-surface p-6">128 {poll ? (129 <div>130 <p className="klabel">{fr ? "Analyse en cours" : "Analysis in progress"} · v{poll.version}</p>131 <ol className="mt-4 space-y-2">132 {STEPS.map((s, i) => {133 const idx = STEPS.findIndex((x) => x.key.includes(poll.status));134 const state = i < idx ? "done" : i === idx ? "active" : "todo";135 return (136 <li key={s.fr} className={`flex items-center gap-3 text-[14px] ${state === "todo" ? "text-ink-3" : "text-ink"}`}>137 <span className={`inline-block h-[10px] w-[10px] ${state === "done" ? "bg-ink" : state === "active" ? "animate-pulse bg-[var(--accent)]" : "border border-[var(--line-strong)]"}`} />138 {fr ? s.fr : s.en}{state === "active" ? "…" : ""}139 </li>140 );141 })}142 </ol>143 <p className="vp-mono mt-4 text-[10px] uppercase tracking-[0.05em] text-ink-3">{fr ? "Vous pouvez quitter la page : l'analyse continue côté serveur." : "You can leave the page: the analysis continues server-side."}</p>144 </div>145 ) : (146 <div className="grid gap-6 lg:grid-cols-[1fr_auto] lg:items-center">147 <div>148 <p className="klabel">{view?.status === "failed" ? (fr ? "Dernière analyse échouée" : "Last analysis failed") : (fr ? "Analyse multimodale non effectuée" : "Multimodal analysis not performed")}</p>149 {view?.status === "failed" && <p className="mt-2 border-l-2 border-[var(--danger)] pl-3 text-[13px] text-ink-2">{view.error}</p>}150 <p className="mt-2 text-[14px] text-ink-2">{fr ? `Cette analyse utilisera jusqu'à ${Math.min(head.images.length, head.maxImages)} photos (${head.images.length} disponibles) ainsi que la description, les caractéristiques structurées${head.unitId ? " et les données du rôle d'évaluation (MAMH)" : ""}.` : `This analysis will use up to ${Math.min(head.images.length, head.maxImages)} photos (${head.images.length} available), the description, the structured fields${head.unitId ? " and the assessment-roll data (MAMH)" : ""}.`}</p>151 {!head.unitId && <p className="vp-mono mt-2 text-[10.5px] uppercase tracking-[0.05em] text-[#8a5a12]">{fr ? "Annonce non jumelée au rôle : la valeur du terrain devra être saisie." : "Listing not matched to the roll: land value must be entered."}</p>}152 </div>153 <button type="button" disabled={busy || !head.images.length} onClick={() => start(view?.status === "failed")} className="btn btn-accent">{busy ? "…" : fr ? `Analyser avec ${head.model === "claude-sonnet-5" ? "Claude Sonnet 5" : head.model}` : `Analyse with ${head.model === "claude-sonnet-5" ? "Claude Sonnet 5" : head.model}`} →</button>154 </div>155 )}156 {err && <p className="mt-4 border-l-2 border-[var(--danger)] pl-3 text-[13px] text-[var(--danger)]">{err}</p>}157 </section>158 )}159160 {completed && revealed && <Results v={completed} head={head} fr={fr} busy={busy} err={err} onRecalc={recalc} onReanalyze={() => start(true)} onOverride={override} />}161 {completed && !revealed && <p className="vp-mono text-[11px] uppercase tracking-[0.06em] text-ink-3">{fr ? "Résultats de l'IA masqués — complétez l'exercice puis révélez." : "AI results hidden — complete the exercise, then reveal."}</p>}162 </div>163 );164}165166/* ================================================================ résultats */167168function Results({ v, head, fr, busy, err, onRecalc, onReanalyze, onOverride }: { v: AnalysisView; head: AiHead; fr: boolean; busy: boolean; err: string | null; onRecalc: () => void; onReanalyze: () => void; onOverride: (path: string, value: unknown) => void }) {169 const lang = fr ? "fr" : "en";170 const a = v.analysis!; const m = v.merged!; const e = v.estimate!;171 const conf = v.combined?.overall ?? Math.round((a.confidence.overall ?? 0) * 100);172 const [similar, setSimilar] = useState<{ listingUid: string; similarity: number; address: string | null; city: string | null; price: number | null; image: string | null }[] | null>(null);173 useEffect(() => {174 let alive = true;175 fetch(`/api/listings/${encodeURIComponent(head.uid)}/technical-similar?id=${v.id}`).then((r) => r.json()).then((d) => { if (alive) setSimilar(d.similar ?? []); }).catch(() => { if (alive) setSimilar([]); });176 return () => { alive = false; };177 }, [head.uid, v.id]);178 const photosFor = (ev: string[]) => a.images.filter((i) => ev.includes(i.id));179 const implicitBuilding = head.price - e.landValue;180 const gap = e.depreciation.depreciatedImprovementValue > 0 ? ((implicitBuilding - e.depreciation.depreciatedImprovementValue) / e.depreciation.depreciatedImprovementValue) * 100 : null;181182 return (183 <>184 {/* résumé */}185 <section className="border-2 border-ink bg-surface p-6">186 <div className="flex flex-wrap items-baseline justify-between gap-4">187 <div>188 <p className="klabel">{fr ? "Profil technique généré" : "Technical profile generated"} · v{v.version} · {a.metadata.model} · {a.metadata.image_count} {fr ? "photos" : "photos"}</p>189 <p className="vp-display mt-1 text-[clamp(22px,3vw,30px)] font-bold uppercase tracking-[-0.02em]">{fr ? "Confiance globale" : "Overall confidence"} : {conf} %</p>190 </div>191 <div className="flex flex-wrap gap-2">192 <button type="button" className="btn btn-ghost" disabled={busy} onClick={onRecalc}>{fr ? "Recalculer avec les coûts d'aujourd'hui" : "Recalculate with today's costs"}</button>193 <button type="button" className="btn btn-ghost" disabled={busy} onClick={onReanalyze}>{fr ? "Réanalyser le bâtiment" : "Re-analyse the building"}</button>194 <a className="btn btn-ghost" href={`/api/listings/${encodeURIComponent(head.uid)}/ai-analysis-json?id=${v.id}`}>{fr ? "Télécharger le JSON" : "Download JSON"}</a>195 {head.unitId && <Link className="btn btn-primary" href={`/cout?property=${encodeURIComponent(head.unitId)}`}>{fr ? "Voir la méthode du coût" : "Open the cost approach"} →</Link>}196 </div>197 </div>198 {v.combined && (199 <div className="mt-5 grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-3 lg:grid-cols-6">200 <Stat k={fr ? "Analyse bâtiment" : "Building analysis"} v={`${v.combined.building}`} /><Stat k={fr ? "Quantités" : "Quantities"} v={`${v.combined.quantities}`} /><Stat k={fr ? "Mapping assemblages" : "Assembly mapping"} v={`${v.combined.mapping}`} />201 <Stat k={fr ? "Données de coût" : "Cost data"} v={`${v.combined.costData}`} /><Stat k={fr ? "Localisation" : "Location"} v={`${v.combined.location}`} /><Stat k={fr ? "Globale" : "Overall"} v={`${v.combined.overall} / 100`} big />202 </div>203 )}204 <p className="vp-mono mt-4 text-[10px] uppercase tracking-[0.05em] text-ink-3">{fr ? `Prix du ${v.costSnapshotDate} · RCN ${money(e.replacementCostNew, lang)} · indication par le coût ${money(e.costApproachValue, lang)}` : `Prices as of ${v.costSnapshotDate} · RCN ${money(e.replacementCostNew, lang)} · cost approach ${money(e.costApproachValue, lang)}`}</p>205 {err && <p className="mt-3 border-l-2 border-[var(--danger)] pl-3 text-[13px] text-[var(--danger)]">{err}</p>}206 {v.conflicts.length > 0 && (207 <div className="mt-4 border-l-2 border-[var(--amber)] pl-4">208 <p className="klabel">{fr ? "Conflits détectés entre données d'annonce et analyse visuelle" : "Conflicts between listing data and visual analysis"}</p>209 <ul className="mt-1 space-y-0.5 text-[13px] text-ink-2">{v.conflicts.map((c) => <li key={c.id}><b>{c.field}</b> : {c.sourceA} = {c.valueA} · {c.sourceB} = {c.valueB} <span className="vp-mono text-[10px] uppercase text-ink-3">({c.severity})</span> — {fr ? "la source la plus forte a été conservée, sans écrasement" : "stronger source kept, nothing overwritten"}</li>)}</ul>210 </div>211 )}212 {a.privacy.people_visible || a.privacy.documents_visible || a.privacy.licence_plates_visible ? <p className="vp-mono mt-3 text-[10px] uppercase tracking-[0.05em] text-ink-3">{fr ? "Éléments personnels visibles sur certaines photos : ignorés par l'analyse (bâtiment seulement)." : "Personal elements visible on some photos: ignored by the analysis (building only)."}</p> : null}213 </section>214215 {/* 01 photos */}216 <section>217 <Sect num="01" kicker={fr ? "Preuves" : "Evidence"} title={fr ? "Photos analysées" : "Photos analysed"} />218 <p className="mt-3 text-[13px] text-ink-2">{fr ? `${a.images.length} photos retenues après dédoublonnage (identifiants photo_NN cités dans chaque inférence).` : `${a.images.length} photos kept after de-duplication (photo_NN ids cited in each inference).`}</p>219 <div className="mt-4 grid grid-cols-3 gap-2 sm:grid-cols-5 lg:grid-cols-8">220 {a.images.map((im) => (221 <figure key={im.id} className="relative aspect-[4/3] overflow-hidden border border-[var(--line)] bg-surface-2">222 {/* eslint-disable-next-line @next/next/no-img-element */}223 <img src={im.source_url} alt={im.id} loading="lazy" className="h-full w-full object-cover" />224 <figcaption className="vp-mono absolute bottom-0 left-0 bg-ink px-1 text-[9px] text-paper">{im.id} · {im.room_hint}</figcaption>225 </figure>226 ))}227 </div>228 </section>229230 {/* 02 profil technique */}231 <section>232 <Sect num="02" kicker={fr ? "Bâtiment" : "Building"} title={fr ? "Profil technique" : "Technical profile"} />233 <p className="mt-3 max-w-3xl text-[13px] text-ink-2">{fr ? "Chaque valeur indique sa couche d'origine et sa confiance. « Modifier » remplace l'inférence (l'originale est conservée pour audit) et recalcule le coût sans rappeler le modèle." : "Each value shows its source layer and confidence. “Edit” replaces the inference (the original is kept for audit) and recalculates the cost without calling the model again."}</p>234 <div className="mt-5 grid gap-x-8 gap-y-2 md:grid-cols-2">235 <FactRow fr={fr} label={fr ? "Type de bâtiment" : "Building type"} f={m.buildingType} path="buildingType" options={["detached", "semi_detached", "row", "plex", "condo", "chalet", "mobile"]} photos={photosFor} onOverride={onOverride} />236 <FactRow fr={fr} label={fr ? "Année de construction" : "Year built"} f={m.yearBuilt} path="yearBuilt" numeric photos={photosFor} onOverride={onOverride} />237 <FactRow fr={fr} label={fr ? "Étages" : "Storeys"} f={m.stories} path="stories" numeric photos={photosFor} onOverride={onOverride} />238 <FactRow fr={fr} label={fr ? "Aire d'étages (pi², hors sous-sol)" : "Gross floor area (sq ft, excl. basement)"} f={m.grossFloorAreaSqft} path="grossFloorAreaSqft" numeric photos={photosFor} onOverride={onOverride} />239 <FactRow fr={fr} label={fr ? "Qualité générale" : "Overall quality"} f={m.quality} path="quality" options={QUALITIES.map((q) => q.key)} photos={photosFor} onOverride={onOverride} />240 <FactRow fr={fr} label={fr ? "Sous-sol" : "Basement"} f={m.basement} path="basement" options={["none", "crawl", "unfinished", "partial", "finished", "walkout"]} photos={photosFor} onOverride={onOverride} />241 <FactRow fr={fr} label={fr ? "Sous-sol fini (part)" : "Finished basement (share)"} f={m.basementFinishedPct} path="basementFinishedPct" numeric fmt={(x) => pct(Number(x) * 100, lang, 0, false)} photos={photosFor} onOverride={onOverride} />242 <FactRow fr={fr} label={fr ? "Garage" : "Garage"} f={m.garage} path="garage.type" options={["none", "attached", "detached", "integrated", "carport"]} fmt={(g) => { const x = g as { type: string; spaces: number }; return x.type === "none" ? (fr ? "aucun" : "none") : `${x.type} · ${x.spaces} ${fr ? "place(s)" : "space(s)"}`; }} photos={photosFor} onOverride={onOverride} />243 <FactRow fr={fr} label={fr ? "Cuisines × qualité" : "Kitchens × quality"} f={m.kitchenQuality} path="kitchenQuality" options={QUALITIES.map((q) => q.key)} fmt={(x) => `${m.kitchens.value} × ${x}`} photos={photosFor} onOverride={onOverride} />244 <FactRow fr={fr} label={fr ? "Salles de bain" : "Bathrooms"} f={m.bathrooms} path="bathrooms" numeric photos={photosFor} onOverride={onOverride} />245 <FactRow fr={fr} label={fr ? "Salles d'eau" : "Powder rooms"} f={m.powderRooms} path="powderRooms" numeric photos={photosFor} onOverride={onOverride} />246 <FactRow fr={fr} label={fr ? "Qualité des salles de bain" : "Bathroom quality"} f={m.bathroomQuality} path="bathroomQuality" options={QUALITIES.map((q) => q.key)} photos={photosFor} onOverride={onOverride} />247 <FactRow fr={fr} label={fr ? "Chauffage" : "Heating"} f={m.heating} path="heating" options={["electric_baseboard", "heat_pump", "furnace_electric", "furnace_gas", "furnace_oil", "hydronic", "geothermal", "wood"]} photos={photosFor} onOverride={onOverride} />248 <FactRow fr={fr} label={fr ? "Climatisation · échangeur d'air" : "A/C · air exchanger"} f={m.hasAirConditioning} path="hasAirConditioning" options={["true", "false"]} fmt={(x) => `${x ? (fr ? "clim. oui" : "A/C yes") : (fr ? "clim. non" : "A/C no")} · ${m.hasAirExchanger.value ? "VRC" : fr ? "sans VRC" : "no HRV"}`} photos={photosFor} onOverride={(p, val) => onOverride(p, val === "true")} />249 <FactRow fr={fr} label={fr ? "Planchers" : "Flooring"} f={m.flooring} fmt={(x) => Object.entries(x as Record<string, number>).map(([k, s]) => `${k} ${Math.round(s * 100)} %`).join(", ") || "—"} photos={photosFor} onOverride={onOverride} />250 <FactRow fr={fr} label={fr ? "Terrasse (pi²)" : "Deck (sq ft)"} f={m.deckSqft} path="deckSqft" numeric photos={photosFor} onOverride={onOverride} />251 <FactRow fr={fr} label={fr ? "Entrée" : "Driveway"} f={m.driveway} path="driveway" options={["asphalt", "pavers", "gravel", "concrete", "none"]} fmt={(x) => `${x} · ${num(m.drivewaySqft.value, lang)} pi²`} photos={photosFor} onOverride={onOverride} />252 <FactRow fr={fr} label={fr ? "Piscine" : "Pool"} f={m.pool} path="pool" options={["none", "above_ground", "inground"]} photos={photosFor} onOverride={onOverride} />253 </div>254 {a.renovations.length > 0 && (255 <div className="mt-5">256 <p className="klabel">{fr ? "Rénovations probables (estimation IA)" : "Likely renovations (AI estimate)"}</p>257 <ul className="mt-1 grid gap-1 text-[13px] text-ink-2 sm:grid-cols-2">{a.renovations.map((r, i) => <li key={i}>{r.component} — {fr ? "probabilité" : "likelihood"} {Math.round(r.renovation_likelihood * 100)} %{r.estimated_renovation_age_range.length === 2 ? ` · ${r.estimated_renovation_age_range[0]}–${r.estimated_renovation_age_range[1]} ${fr ? "ans" : "yrs"}` : ""} <span className="vp-mono text-[10px] text-ink-3">{r.evidence.join(", ")}</span></li>)}</ul>258 </div>259 )}260 </section>261262 {/* 03 construction */}263 <section>264 <Sect num="03" kicker={fr ? "Composantes" : "Components"} title={fr ? "Construction et enveloppe" : "Construction and envelope"} />265 <div className="mt-5 grid gap-x-8 gap-y-2 md:grid-cols-2">266 <FactRow fr={fr} label={fr ? "Structure" : "Structure"} f={m.structure} path="structure" options={["wood_frame", "steel", "concrete", "log", "masonry"]} photos={photosFor} onOverride={onOverride} />267 <FactRow fr={fr} label={fr ? "Fondation" : "Foundation"} f={m.foundation} path="foundation" options={["poured_concrete", "concrete_block", "slab_on_grade", "piers", "stone"]} photos={photosFor} onOverride={onOverride} />268 <FactRow fr={fr} label={fr ? "Revêtement extérieur" : "Exterior cladding"} f={m.siding} fmt={(x) => Object.entries(x as Record<string, number>).sort((p, q) => q[1] - p[1]).map(([k, s]) => `${Math.round(s * 100)} % ${k.replace(/_/g, " ")}`).join(" + ")} photos={photosFor} onOverride={onOverride} />269 <FactRow fr={fr} label={fr ? "Toiture" : "Roof covering"} f={m.roof} path="roof" options={["asphalt_shingle", "metal", "membrane", "cedar", "slate_tile"]} photos={photosFor} onOverride={onOverride} />270 <FactRow fr={fr} label={fr ? "Géométrie du toit · pente" : "Roof geometry · pitch"} f={m.roofGeometry} path="roofGeometry" options={["gable", "hip", "flat", "mansard", "complex"]} fmt={(x) => `${x} · ${m.roofPitch.value}/12`} photos={photosFor} onOverride={onOverride} />271 <FactRow fr={fr} label={fr ? "Fenêtres" : "Windows"} f={m.windows} path="windows" options={["pvc", "hybrid", "aluminum", "wood"]} fmt={(x) => `${x}${m.windowCount.value ? ` · ${m.windowCount.value} ${fr ? "fenêtres (IA)" : "windows (AI)"}` : ""}`} photos={photosFor} onOverride={onOverride} />272 </div>273 <div className="mt-6 grid gap-x-8 gap-y-2 md:grid-cols-3">274 {(["heat_source", "heat_distribution", "heat_pump", "air_conditioning", "air_exchanger", "water_heater"] as const).map((k) => <MechRow key={k} fr={fr} label={k.replace(/_/g, " ")} m={a.mechanical[k]} />)}275 {(["panel_type", "service_type", "ev_charger"] as const).map((k) => <MechRow key={k} fr={fr} label={k.replace(/_/g, " ")} m={a.electrical[k]} />)}276 {(["water_supply", "waste_system"] as const).map((k) => <MechRow key={k} fr={fr} label={k.replace(/_/g, " ")} m={a.plumbing[k]} />)}277 </div>278 {a.uncertainties.length > 0 && <div className="mt-5 border-l-2 border-[var(--line-strong)] pl-4"><p className="klabel">{fr ? "Incertitudes signalées par le modèle" : "Uncertainties reported by the model"}</p><ul className="mt-1 list-disc pl-4 text-[13px] text-ink-2">{a.uncertainties.map((u, i) => <li key={i}>{u}</li>)}</ul></div>}279 </section>280281 {/* 04 quantités */}282 <section>283 <Sect num="04" kicker={fr ? "Prise de quantités" : "Quantity take-off"} title={fr ? "Quantités" : "Quantities"} />284 <p className="mt-3 max-w-3xl text-[13px] text-ink-2">{fr ? "Quantité estimée à partir des données et images disponibles. Les surfaces (murs, toit, fondations, gypse, planchers) sont CALCULÉES par le moteur de géométrie à partir de l'aire d'étages et du nombre d'étages ; l'IA ne fournit que ce que le code ne peut pas dériver." : "Quantities estimated from available data and images. Areas (walls, roof, foundations, drywall, floors) are COMPUTED by the geometry engine from floor area and storeys; the AI only provides what code cannot derive."}</p>285 <div className="mt-4 grid grid-cols-2 gap-x-6 gap-y-3 md:grid-cols-5">286 <Stat k={fr ? "Empreinte" : "Footprint"} v={`${num(a.derived_geometry.footprint_sqft, lang)} pi²`} sub={fr ? "calculé" : "computed"} />287 <Stat k={fr ? "Périmètre exposé" : "Exposed perimeter"} v={`${num(a.derived_geometry.exposed_perimeter_ft, lang)} pi`} sub={fr ? "calculé" : "computed"} />288 <Stat k={fr ? "Murs extérieurs nets" : "Net exterior walls"} v={`${num(a.derived_geometry.net_wall_sqft, lang)} pi²`} sub={fr ? "calculé" : "computed"} />289 <Stat k={fr ? "Toit" : "Roof"} v={`${num(a.derived_geometry.roof_sqft, lang)} pi²`} sub={fr ? "calculé" : "computed"} />290 <Stat k={fr ? "Fenêtres" : "Windows"} v={`${a.derived_geometry.window_count}`} sub={m.windowCount.value ? (fr ? "IA" : "AI") : fr ? "calculé" : "computed"} />291 </div>292 <div className="src-wrap mt-5">293 <table className="src-table">294 <thead><tr><th>{fr ? "Quantité IA" : "AI quantity"}</th><th className="text-right">{fr ? "Valeur" : "Value"}</th><th>{fr ? "Confiance" : "Confidence"}</th><th>{fr ? "Méthode / preuves" : "Method / evidence"}</th><th>{fr ? "Utilisée ?" : "Used?"}</th></tr></thead>295 <tbody>296 {Object.entries(a.estimated_quantities).map(([k, qv]) => (297 <tr key={k}><td>{k.replace(/_/g, " ")}</td><td className="vp-mono text-right">{qv.value == null ? "—" : `${num(qv.value, lang, 1)} ${qv.unit}`}</td><td><ConfBar value={qv.confidence} fr={fr} /></td><td className="text-[12px] text-ink-2">{qv.method ?? "—"} <span className="vp-mono text-[10px] text-ink-3">{qv.evidence.join(", ")}</span></td><td className="vp-mono text-[10px] uppercase">{qv.value != null && qv.confidence >= 0.6 ? (fr ? "oui (≥ 0,6)" : "yes (≥ 0.6)") : fr ? "non → moteur" : "no → engine"}</td></tr>298 ))}299 </tbody>300 </table>301 </div>302 </section>303304 {/* 05 assemblages */}305 <section>306 <Sect num="05" kicker={fr ? "Mapping" : "Mapping"} title={fr ? "Assemblages retenus" : "Assemblies used"} />307 <p className="mt-3 max-w-3xl text-[13px] text-ink-2">{fr ? `${e.lines.length} assemblages du catalogue, quantifiés par le moteur (ou par l'IA quand la confiance ≥ 0,6). Les façades mixtes sont pondérées (ex. 60 % brique / 40 % vinyle).` : `${e.lines.length} catalogue assemblies, quantified by the engine (or by the AI when confidence ≥ 0.6). Mixed façades are weighted (e.g. 60 % brick / 40 % vinyl).`}</p>308 <div className="src-wrap mt-4">309 <table className="src-table">310 <thead><tr><th>{fr ? "Assemblage" : "Assembly"}</th><th className="text-right">{fr ? "Quantité" : "Quantity"}</th><th>{fr ? "Origine" : "Source"}</th><th className="text-right">{fr ? "Coût unitaire" : "Unit cost"}</th><th className="text-right">{fr ? "Coût" : "Cost"}</th><th>{fr ? "Formule" : "Formula"}</th></tr></thead>311 <tbody>312 {e.lines.map((l) => (313 <tr key={l.assemblyCode}><td><Link href={`/cout/assemblage/${encodeURIComponent(l.assemblyCode)}`} className="hover:text-accent-deep">{fr ? l.nameFr : l.nameEn}</Link><span className="vp-mono ml-2 text-[10px] text-ink-3">{categoryLabel(l.category, fr)}</span></td><td className="vp-mono text-right">{num(l.quantity, lang, 1)} {l.unit}</td><td><LayerBadge layer={layerOf(l.quantitySource)} fr={fr} /></td><td className="vp-mono text-right">{num(l.unitCost, lang, 2)} $</td><td className="vp-mono text-right">{money(l.adjusted, lang)}</td><td className="text-[11.5px] text-ink-2">{l.quantityFormula}</td></tr>314 ))}315 </tbody>316 </table>317 </div>318 </section>319320 {/* 06 coût */}321 <section>322 <Sect num="06" kicker={fr ? "Coûts actuels" : "Current costs"} title={fr ? "Coût de remplacement à neuf" : "Replacement cost new"} />323 <div className="mt-5"><CostTable e={e} fr={fr} /></div>324 </section>325326 {/* 07 dépréciation */}327 <section>328 <Sect num="07" kicker={fr ? "Dépréciation" : "Depreciation"} title={fr ? "Condition observée → âge effectif → dépréciation" : "Observed condition → effective age → depreciation"} />329 <p className="mt-3 max-w-3xl text-[13px] text-ink-2">{fr ? "L'IA propose une condition par composante (INFÉRÉ PAR IA) ; les règles métier (table component_condition_rules) la transforment en âge effectif = ratio × vie économique ; le moteur calcule les dollars (CALCULÉ). L'IA ne calcule jamais la dépréciation en dollars." : "The AI proposes a condition per component (AI-INFERRED); business rules (component_condition_rules) turn it into an effective age = ratio × economic life; the engine computes the dollars (COMPUTED). The AI never computes depreciation dollars."}</p>330 <div className="mt-4 grid grid-cols-2 gap-x-6 gap-y-3 md:grid-cols-4">331 <Stat k={fr ? "Âge chronologique" : "Chronological age"} v={e.depreciation.chronologicalAge != null ? `${e.depreciation.chronologicalAge} ${fr ? "ans" : "yrs"}` : "—"} />332 <Stat k={fr ? "Âge effectif (suggestion IA)" : "Effective age (AI suggestion)"} v={a.estimated_effective_age.effective_age != null ? `${a.estimated_effective_age.effective_age} ${fr ? "ans" : "yrs"}` : "—"} sub={`${fr ? "confiance" : "confidence"} ${Math.round(a.estimated_effective_age.confidence * 100)} %`} />333 <Stat k={fr ? "Vie économique" : "Economic life"} v={`${e.depreciation.economicLife} ${fr ? "ans" : "yrs"}`} />334 <Stat k={fr ? "Détérioration physique" : "Physical deterioration"} v={pct(e.depreciation.physicalPct, lang, 1, false)} sub={money(e.depreciation.physical, lang)} />335 </div>336 {a.estimated_effective_age.reasoning_summary.length > 0 && <ul className="mt-3 flex flex-wrap gap-2">{a.estimated_effective_age.reasoning_summary.map((r, i) => <li key={i} className="vp-mono border border-[var(--line)] px-2 py-0.5 text-[10.5px] text-ink-2">{r}</li>)}</ul>}337 <div className="src-wrap mt-5">338 <table className="src-table">339 <thead><tr><th>{fr ? "Composante" : "Component"}</th><th className="text-right">RCN</th><th>{fr ? "Condition (IA)" : "Condition (AI)"}</th><th className="text-right">{fr ? "Vie" : "Life"}</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></tr></thead>340 <tbody>341 {e.depreciation.components.map((c) => (342 <tr key={c.conditionGroup}>343 <td>{fr ? c.labelFr : c.labelEn}</td><td className="vp-mono text-right">{money(c.rcn, lang)}</td>344 <td>345 <select className="vp-input !py-0.5 text-[12px]" value={c.condition ?? ""} onChange={(ev) => onOverride(`conditions.${c.conditionGroup}`, ev.target.value)} disabled={busy}>346 <option value="">{fr ? "— (âge chronologique)" : "— (chronological age)"}</option>347 {CONDITIONS.map((o) => <option key={o.key} value={o.key}>{fr ? o.fr : o.en}</option>)}348 </select>349 </td>350 <td className="vp-mono text-right">{c.economicLife}</td><td className="vp-mono text-right">{num(c.effectiveAge, lang, 1)}</td><td className="vp-mono text-right">{num(c.depreciationPct, lang, 1)} %</td><td className="vp-mono text-right text-[var(--danger)]">−{money(c.depreciation, lang)}</td>351 </tr>352 ))}353 </tbody>354 </table>355 </div>356 </section>357358 {/* 08 terrain */}359 <section>360 <Sect num="08" kicker={fr ? "Terrain" : "Land"} title={fr ? "Valeur du terrain utilisée" : "Land value used"} />361 <div className="mt-4 grid gap-x-8 gap-y-3 md:grid-cols-[auto_1fr]">362 <p className="vp-display text-[36px] font-bold tracking-[-0.03em]">{e.landValue ? money(e.landValue, lang) : fr ? "Donnée non disponible" : "Not available"}</p>363 <div className="text-[13.5px] text-ink-2">364 <p><b>{fr ? "Source" : "Source"}</b> : {e.input.land.source === "role" ? (fr ? "Rôle d'évaluation foncière 2026 (MAMH), via le jumelage de l'annonce à son unité d'évaluation." : "2026 assessment roll (MAMH), via the listing's match to its assessment unit.") : fr ? "Annonce non jumelée au rôle — saisir la valeur du terrain dans l'atelier Coût." : "Listing not matched to the roll — enter the land value in the Cost workbench."} <LayerBadge layer={e.input.land.source === "role" ? "observed" : "assumption"} fr={fr} /></p>365 <p className="mt-2 border-l-2 border-[var(--amber)] pl-3">{fr ? "⚠ La valeur du terrain au rôle n'est pas nécessairement la valeur marchande actuelle du terrain." : "⚠ The land value on the roll is not necessarily the land's current market value."}</p>366 </div>367 </div>368 </section>369370 {/* 09 résultat */}371 <section>372 <Sect num="09" kicker={fr ? "Résultat" : "Result"} title={fr ? "Indication par la méthode du coût et croisements" : "Cost approach indication and cross-checks"} />373 <div className="mt-5 grid gap-x-12 gap-y-8 lg:grid-cols-[1fr_1fr]">374 <div className="src-wrap">375 <table className="src-table">376 <tbody>377 <tr><td>{fr ? "Prix demandé" : "Asking price"}</td><td className="vp-mono text-right">{money(head.price, lang)}</td></tr>378 <tr><td>{fr ? "Terrain" : "Land"}</td><td className="vp-mono text-right">{money(e.landValue, lang)}</td></tr>379 <tr><td>{fr ? "Valeur implicite du bâtiment (prix − terrain)" : "Implicit building value (price − land)"}</td><td className="vp-mono text-right">{money(implicitBuilding, lang)}</td></tr>380 <tr><td>RCN</td><td className="vp-mono text-right">{money(e.replacementCostNew, lang)}</td></tr>381 <tr><td>{fr ? "Valeur bâtiment dépréciée" : "Depreciated building value"}</td><td className="vp-mono text-right">{money(e.depreciation.depreciatedImprovementValue, lang)}</td></tr>382 <tr className="font-bold border-t-2 border-ink"><td>{fr ? "Indication par le coût" : "Cost approach indication"}</td><td className="vp-mono text-right">{money(e.costApproachValue, lang)}</td></tr>383 <tr><td>{fr ? "Écart relatif (bâtiment implicite vs déprécié)" : "Relative gap (implicit vs depreciated building)"}</td><td className="vp-mono text-right">{gap == null ? "—" : pct(gap, lang, 1)}</td></tr>384 </tbody>385 </table>386 <p className="vp-mono mt-2 text-[10px] uppercase tracking-[0.05em] text-ink-3">{fr ? "Aucune conclusion automatique de sur- ou sous-évaluation : la comparaison des approches est l'objet de l'analyse." : "No automatic over/under-valuation conclusion: comparing approaches is the teaching goal."}</p>387 </div>388 <ReadingsChart fr={fr} rows={[389 { label: fr ? "Hédonique" : "Hedonic", v: e.otherReadings?.hedonic ?? head.modelEst },390 { label: fr ? "Comparables" : "Comparables", v: e.otherReadings?.comparables ?? head.compsEst },391 { label: fr ? "Hybride Vrai-Prix" : "Vrai-Prix hybrid", v: e.otherReadings?.hybrid ?? head.evalEst },392 { label: fr ? "Méthode du coût" : "Cost approach", v: e.costApproachValue },393 { label: fr ? "Prix demandé" : "Asking price", v: head.price },394 { label: fr ? "Rôle 2026" : "2026 roll", v: e.otherReadings?.rollValue ?? head.roleValue },395 ]} />396 </div>397 {similar && similar.length > 0 && (398 <div className="mt-8">399 <p className="klabel">{fr ? "Propriétés de construction similaire (vecteur technique — comparables TECHNIQUES, pas de marché)" : "Technically similar properties (technical vector — TECHNICAL comparables, not market)"}</p>400 <div className="mt-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">401 {similar.map((s) => (402 <Link key={s.listingUid} href={`/a-vendre/${encodeURIComponent(s.listingUid)}/analyse-cout`} className="flex gap-3 border border-[var(--line)] bg-surface p-2 hover:border-[var(--line-strong)]">403 {/* eslint-disable-next-line @next/next/no-img-element */}404 {s.image ? <img src={s.image} alt="" className="h-16 w-20 object-cover" /> : <div className="h-16 w-20 bg-surface-2" />}405 <div className="min-w-0 text-[12.5px]"><p className="truncate font-bold">{s.address ?? s.listingUid}</p><p className="text-ink-2">{s.city} · {s.price ? money(s.price, lang) : "—"}</p><p className="vp-mono text-[10px] text-ink-3">{fr ? "similarité" : "similarity"} {Math.round(s.similarity * 100)} %</p></div>406 </Link>407 ))}408 </div>409 </div>410 )}411 {similar && similar.length === 0 && <p className="vp-mono mt-6 text-[10px] uppercase tracking-[0.05em] text-ink-3">{fr ? "Aucune autre annonce analysée de type et de taille comparables pour l'instant." : "No other analysed listing of comparable type and size yet."}</p>}412 </section>413414 {/* 10 sources */}415 <section>416 <Sect num="10" kicker={fr ? "Provenance" : "Provenance"} title={fr ? "Sources" : "Sources"} />417 <dl className="mt-4 grid gap-x-8 gap-y-2 text-[13.5px] sm:grid-cols-2">418 {[419 [fr ? "Photos" : "Photos", fr ? `annonce immobilière (${a.metadata.listing_source}), ${a.metadata.image_count} photos analysées` : `real-estate listing (${a.metadata.listing_source}), ${a.metadata.image_count} photos analysed`],420 [fr ? "Caractéristiques" : "Characteristics", fr ? `annonce${head.unitId ? " + rôle d'évaluation MAMH" : ""}` : `listing${head.unitId ? " + MAMH assessment roll" : ""}`],421 [fr ? "Analyse du bâtiment" : "Building analysis", `${a.metadata.model} · prompt ${a.metadata.prompt_version} · ${fr ? "schéma" : "schema"} ${a.metadata.schema_version}`],422 [fr ? "Main-d'œuvre" : "Labour", "APCHQ (coût horaire employeur, secteur résidentiel léger) · CCQ (conventions)"],423 [fr ? "Matériaux" : "Materials", "Canac · BMR · Patrick Morin (prix affichés) · prix de référence internes étiquetés « hypothèse »"],424 [fr ? "Indices" : "Indices", "Statistique Canada 18-10-0289 (indices des prix de la construction de bâtiments)"],425 [fr ? "Benchmarks" : "Benchmarks", e.benchmarks.length ? e.benchmarks.map((b) => `${b.source} ${b.year} : ${b.low}–${b.high} ${b.unit} (${b.status})`).join(" · ") : fr ? "aucun benchmark externe disponible pour ce type/marché" : "no external benchmark available for this type/market"],426 [fr ? "Géométrie et quantités" : "Geometry and quantities", fr ? "moteur Vrai-Prix (formules affichées en 05)" : "Vrai-Prix engine (formulas shown in 05)"],427 [fr ? "Instantané des prix" : "Price snapshot", `${v.costSnapshotDate} · ${fr ? "base" : "database"} ${e.costDatabaseVersion}`],428 ].map(([k, val]) => <div key={k} className="border-t border-[var(--line)] pt-1.5"><dt className="vp-mono text-[9.5px] uppercase tracking-[0.1em] text-ink-3">{k}</dt><dd className="text-ink-2">{val}</dd></div>)}429 </dl>430 {v.usage && typeof v.usage.inputTokens === "number" && <p className="vp-mono mt-4 text-[10px] uppercase tracking-[0.05em] text-ink-3">{fr ? "Usage modèle" : "Model usage"} : {num(v.usage.inputTokens as number, lang)} {fr ? "jetons entrée" : "input tokens"} · {num(v.usage.outputTokens as number, lang)} {fr ? "sortie" : "output"} · {num((v.usage.latencyMs as number) / 1000, lang, 0)} s</p>}431 </section>432433 {/* 11 JSON */}434 <section>435 <Sect num="11" kicker={fr ? "Données" : "Data"} title={fr ? "JSON technique" : "Technical JSON"} />436 <p className="mt-3 text-[13px] text-ink-2">{fr ? "Le profil complet (sections metadata, listing, property, geometry, construction, exterior, interior, kitchens, bathrooms, mechanical, electrical, plumbing, basement, garage, exterior_improvements, quality, condition, estimated_effective_age, estimated_quantities, assemblies, uncertainties, evidence, confidence) et le JSON compact du moteur." : "The full profile (metadata, listing, property, geometry, construction, exterior, interior, kitchens, bathrooms, mechanical, electrical, plumbing, basement, garage, exterior_improvements, quality, condition, estimated_effective_age, estimated_quantities, assemblies, uncertainties, evidence, confidence) and the engine's compact JSON."}</p>437 <div className="mt-4 flex flex-wrap gap-3"><JsonViewer value={{ ...a, compact_for_cost_engine: v.compact }} fr={fr} /><a className="btn btn-ghost" href={`/api/listings/${encodeURIComponent(head.uid)}/ai-analysis-json?id=${v.id}`}>{fr ? "Télécharger JSON" : "Download JSON"}</a></div>438 {v.versions.length > 1 && <p className="vp-mono mt-4 text-[10px] uppercase tracking-[0.05em] text-ink-3">{fr ? "Versions" : "Versions"} : {v.versions.map((x) => `v${x.version} ${x.status}`).join(" · ")}</p>}439 </section>440 </>441 );442}443444/* ================================================================ lignes de faits */445446function FactRow<T>({ fr, label, f, path, options, numeric, fmt, photos, onOverride }: { fr: boolean; label: string; f: Fact<T>; path?: string; options?: string[]; numeric?: boolean; fmt?: (v: T) => string; photos: (ev: string[]) => { id: string; source_url: string }[]; onOverride: (p: string, v: unknown) => void }) {447 const [edit, setEdit] = useState(false);448 const [showPhotos, setShowPhotos] = useState(false);449 const [val, setVal] = useState<string>(f.value == null ? "" : typeof f.value === "object" ? "" : String(f.value));450 const shown = f.value == null ? "—" : fmt ? fmt(f.value) : typeof f.value === "object" ? JSON.stringify(f.value) : String(f.value).replace(/_/g, " ");451 const evPhotos = photos(f.evidence);452 return (453 <div className="border-t border-[var(--line)] py-2">454 <div className="flex flex-wrap items-center justify-between gap-2">455 <p className="vp-mono text-[9.5px] uppercase tracking-[0.1em] text-ink-3">{label}</p>456 <div className="flex items-center gap-2"><LayerBadge layer={layerOf(f.source)} fr={fr} /><ConfBar value={f.confidence} fr={fr} /></div>457 </div>458 <div className="mt-1 flex flex-wrap items-center gap-3">459 <p className="vp-display text-[15px] font-bold">{shown}</p>460 {evPhotos.length > 0 && <button type="button" className="vp-mono text-[10px] uppercase tracking-[0.06em] text-accent" onClick={() => setShowPhotos((s) => !s)}>{fr ? `Voir les ${evPhotos.length} photos utilisées` : `See the ${evPhotos.length} photos used`}</button>}461 {path && (options || numeric) && <button type="button" className="vp-mono text-[10px] uppercase tracking-[0.06em] text-ink-2 underline" onClick={() => setEdit((s) => !s)}>{fr ? "Modifier" : "Edit"}</button>}462 {f.alternatives.length > 0 && <span className="vp-mono text-[10px] text-ink-3" title={f.alternatives.map((alt) => `${alt.source}: ${JSON.stringify(alt.value)}`).join("\n")}>{fr ? `${f.alternatives.length} autre(s) source(s)` : `${f.alternatives.length} other source(s)`}</span>}463 </div>464 {edit && path && (465 <form className="mt-2 flex flex-wrap items-center gap-2" onSubmit={(ev) => { ev.preventDefault(); onOverride(path, numeric ? Number(val) : val); setEdit(false); }}>466 {options ? <select className="vp-input !py-1 text-[13px]" value={val} onChange={(ev) => setVal(ev.target.value)}><option value="">—</option>{options.map((o) => <option key={o} value={o}>{o.replace(/_/g, " ")}</option>)}</select> : <input className="vp-input !py-1 w-32 text-[13px]" type="number" step="any" value={val} onChange={(ev) => setVal(ev.target.value)} />}467 <button type="submit" className="btn btn-primary !py-1 text-[12px]" disabled={val === ""}>{fr ? "Appliquer et recalculer" : "Apply and recalculate"}</button>468 </form>469 )}470 {showPhotos && <div className="mt-2 flex flex-wrap gap-2">{evPhotos.map((p) => (471 // eslint-disable-next-line @next/next/no-img-element472 <img key={p.id} src={p.source_url} alt={p.id} className="h-24 w-32 object-cover border border-[var(--line)]" />473 ))}</div>}474 </div>475 );476}477478function MechRow({ fr, label, m }: { fr: boolean; label: string; m: { value: string | null; status: string; confidence: number; evidence: string[] } }) {479 const layer = m.status === "observed" || m.status === "inferred" ? "ai_inferred" : m.status === "listing" ? "observed" : "assumption";480 return (481 <div className="border-t border-[var(--line)] py-2">482 <p className="vp-mono text-[9.5px] uppercase tracking-[0.1em] text-ink-3">{label}</p>483 <div className="mt-1 flex flex-wrap items-center gap-2"><p className="vp-display text-[14px] font-bold">{m.value ? m.value.replace(/_/g, " ") : fr ? "inconnu" : "unknown"}</p><LayerBadge layer={layer} fr={fr} /><ConfBar value={m.confidence} fr={fr} /></div>484 </div>485 );486}487488function ReadingsChart({ fr, rows }: { fr: boolean; rows: { label: string; v: number | null }[] }) {489 const lang = fr ? "fr" : "en";490 const vals = rows.map((r) => r.v).filter((x): x is number => x != null && x > 0);491 const max = Math.max(...vals, 1);492 return (493 <div>494 <p className="klabel">{fr ? "Lectures de la valeur — hédonique, comparables, coût, prix demandé" : "Value readings — hedonic, comparables, cost, asking price"}</p>495 <div className="mt-3 space-y-2">496 {rows.map((r) => (497 <div key={r.label} className="grid grid-cols-[130px_1fr_110px] items-center gap-2 text-[12.5px]">498 <span className="truncate text-ink-2">{r.label}</span>499 <span className="h-[14px] bg-[var(--line-soft)]"><span className={`block h-full ${r.label.includes("coût") || r.label.includes("Cost") ? "bg-[var(--accent)]" : "bg-ink"}`} style={{ width: `${r.v ? (100 * r.v) / max : 0}%` }} /></span>500 <span className="vp-mono text-right">{r.v ? money(r.v, lang) : "—"}</span>501 </div>502 ))}503 </div>504 <p className="vp-mono mt-2 text-[10px] uppercase tracking-[0.05em] text-ink-3">{fr ? "Pas de moyenne automatique des approches." : "No automatic averaging of approaches."}</p>505 </div>506 );507}508509/* ================================================================ exercice */510511interface Exercise { foundation: string; siding: string; roof: string; quality: string; roofCondition: string; kitchenCondition: string; windowCount: string }512const EMPTY: Exercise = { foundation: "", siding: "", roof: "", quality: "", roofCondition: "", kitchenCondition: "", windowCount: "" };513514function ExerciseBlock({ uid, fr, view, revealed, onReveal }: { uid: string; fr: boolean; view: AnalysisView | null; revealed: boolean; onReveal: () => void }) {515 const [ex, setEx] = useState<Exercise>(EMPTY);516 const [ready, setReady] = useState(false);517 useEffect(() => { const id = setTimeout(() => { try { const raw = window.localStorage.getItem(EX_KEY(uid)); if (raw) setEx({ ...EMPTY, ...(JSON.parse(raw) as Exercise) }); } catch {} setReady(true); }, 0); return () => clearTimeout(id); }, [uid]);518 useEffect(() => { if (ready) try { window.localStorage.setItem(EX_KEY(uid), JSON.stringify(ex)); } catch {} }, [ex, ready, uid]);519 const set = (k: keyof Exercise) => (e: React.ChangeEvent<HTMLSelectElement | HTMLInputElement>) => setEx((s) => ({ ...s, [k]: e.target.value }));520 const m = view?.merged ?? null;521 const dominant = m ? Object.entries(m.siding.value).sort((a, b) => (b[1] ?? 0) - (a[1] ?? 0))[0]?.[0] ?? "" : "";522 const rows = useMemo(() => m ? [523 [fr ? "Fondation" : "Foundation", ex.foundation, m.foundation.value], [fr ? "Revêtement dominant" : "Dominant cladding", ex.siding, dominant], [fr ? "Toiture" : "Roof", ex.roof, m.roof.value], [fr ? "Qualité" : "Quality", ex.quality, m.quality.value],524 [fr ? "Condition toiture" : "Roof condition", ex.roofCondition, m.conditions.roof ?? "—"], [fr ? "Condition cuisine" : "Kitchen condition", ex.kitchenCondition, m.conditions.kitchen ?? "—"], [fr ? "Fenêtres" : "Windows", ex.windowCount, String(m.windowCount.value ?? (view?.analysis?.derived_geometry.window_count ?? "—"))],525 ] as [string, string, string][] : [], [m, ex, fr, dominant, view]);526 return (527 <section className="border border-[var(--accent)] bg-surface p-6">528 <p className="klabel">✎ {fr ? "Exercice — votre lecture du bâtiment" : "Exercise — your reading of the building"}</p>529 <p className="mt-2 text-[13px] text-ink-2">{fr ? "Observez les photos de l'annonce et remplissez la grille avant de révéler l'analyse IA ; vos réponses restent sur cet appareil." : "Look at the listing photos and fill the grid before revealing the AI analysis; your answers stay on this device."}</p>530 <div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">531 <label className="block"><span className="klabel">{fr ? "Fondation" : "Foundation"}</span><select className="vp-input mt-1" value={ex.foundation} onChange={set("foundation")}><option value="">—</option>{["poured_concrete", "concrete_block", "slab_on_grade", "piers", "stone"].map((o) => <option key={o}>{o}</option>)}</select></label>532 <label className="block"><span className="klabel">{fr ? "Revêtement dominant" : "Dominant cladding"}</span><select className="vp-input mt-1" value={ex.siding} onChange={set("siding")}><option value="">—</option>{["vinyl", "brick", "fiber_cement", "wood", "stone", "stucco", "aluminum", "steel"].map((o) => <option key={o}>{o}</option>)}</select></label>533 <label className="block"><span className="klabel">{fr ? "Toiture" : "Roof"}</span><select className="vp-input mt-1" value={ex.roof} onChange={set("roof")}><option value="">—</option>{["asphalt_shingle", "metal", "membrane", "cedar", "slate_tile"].map((o) => <option key={o}>{o}</option>)}</select></label>534 <label className="block"><span className="klabel">{fr ? "Qualité" : "Quality"}</span><select className="vp-input mt-1" value={ex.quality} onChange={set("quality")}><option value="">—</option>{QUALITIES.map((o) => <option key={o.key} value={o.key}>{fr ? o.fr : o.en}</option>)}</select></label>535 <label className="block"><span className="klabel">{fr ? "Condition toiture" : "Roof condition"}</span><select className="vp-input mt-1" value={ex.roofCondition} onChange={set("roofCondition")}><option value="">—</option>{CONDITIONS.map((o) => <option key={o.key} value={o.key}>{fr ? o.fr : o.en}</option>)}</select></label>536 <label className="block"><span className="klabel">{fr ? "Condition cuisine" : "Kitchen condition"}</span><select className="vp-input mt-1" value={ex.kitchenCondition} onChange={set("kitchenCondition")}><option value="">—</option>{CONDITIONS.map((o) => <option key={o.key} value={o.key}>{fr ? o.fr : o.en}</option>)}</select></label>537 <label className="block"><span className="klabel">{fr ? "Nombre de fenêtres" : "Window count"}</span><input className="vp-input mt-1" type="number" value={ex.windowCount} onChange={set("windowCount")} /></label>538 </div>539 <div className="mt-4 flex flex-wrap gap-2">540 {!revealed && <button type="button" className="btn btn-accent" onClick={onReveal} disabled={!view}>{fr ? "Comparer avec l'analyse IA" : "Compare with the AI analysis"} →</button>}541 {!view && <span className="vp-mono self-center text-[10px] uppercase text-ink-3">{fr ? "Lancez d'abord l'analyse IA (ci-dessous)." : "Launch the AI analysis first (below)."}</span>}542 </div>543 {revealed && rows.length > 0 && (544 <div className="src-wrap mt-4"><table className="src-table"><thead><tr><th>{fr ? "Élément" : "Item"}</th><th>{fr ? "Votre réponse" : "Your answer"}</th><th>{fr ? "Analyse IA" : "AI analysis"}</th><th>{fr ? "Accord" : "Match"}</th></tr></thead>545 <tbody>{rows.map(([k, mine, ai]) => <tr key={k}><td>{k}</td><td className="vp-mono">{mine || "—"}</td><td className="vp-mono">{String(ai).replace(/_/g, " ")}</td><td>{mine ? (mine === ai ? "✓" : "✗") : "—"}</td></tr>)}</tbody></table></div>546 )}547 </section>548 );549}550