// Auteur : Simon-Pierre Boucher — contact@spboucher.ai /** * Page « Analyse IA du bâtiment » d'une annonce (§138-142, 156-157, 174-176, 206-208). * Avant : rien n'est analysé tant que l'utilisateur ne le demande pas. * Pendant : étapes du pipeline (polling léger). * Après : 01 photos · 02 profil · 03 construction · 04 quantités · 05 assemblages * · 06 coût · 07 dépréciation · 08 terrain · 09 résultat · 10 sources · 11 JSON. * Chaque valeur porte sa couche (OBSERVÉ / INFÉRÉ PAR IA / CALCULÉ / PRIX SOURCÉ) * et peut être corrigée ; le coût est recalculé sans rappeler le modèle. * DOM = ordre visuel, une colonne sur mobile. */ "use client"; import { useCallback, useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { useLang } from "@/components/LangContext"; import type { AnalysisView } from "@/lib/cost/ai/service"; import type { Fact } from "@/lib/cost/ai/merge"; import { CONDITIONS, QUALITIES, categoryLabel } from "@/lib/cost/taxonomy"; import { money, num, pct } from "../fmt"; import { ConfBar, CostTable, JsonViewer, LayerBadge, Sect, Stat, layerOf } from "./ui"; export interface AiHead { uid: string; address: string | null; city: string | null; propertyType: string | null; price: number; images: string[]; source: string; yearBuilt: number | null; areaSqft: number | null; unitId: string | null; evalEst: number | null; modelEst: number | null; compsEst: number | null; roleValue: number | null; maxImages: number; model: string; } type Status = "none" | "queued" | "fetching_images" | "analyzing" | "validating" | "embedding" | "mapping_assemblies" | "pricing" | "completed" | "failed"; interface Poll { id: string; status: Status; stage: string | null; version: number; error?: string | null } const STEPS: { key: Status[]; fr: string; en: string }[] = [ { key: ["queued", "fetching_images"], fr: "Analyse des photos", en: "Analysing photos" }, { key: ["analyzing"], fr: "Extraction des caractéristiques", en: "Extracting features" }, { key: ["validating", "embedding"], fr: "Estimation des composantes", en: "Estimating components" }, { key: ["mapping_assemblies"], fr: "Construction du profil technique", en: "Building the technical profile" }, { key: ["pricing"], fr: "Association aux coûts actuels", en: "Applying current costs" }, ]; const EX_KEY = (uid: string) => `vrai-prix-ai-exercice:${uid}`; export default function AiCostAnalysis({ head, initial }: { head: AiHead; initial: AnalysisView | null }) { const { lang } = useLang(); const fr = lang === "fr"; const [view, setView] = useState(initial); const [poll, setPoll] = useState(initial && initial.status !== "completed" && initial.status !== "failed" ? { id: initial.id, status: initial.status, stage: initial.stage, version: initial.version } : null); const [busy, setBusy] = useState(false); const [err, setErr] = useState(null); const [exerciseMode, setExerciseMode] = useState(false); const [revealed, setRevealed] = useState(true); const base = `/api/listings/${encodeURIComponent(head.uid)}`; // polling tant qu'une analyse est en cours useEffect(() => { if (!poll) return; const t = setInterval(async () => { try { const r = await fetch(`${base}/ai-cost-analysis?id=${poll.id}`, { cache: "no-store" }); const d = (await r.json()) as Poll & Partial; if (d.status === "completed" || d.status === "failed") { setPoll(null); setView(d as AnalysisView); if (d.status === "failed") setErr(d.error ?? "échec"); } else setPoll({ id: d.id, status: d.status, stage: d.stage, version: d.version }); } catch { /* réessai au prochain tick */ } }, 2500); return () => clearInterval(t); }, [poll, base]); const start = useCallback(async (force: boolean) => { setBusy(true); setErr(null); try { const r = await fetch(`${base}/ai-cost-analysis${force ? "/reanalyze" : ""}`, { method: "POST" }); const d = await r.json(); if (!r.ok) throw new Error(d.error ?? r.statusText); 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); } else setPoll({ id: d.analysisId, status: d.status, stage: d.status, version: d.version }); } catch (e) { setErr((e as Error).message); } finally { setBusy(false); } }, [base]); const recalc = useCallback(async () => { if (!view) return; setBusy(true); setErr(null); try { const r = await fetch(`${base}/cost-estimate`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ analysisId: view.id }) }); const d = await r.json(); if (!r.ok) throw new Error(d.error ?? r.statusText); setView(d as AnalysisView); } catch (e) { setErr((e as Error).message); } finally { setBusy(false); } }, [base, view]); const override = useCallback(async (fieldPath: string, value: unknown) => { if (!view) return; setBusy(true); setErr(null); try { 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 }) }); const d = await r.json(); if (!r.ok) throw new Error(d.error ?? r.statusText); setView(d as AnalysisView); } catch (e) { setErr((e as Error).message); } finally { setBusy(false); } }, [base, view]); const completed = view?.status === "completed" && view.analysis && view.merged && view.estimate ? view : null; const where = [head.address, head.city].filter(Boolean).join(", "); return (
{/* ---------------- en-tête ---------------- */}
{fr ? "Des photos à la méthode du coût" : "From photos to the cost approach"}

{fr ? "Analyse IA du bâtiment" : "AI building analysis"}

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

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

{exerciseMode && setRevealed(true)} />} {/* ---------------- état : avant / pendant / échec ---------------- */} {!completed && (
{poll ? (

{fr ? "Analyse en cours" : "Analysis in progress"} · v{poll.version}

    {STEPS.map((s, i) => { const idx = STEPS.findIndex((x) => x.key.includes(poll.status)); const state = i < idx ? "done" : i === idx ? "active" : "todo"; return (
  1. {fr ? s.fr : s.en}{state === "active" ? "…" : ""}
  2. ); })}

{fr ? "Vous pouvez quitter la page : l'analyse continue côté serveur." : "You can leave the page: the analysis continues server-side."}

) : (

{view?.status === "failed" ? (fr ? "Dernière analyse échouée" : "Last analysis failed") : (fr ? "Analyse multimodale non effectuée" : "Multimodal analysis not performed")}

{view?.status === "failed" &&

{view.error}

}

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

{!head.unitId &&

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

}
)} {err &&

{err}

}
)} {completed && revealed && start(true)} onOverride={override} />} {completed && !revealed &&

{fr ? "Résultats de l'IA masqués — complétez l'exercice puis révélez." : "AI results hidden — complete the exercise, then reveal."}

}
); } /* ================================================================ résultats */ function 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 }) { const lang = fr ? "fr" : "en"; const a = v.analysis!; const m = v.merged!; const e = v.estimate!; const conf = v.combined?.overall ?? Math.round((a.confidence.overall ?? 0) * 100); const [similar, setSimilar] = useState<{ listingUid: string; similarity: number; address: string | null; city: string | null; price: number | null; image: string | null }[] | null>(null); useEffect(() => { let alive = true; 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([]); }); return () => { alive = false; }; }, [head.uid, v.id]); const photosFor = (ev: string[]) => a.images.filter((i) => ev.includes(i.id)); const implicitBuilding = head.price - e.landValue; const gap = e.depreciation.depreciatedImprovementValue > 0 ? ((implicitBuilding - e.depreciation.depreciatedImprovementValue) / e.depreciation.depreciatedImprovementValue) * 100 : null; return ( <> {/* résumé */}

{fr ? "Profil technique généré" : "Technical profile generated"} · v{v.version} · {a.metadata.model} · {a.metadata.image_count} {fr ? "photos" : "photos"}

{fr ? "Confiance globale" : "Overall confidence"} : {conf} %

{fr ? "Télécharger le JSON" : "Download JSON"} {head.unitId && {fr ? "Voir la méthode du coût" : "Open the cost approach"} →}
{v.combined && (
)}

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

{err &&

{err}

} {v.conflicts.length > 0 && (

{fr ? "Conflits détectés entre données d'annonce et analyse visuelle" : "Conflicts between listing data and visual analysis"}

    {v.conflicts.map((c) =>
  • {c.field} : {c.sourceA} = {c.valueA} · {c.sourceB} = {c.valueB} ({c.severity}) — {fr ? "la source la plus forte a été conservée, sans écrasement" : "stronger source kept, nothing overwritten"}
  • )}
)} {a.privacy.people_visible || a.privacy.documents_visible || a.privacy.licence_plates_visible ?

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

: null}
{/* 01 photos */}

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

{a.images.map((im) => (
{/* eslint-disable-next-line @next/next/no-img-element */} {im.id}
{im.id} · {im.room_hint}
))}
{/* 02 profil technique */}

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

q.key)} photos={photosFor} onOverride={onOverride} /> pct(Number(x) * 100, lang, 0, false)} photos={photosFor} onOverride={onOverride} /> { 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} /> q.key)} fmt={(x) => `${m.kitchens.value} × ${x}`} photos={photosFor} onOverride={onOverride} /> q.key)} photos={photosFor} onOverride={onOverride} /> `${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")} /> Object.entries(x as Record).map(([k, s]) => `${k} ${Math.round(s * 100)} %`).join(", ") || "—"} photos={photosFor} onOverride={onOverride} /> `${x} · ${num(m.drivewaySqft.value, lang)} pi²`} photos={photosFor} onOverride={onOverride} />
{a.renovations.length > 0 && (

{fr ? "Rénovations probables (estimation IA)" : "Likely renovations (AI estimate)"}

    {a.renovations.map((r, 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"}` : ""} {r.evidence.join(", ")}
  • )}
)}
{/* 03 construction */}
Object.entries(x as Record).sort((p, q) => q[1] - p[1]).map(([k, s]) => `${Math.round(s * 100)} % ${k.replace(/_/g, " ")}`).join(" + ")} photos={photosFor} onOverride={onOverride} /> `${x} · ${m.roofPitch.value}/12`} photos={photosFor} onOverride={onOverride} /> `${x}${m.windowCount.value ? ` · ${m.windowCount.value} ${fr ? "fenêtres (IA)" : "windows (AI)"}` : ""}`} photos={photosFor} onOverride={onOverride} />
{(["heat_source", "heat_distribution", "heat_pump", "air_conditioning", "air_exchanger", "water_heater"] as const).map((k) => )} {(["panel_type", "service_type", "ev_charger"] as const).map((k) => )} {(["water_supply", "waste_system"] as const).map((k) => )}
{a.uncertainties.length > 0 &&

{fr ? "Incertitudes signalées par le modèle" : "Uncertainties reported by the model"}

    {a.uncertainties.map((u, i) =>
  • {u}
  • )}
}
{/* 04 quantités */}

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

{Object.entries(a.estimated_quantities).map(([k, qv]) => ( ))}
{fr ? "Quantité IA" : "AI quantity"}{fr ? "Valeur" : "Value"}{fr ? "Confiance" : "Confidence"}{fr ? "Méthode / preuves" : "Method / evidence"}{fr ? "Utilisée ?" : "Used?"}
{k.replace(/_/g, " ")}{qv.value == null ? "—" : `${num(qv.value, lang, 1)} ${qv.unit}`}{qv.method ?? "—"} {qv.evidence.join(", ")}{qv.value != null && qv.confidence >= 0.6 ? (fr ? "oui (≥ 0,6)" : "yes (≥ 0.6)") : fr ? "non → moteur" : "no → engine"}
{/* 05 assemblages */}

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

{e.lines.map((l) => ( ))}
{fr ? "Assemblage" : "Assembly"}{fr ? "Quantité" : "Quantity"}{fr ? "Origine" : "Source"}{fr ? "Coût unitaire" : "Unit cost"}{fr ? "Coût" : "Cost"}{fr ? "Formule" : "Formula"}
{fr ? l.nameFr : l.nameEn}{categoryLabel(l.category, fr)}{num(l.quantity, lang, 1)} {l.unit}{num(l.unitCost, lang, 2)} ${money(l.adjusted, lang)}{l.quantityFormula}
{/* 06 coût */}
{/* 07 dépréciation */}

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

{a.estimated_effective_age.reasoning_summary.length > 0 &&
    {a.estimated_effective_age.reasoning_summary.map((r, i) =>
  • {r}
  • )}
}
{e.depreciation.components.map((c) => ( ))}
{fr ? "Composante" : "Component"}RCN{fr ? "Condition (IA)" : "Condition (AI)"}{fr ? "Vie" : "Life"}{fr ? "Âge eff." : "Eff. age"}%{fr ? "Dépréciation" : "Depreciation"}
{fr ? c.labelFr : c.labelEn}{money(c.rcn, lang)} {c.economicLife}{num(c.effectiveAge, lang, 1)}{num(c.depreciationPct, lang, 1)} %−{money(c.depreciation, lang)}
{/* 08 terrain */}

{e.landValue ? money(e.landValue, lang) : fr ? "Donnée non disponible" : "Not available"}

{fr ? "Source" : "Source"} : {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."}

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

{/* 09 résultat */}
{fr ? "Prix demandé" : "Asking price"}{money(head.price, lang)}
{fr ? "Terrain" : "Land"}{money(e.landValue, lang)}
{fr ? "Valeur implicite du bâtiment (prix − terrain)" : "Implicit building value (price − land)"}{money(implicitBuilding, lang)}
RCN{money(e.replacementCostNew, lang)}
{fr ? "Valeur bâtiment dépréciée" : "Depreciated building value"}{money(e.depreciation.depreciatedImprovementValue, lang)}
{fr ? "Indication par le coût" : "Cost approach indication"}{money(e.costApproachValue, lang)}
{fr ? "Écart relatif (bâtiment implicite vs déprécié)" : "Relative gap (implicit vs depreciated building)"}{gap == null ? "—" : pct(gap, lang, 1)}

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

{similar && similar.length > 0 && (

{fr ? "Propriétés de construction similaire (vecteur technique — comparables TECHNIQUES, pas de marché)" : "Technically similar properties (technical vector — TECHNICAL comparables, not market)"}

{similar.map((s) => ( {/* eslint-disable-next-line @next/next/no-img-element */} {s.image ? :
}

{s.address ?? s.listingUid}

{s.city} · {s.price ? money(s.price, lang) : "—"}

{fr ? "similarité" : "similarity"} {Math.round(s.similarity * 100)} %

))}
)} {similar && similar.length === 0 &&

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

}
{/* 10 sources */}
{[ [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`], [fr ? "Caractéristiques" : "Characteristics", fr ? `annonce${head.unitId ? " + rôle d'évaluation MAMH" : ""}` : `listing${head.unitId ? " + MAMH assessment roll" : ""}`], [fr ? "Analyse du bâtiment" : "Building analysis", `${a.metadata.model} · prompt ${a.metadata.prompt_version} · ${fr ? "schéma" : "schema"} ${a.metadata.schema_version}`], [fr ? "Main-d'œuvre" : "Labour", "APCHQ (coût horaire employeur, secteur résidentiel léger) · CCQ (conventions)"], [fr ? "Matériaux" : "Materials", "Canac · BMR · Patrick Morin (prix affichés) · prix de référence internes étiquetés « hypothèse »"], [fr ? "Indices" : "Indices", "Statistique Canada 18-10-0289 (indices des prix de la construction de bâtiments)"], [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"], [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)"], [fr ? "Instantané des prix" : "Price snapshot", `${v.costSnapshotDate} · ${fr ? "base" : "database"} ${e.costDatabaseVersion}`], ].map(([k, val]) =>
{k}
{val}
)}
{v.usage && typeof v.usage.inputTokens === "number" &&

{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

}
{/* 11 JSON */}

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

{v.versions.length > 1 &&

{fr ? "Versions" : "Versions"} : {v.versions.map((x) => `v${x.version} ${x.status}`).join(" · ")}

}
); } /* ================================================================ lignes de faits */ function FactRow({ fr, label, f, path, options, numeric, fmt, photos, onOverride }: { fr: boolean; label: string; f: Fact; path?: string; options?: string[]; numeric?: boolean; fmt?: (v: T) => string; photos: (ev: string[]) => { id: string; source_url: string }[]; onOverride: (p: string, v: unknown) => void }) { const [edit, setEdit] = useState(false); const [showPhotos, setShowPhotos] = useState(false); const [val, setVal] = useState(f.value == null ? "" : typeof f.value === "object" ? "" : String(f.value)); const shown = f.value == null ? "—" : fmt ? fmt(f.value) : typeof f.value === "object" ? JSON.stringify(f.value) : String(f.value).replace(/_/g, " "); const evPhotos = photos(f.evidence); return (

{label}

{shown}

{evPhotos.length > 0 && } {path && (options || numeric) && } {f.alternatives.length > 0 && `${alt.source}: ${JSON.stringify(alt.value)}`).join("\n")}>{fr ? `${f.alternatives.length} autre(s) source(s)` : `${f.alternatives.length} other source(s)`}}
{edit && path && (
{ ev.preventDefault(); onOverride(path, numeric ? Number(val) : val); setEdit(false); }}> {options ? : setVal(ev.target.value)} />}
)} {showPhotos &&
{evPhotos.map((p) => ( // eslint-disable-next-line @next/next/no-img-element {p.id} ))}
}
); } function MechRow({ fr, label, m }: { fr: boolean; label: string; m: { value: string | null; status: string; confidence: number; evidence: string[] } }) { const layer = m.status === "observed" || m.status === "inferred" ? "ai_inferred" : m.status === "listing" ? "observed" : "assumption"; return (

{label}

{m.value ? m.value.replace(/_/g, " ") : fr ? "inconnu" : "unknown"}

); } function ReadingsChart({ fr, rows }: { fr: boolean; rows: { label: string; v: number | null }[] }) { const lang = fr ? "fr" : "en"; const vals = rows.map((r) => r.v).filter((x): x is number => x != null && x > 0); const max = Math.max(...vals, 1); return (

{fr ? "Lectures de la valeur — hédonique, comparables, coût, prix demandé" : "Value readings — hedonic, comparables, cost, asking price"}

{rows.map((r) => (
{r.label} {r.v ? money(r.v, lang) : "—"}
))}

{fr ? "Pas de moyenne automatique des approches." : "No automatic averaging of approaches."}

); } /* ================================================================ exercice */ interface Exercise { foundation: string; siding: string; roof: string; quality: string; roofCondition: string; kitchenCondition: string; windowCount: string } const EMPTY: Exercise = { foundation: "", siding: "", roof: "", quality: "", roofCondition: "", kitchenCondition: "", windowCount: "" }; function ExerciseBlock({ uid, fr, view, revealed, onReveal }: { uid: string; fr: boolean; view: AnalysisView | null; revealed: boolean; onReveal: () => void }) { const [ex, setEx] = useState(EMPTY); const [ready, setReady] = useState(false); 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]); useEffect(() => { if (ready) try { window.localStorage.setItem(EX_KEY(uid), JSON.stringify(ex)); } catch {} }, [ex, ready, uid]); const set = (k: keyof Exercise) => (e: React.ChangeEvent) => setEx((s) => ({ ...s, [k]: e.target.value })); const m = view?.merged ?? null; const dominant = m ? Object.entries(m.siding.value).sort((a, b) => (b[1] ?? 0) - (a[1] ?? 0))[0]?.[0] ?? "" : ""; const rows = useMemo(() => m ? [ [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], [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 ?? "—"))], ] as [string, string, string][] : [], [m, ex, fr, dominant, view]); return (

✎ {fr ? "Exercice — votre lecture du bâtiment" : "Exercise — your reading of the building"}

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

{!revealed && } {!view && {fr ? "Lancez d'abord l'analyse IA (ci-dessous)." : "Launch the AI analysis first (below)."}}
{revealed && rows.length > 0 && (
{rows.map(([k, mine, ai]) => )}
{fr ? "Élément" : "Item"}{fr ? "Votre réponse" : "Your answer"}{fr ? "Analyse IA" : "AI analysis"}{fr ? "Accord" : "Match"}
{k}{mine || "—"}{String(ai).replace(/_/g, " ")}{mine ? (mine === ai ? "✓" : "✗") : "—"}
)}
); }