Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.
TypeScript 90.2%
JavaScript 3.5%
Python 3.4%
CSS 1.9%
HTML 0.6%
1// Vrai-Prix — atelier « mon évaluation » d'une propriété à vendre.2/**3 * Quatre temps, comme un rapport d'évaluation par comparaison directe :4 * 01 bassin — ventes réelles (et annonces actives) autour du sujet, l'utilisateur5 * coche celles qu'il juge comparables (les 12 du moteur sont signalées) ;6 * 02 grille — ajustements en dollars (marché, superficie, âge, + libres),7 * pré-remplis avec les formules du moteur mais entièrement modifiables ;8 * 03 réconciliation — médiane, moyenne, moyennes pondérées ; valeur conclue ;9 * 04 comparaison — face à la mesure Vrai-Prix déjà calculée (hybride, hédonique,10 * comparables, coût, rôle indexé, ensemble), au prix demandé et au rôle.11 * Tout est conservé sur l'appareil (localStorage) — aucun envoi au serveur.12 */13"use client";14import { useCallback, useEffect, useMemo, useRef, useState } from "react";15import Link from "next/link";16import { useLang } from "@/components/LangContext";17import RadarMap, { type RadarComp } from "@/components/RadarMap";18import { TYPE_MATCH } from "@/lib/engine";19import type { CompCandidate, CompsPayload } from "@/lib/comps";20import type { TypeGroupKey } from "@/lib/immoka";21import { groupLabel } from "./ListingCardView";22import { dateFr, mean, median, money, num, pct, round100, signedMoney, sqftToM2, weightedMean, weightedMedian } from "./fmt";2324/* ---------------------------------- types --------------------------------- */25export interface WorkbenchHead {26 uid: string;27 address: string | null;28 city: string | null;29 propertyType: string | null;30 group: TypeGroupKey;31 price: number;32 image: string | null;33 bedrooms: number | null;34 bathrooms: number | null;35 areaSqft: number | null;36 lotSqft: number | null;37 yearBuilt: number | null;38 geolocated: boolean;39 eval: {40 est: number | null;41 low: number | null;42 high: number | null;43 confidence: string | null;44 modelEst: number | null;45 compsEst: number | null;46 costEst: number | null;47 roleEst: number | null;48 ensEst: number | null;49 valeurRole: number | null;50 nComps: number | null;51 unitId: string | null;52 evaluatedAt: string | null;53 } | null;54}5556interface Extra {57 id: string;58 label: string;59 amount: number;60}61interface CompState {62 adjTime: number;63 adjArea: number;64 adjAge: number;65 extras: Extra[];66 weight: number; // 1..567 rev?: number; // incrémenté à chaque « ↺ moteur » → remonte les champs (key)68}69type Method = "median" | "mean" | "wmean" | "wmedian";70interface Params {71 months: number;72 radiusKm: number;73 includeListings: boolean;74 sameType: boolean;75}76interface Saved {77 v: 1;78 params: Params;79 selected: string[];80 states: Record<string, CompState>;81 snap: Record<string, CompCandidate>;82 method: Method;83 rangePct: number;84 finalValue: number | null;85}8687const DEFAULT_PARAMS: Params = { months: 24, radiusKm: 3, includeListings: true, sameType: true };88const key = (uid: string) => `vrai-prix-avendre:${uid}`;8990const adjustedOf = (c: CompCandidate, s: CompState) =>91 c.amount + s.adjTime + s.adjArea + s.adjAge + s.extras.reduce((a, e) => a + (Number.isFinite(e.amount) ? e.amount : 0), 0);92const grossAdjPct = (c: CompCandidate, s: CompState) =>93 ((Math.abs(s.adjTime) + Math.abs(s.adjArea) + Math.abs(s.adjAge) + s.extras.reduce((a, e) => a + Math.abs(e.amount || 0), 0)) / c.amount) * 100;9495const fmtDist = (m: number) => (m < 1000 ? `${m} m` : `${(m / 1000).toFixed(1).replace(".", ",")} km`);9697/* ------------------------------- sous-blocs -------------------------------- */98function Sect({ num: n, kicker, title, right }: { num: string; kicker: string; title: string; right?: React.ReactNode }) {99 return (100 <header className="sec">101 <div className="flex flex-wrap items-baseline justify-between gap-2">102 <span className="sec-num">103 {n} — {kicker}104 </span>105 {right}106 </div>107 <h2 className="vp-display mt-2 text-[clamp(20px,3vw,28px)] font-bold uppercase leading-[1.05] tracking-[-0.025em]">{title}</h2>108 </header>109 );110}111112/** Champ monétaire : texte libre pendant la saisie ; remonté par `key` quand le parent réinitialise. */113function MoneyInput({ value, onChange, hint, label }: { value: number; onChange: (v: number) => void; hint?: string; label: string }) {114 const [txt, setTxt] = useState(() => String(Math.round(value)));115 return (116 <label className="block">117 <span className="klabel">{label}</span>118 <input119 value={txt}120 inputMode="numeric"121 onChange={(e) => {122 const t = e.target.value.replace(/[^\d-]/g, "");123 setTxt(t);124 const n = Number(t);125 if (Number.isFinite(n) && t !== "" && t !== "-") onChange(n);126 if (t === "") onChange(0);127 }}128 className="vp-input vp-mono mt-0.5 text-[13px]"129 />130 {hint && <span className="vp-mono mt-0.5 block text-[9.5px] uppercase tracking-[0.05em] text-ink-3">{hint}</span>}131 </label>132 );133}134135/** Graphique à points : chaque repère sur sa ligne, échelle commune, ma valeur en trait vertical. */136function DotPlot({137 rows,138 me,139 lang,140}: {141 rows: { key: string; label: string; value: number | null; band?: [number, number] | null; tone: "me" | "main" | "method" | "ask" | "role" }[];142 me: number | null;143 lang: string;144}) {145 const vals = rows.flatMap((r) => [r.value, r.band?.[0], r.band?.[1]]).filter((v): v is number => v != null && Number.isFinite(v));146 if (!vals.length) return null;147 const lo = Math.min(...vals) * 0.96;148 const hi = Math.max(...vals) * 1.04;149 const W = 760;150 const L = 250;151 const R = 24;152 const RH = 30;153 const H = rows.length * RH + 28;154 const x = (v: number) => L + ((v - lo) / (hi - lo)) * (W - L - R);155 const ticks = 4;156 return (157 <svg viewBox={`0 0 ${W} ${H}`} className="w-full" role="img" aria-label="Comparaison des valeurs">158 {Array.from({ length: ticks + 1 }, (_, i) => lo + ((hi - lo) * i) / ticks).map((v, i) => (159 <g key={i}>160 <line x1={x(v)} y1={4} x2={x(v)} y2={H - 22} stroke="var(--line)" strokeWidth="1" />161 <text x={x(v)} y={H - 8} textAnchor="middle" fontSize="9.5" fontFamily="var(--font-jetbrains), monospace" fill="var(--ink-3)">162 {new Intl.NumberFormat(lang === "fr" ? "fr-CA" : "en-CA", { notation: "compact", maximumFractionDigits: 0 }).format(v)} $163 </text>164 </g>165 ))}166 {me != null && Number.isFinite(me) && <line x1={x(me)} y1={2} x2={x(me)} y2={H - 22} stroke="var(--accent)" strokeWidth="1.5" strokeDasharray="4 4" />}167 {rows.map((r, i) => {168 const y = i * RH + RH / 2 + 4;169 const color = r.tone === "me" ? "var(--accent)" : r.tone === "ask" ? "var(--danger)" : r.tone === "role" ? "var(--ink-3)" : "var(--ink)";170 return (171 <g key={r.key}>172 <text x={0} y={y + 4} fontSize={r.tone === "me" || r.tone === "main" ? 11.5 : 10.5} fontWeight={r.tone === "me" || r.tone === "main" ? 700 : 500} fontFamily="var(--font-space-grotesk), sans-serif" fill={color}>173 {r.label.length > 40 ? r.label.slice(0, 39) + "…" : r.label}174 </text>175 <line x1={L} y1={y} x2={W - R} y2={y} stroke="var(--line-soft)" strokeWidth="1" />176 {r.band && r.band[1] > r.band[0] && <rect x={x(r.band[0])} y={y - 6} width={Math.max(2, x(r.band[1]) - x(r.band[0]))} height={12} fill={color} opacity={0.14} />}177 {r.value != null && Number.isFinite(r.value) && (178 <>179 {r.tone === "me" ? (180 <rect x={x(r.value) - 7} y={y - 7} width={14} height={14} transform={`rotate(45 ${x(r.value)} ${y})`} fill={color} stroke="var(--paper)" strokeWidth="1.5" />181 ) : (182 <circle cx={x(r.value)} cy={y} r={r.tone === "main" ? 6.5 : 5} fill={r.tone === "ask" ? "var(--paper)" : color} stroke={color} strokeWidth={2} />183 )}184 <text x={x(r.value) + (x(r.value) > W - 120 ? -12 : 12)} y={y + 4} textAnchor={x(r.value) > W - 120 ? "end" : "start"} fontSize="10.5" fontWeight="700" fontFamily="var(--font-jetbrains), monospace" fill={color}>185 {money(r.value, lang)}186 </text>187 </>188 )}189 </g>190 );191 })}192 </svg>193 );194}195196/* --------------------------------- atelier --------------------------------- */197export default function EvalWorkbench({ head }: { head: WorkbenchHead }) {198 const { lang } = useLang();199 const fr = lang === "fr";200 const [params, setParams] = useState<Params>(DEFAULT_PARAMS);201 const [data, setData] = useState<CompsPayload | null>(null);202 // la réponse (ou l'erreur) porte la clé de la requête qui l'a produite :203 // « en chargement » = la requête courante n'est pas encore servie204 const reqKey = `${head.uid}|${params.months}|${params.radiusKm}|${params.includeListings ? 1 : 0}`;205 const [dataKey, setDataKey] = useState<string | null>(null);206 const [errState, setErrState] = useState<{ key: string; msg: string } | null>(null);207 const error = errState?.key === reqKey ? errState.msg : null;208 const [selected, setSelected] = useState<string[]>([]);209 const [states, setStates] = useState<Record<string, CompState>>({});210 const [snap, setSnap] = useState<Record<string, CompCandidate>>({});211 const [method, setMethod] = useState<Method>("wmedian");212 const [rangePct, setRangePct] = useState(8);213 const [finalValue, setFinalValue] = useState<number | null>(null);214 const [finalTxt, setFinalTxt] = useState("");215 const [hover, setHover] = useState<string | null>(null);216 const [sortBy, setSortBy] = useState<"weight" | "distance" | "date" | "price">("weight");217 const [showAll, setShowAll] = useState(false);218 const [ready, setReady] = useState(false);219 const abortRef = useRef<AbortController | null>(null);220221 /* --- restauration locale --- */222 useEffect(() => {223 const id = setTimeout(() => {224 try {225 const raw = window.localStorage.getItem(key(head.uid));226 if (raw) {227 const s = JSON.parse(raw) as Saved;228 if (s.v === 1) {229 setParams({ ...DEFAULT_PARAMS, ...s.params });230 setSelected(s.selected ?? []);231 setStates(s.states ?? {});232 setSnap(s.snap ?? {});233 setMethod(s.method ?? "wmedian");234 setRangePct(s.rangePct ?? 8);235 setFinalValue(s.finalValue ?? null);236 if (s.finalValue != null) setFinalTxt(String(Math.round(s.finalValue)));237 }238 }239 } catch {}240 setReady(true);241 }, 0);242 return () => clearTimeout(id);243 }, [head.uid]);244245 /* --- sauvegarde locale (débouncée) --- */246 useEffect(() => {247 if (!ready) return;248 const id = setTimeout(() => {249 try {250 const s: Saved = { v: 1, params, selected, states, snap, method, rangePct, finalValue };251 window.localStorage.setItem(key(head.uid), JSON.stringify(s));252 } catch {}253 }, 300);254 return () => clearTimeout(id);255 }, [ready, params, selected, states, snap, method, rangePct, finalValue, head.uid]);256257 /* --- bassin de comparables --- */258 useEffect(() => {259 if (!ready || !head.geolocated) return;260 abortRef.current?.abort();261 const ctl = new AbortController();262 abortRef.current = ctl;263 const key = `${head.uid}|${params.months}|${params.radiusKm}|${params.includeListings ? 1 : 0}`;264 const qs = new URLSearchParams({265 uid: head.uid,266 months: String(params.months),267 radius: String(params.radiusKm),268 listings: params.includeListings ? "1" : "0",269 limit: "80",270 });271 fetch(`/api/avendre/comps?${qs}`, { signal: ctl.signal })272 .then(async (r) => {273 if (!r.ok) throw new Error((await r.json()).error ?? r.statusText);274 return r.json() as Promise<CompsPayload>;275 })276 .then((d) => {277 if (ctl.signal.aborted) return;278 setData(d);279 setDataKey(key);280 })281 .catch((e) => {282 if (ctl.signal.aborted) return;283 setErrState({ key, msg: String(e.message ?? e) });284 });285 return () => ctl.abort();286 }, [ready, head.uid, head.geolocated, params.months, params.radiusKm, params.includeListings]);287288 const loading = head.geolocated && ready && dataKey !== reqKey && error == null;289 const subject = data?.subject ?? null;290 const subjArea = subject?.floorArea ?? sqftToM2(head.areaSqft);291292 const pool = useMemo(() => {293 if (!data) return [] as CompCandidate[];294 let list = data.comps;295 if (params.sameType && subject) {296 const types = TYPE_MATCH[subject.typeProp] ?? TYPE_MATCH.autre;297 list = list.filter((c) => c.kind === "listing" || (c.propertyType != null && types.includes(c.propertyType)));298 }299 const sorted = [...list];300 sorted.sort((a, b) => {301 if (sortBy === "distance") return a.distanceM - b.distanceM;302 if (sortBy === "date") return b.date.localeCompare(a.date);303 if (sortBy === "price") return a.amount - b.amount;304 return Number(b.engineUsed) - Number(a.engineUsed) || b.weight - a.weight;305 });306 return sorted;307 }, [data, params.sameType, sortBy, subject]);308309 const visible = showAll ? pool : pool.slice(0, 30);310 const maxW = useMemo(() => Math.max(1e-6, ...pool.map((c) => c.weight)), [pool]);311312 const compOf = useCallback((id: string): CompCandidate | undefined => data?.comps.find((c) => c.id === id) ?? snap[id], [data, snap]);313314 const toggle = (c: CompCandidate) => {315 setSelected((prev) => (prev.includes(c.id) ? prev.filter((x) => x !== c.id) : [...prev, c.id]));316 setSnap((prev) => (prev[c.id] ? prev : { ...prev, [c.id]: c }));317 setStates((prev) =>318 prev[c.id] ? prev : { ...prev, [c.id]: { adjTime: c.adjTime, adjArea: c.adjArea, adjAge: c.adjAge, extras: [], weight: c.engineUsed ? 4 : 3 } }319 );320 };321 const takeEngine = () => {322 if (!data) return;323 const eng = data.comps.filter((c) => c.engineUsed);324 setSelected(eng.map((c) => c.id));325 setSnap((prev) => ({ ...prev, ...Object.fromEntries(eng.map((c) => [c.id, c])) }));326 setStates((prev) => ({327 ...prev,328 ...Object.fromEntries(eng.map((c) => [c.id, prev[c.id] ?? { adjTime: c.adjTime, adjArea: c.adjArea, adjAge: c.adjAge, extras: [], weight: 4 }])),329 }));330 };331 const clearAll = () => setSelected([]);332 const resetAll = () => {333 setSelected([]);334 setStates({});335 setSnap({});336 setMethod("wmedian");337 setRangePct(8);338 setFinalValue(null);339 setFinalTxt("");340 setParams(DEFAULT_PARAMS);341 try {342 window.localStorage.removeItem(key(head.uid));343 } catch {}344 };345 const patchState = (id: string, patch: Partial<CompState>) => setStates((prev) => ({ ...prev, [id]: { ...prev[id], ...patch } }));346347 /* --- réconciliation --- */348 const chosen = selected.map((id) => ({ c: compOf(id), s: states[id] })).filter((x): x is { c: CompCandidate; s: CompState } => !!x.c && !!x.s);349 const adjusted = chosen.map(({ c, s }) => adjustedOf(c, s));350 const weights = chosen.map(({ s }) => s.weight);351 const indications: Record<Method, number | null> = {352 median: median(adjusted),353 mean: mean(adjusted),354 wmean: weightedMean(adjusted, weights),355 wmedian: weightedMedian(adjusted, weights),356 };357 const indication = indications[method];358 const myValue = finalValue ?? (indication != null ? round100(indication) : null);359 const dispersion = (() => {360 if (adjusted.length < 2 || indication == null || indication <= 0) return null;361 const dev = adjusted.map((v) => Math.abs(v - indication) / indication).sort((a, b) => a - b);362 return dev[Math.floor(dev.length / 2)] * 100;363 })();364 const avgGross = chosen.length ? chosen.reduce((a, { c, s }) => a + grossAdjPct(c, s), 0) / chosen.length : null;365 const engineOverlap = chosen.filter(({ c }) => c.engineUsed).length;366367 /* --- repères de comparaison --- */368 const ev = head.eval;369 const vpMain = ev?.est ?? data?.engine.estimate ?? null;370 const vpLow = ev?.low ?? data?.engine.low ?? null;371 const vpHigh = ev?.high ?? data?.engine.high ?? null;372 const rows = [373 { key: "me", label: fr ? "Ma valeur conclue" : "My concluded value", value: myValue, band: myValue != null ? ([myValue * (1 - rangePct / 100), myValue * (1 + rangePct / 100)] as [number, number]) : null, tone: "me" as const },374 { key: "vp", label: fr ? "Vrai-Prix — hybride (mesure principale)" : "Vrai-Prix — hybrid (main measure)", value: vpMain, band: vpLow != null && vpHigh != null ? ([vpLow, vpHigh] as [number, number]) : null, tone: "main" as const },375 { key: "model", label: fr ? "Modèle hédonique" : "Hedonic model", value: ev?.modelEst ?? data?.engine.modelEstimate ?? null, tone: "method" as const },376 { key: "comps", label: fr ? "Comparables du moteur" : "Engine comparables", value: ev?.compsEst ?? data?.engine.compsEstimate ?? null, tone: "method" as const },377 { key: "cost", label: fr ? "Méthode du coût" : "Cost approach", value: ev?.costEst ?? null, tone: "method" as const },378 { key: "role_idx", label: fr ? "Rôle indexé (IAAO)" : "Indexed roll (IAAO)", value: ev?.roleEst ?? null, tone: "method" as const },379 { key: "ens", label: fr ? "Ensemble des méthodes" : "Ensemble of methods", value: ev?.ensEst ?? null, tone: "method" as const },380 { key: "ask", label: fr ? "Prix demandé" : "Asking price", value: head.price, tone: "ask" as const },381 { key: "role", label: fr ? "Valeur au rôle" : "Assessed value", value: ev?.valeurRole ?? null, tone: "role" as const },382 ].filter((r) => r.value != null || r.key === "me");383384 const diffPct = (v: number | null) => (v != null && myValue != null && v > 0 ? ((myValue - v) / v) * 100 : null);385 const inBand = myValue != null && vpLow != null && vpHigh != null ? myValue >= vpLow && myValue <= vpHigh : null;386387 const radarComps: RadarComp[] = visible.map((c) => ({388 id: c.id,389 lat: c.lat,390 lng: c.lng,391 label: `${c.street ?? "—"}${c.city ? `, ${c.city}` : ""}${c.kind === "listing" ? (fr ? " (en vente)" : " (for sale)") : ""}`,392 price: money(c.amount, lang),393 adjusted: money(states[c.id] ? adjustedOf(c, states[c.id]) : c.adjustedPrice, lang),394 date: dateFr(c.date, lang),395 distanceM: c.distanceM,396 weight: selected.includes(c.id) ? 1 : (c.weight / maxW) * 0.5,397 }));398399 const step = (n: number, done: boolean, label: string) => (400 <span className={`vp-mono flex items-center gap-1.5 text-[10px] uppercase tracking-[0.08em] ${done ? "text-ink" : "text-ink-3"}`}>401 <span className={`inline-flex h-5 w-5 items-center justify-center border text-[10px] font-bold ${done ? "border-ink bg-ink text-paper" : "border-[var(--line-strong)]"}`}>{n}</span>402 {label}403 </span>404 );405406 return (407 <div className="space-y-12 py-8">408 {/* ================= masthead ================= */}409 <section className="rise">410 <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 border-b-2 border-ink pb-2.5">411 <nav className="vp-mono flex min-w-0 flex-wrap items-center gap-2 text-[10.5px] uppercase tracking-[0.08em] text-ink-3">412 <Link href="/" className="text-ink-2 hover:text-accent-deep">413 Vrai Prix414 </Link>415 <span aria-hidden="true">/</span>416 <Link href="/a-vendre" className="text-ink-2 hover:text-accent-deep">417 {fr ? "À vendre" : "For sale"}418 </Link>419 <span aria-hidden="true">/</span>420 <Link href={`/a-vendre/${encodeURIComponent(head.uid)}`} className="truncate text-ink-2 hover:text-accent-deep">421 {fr ? "Fiche" : "Listing"}422 </Link>423 <span aria-hidden="true">/</span>424 <span>{fr ? "Mon évaluation" : "My valuation"}</span>425 </nav>426 <div className="flex flex-wrap gap-4 print:hidden">427 {step(1, selected.length > 0, fr ? `Comparables (${selected.length})` : `Comparables (${selected.length})`)}428 {step(2, selected.length > 0, fr ? "Ajustements" : "Adjustments")}429 {step(3, myValue != null, fr ? "Réconciliation" : "Reconciliation")}430 {step(4, myValue != null, fr ? "Comparaison" : "Comparison")}431 </div>432 </div>433 <div className="mt-6 grid gap-x-10 gap-y-6 lg:grid-cols-[1fr_260px]">434 <div className="min-w-0">435 <span className="kicker">{fr ? "Atelier — mode exercice" : "Workshop — exercise mode"}</span>436 <h1 className="vp-display mt-2 text-[clamp(22px,3.2vw,34px)] font-bold uppercase leading-tight tracking-[-0.025em]">437 {fr ? "Mon évaluation" : "My valuation"} <span className="text-ink-3">·</span> {head.address || head.propertyType || "—"}438 {head.city ? <span className="text-ink-3"> · {head.city}</span> : null}439 </h1>440 <p className="vp-mono mt-3 text-[11px] uppercase tracking-[0.06em] text-ink-2">441 {[442 groupLabel(head.group, fr),443 `${fr ? "prix demandé" : "asking"} ${money(head.price, lang)}`,444 subjArea ? `${num(subjArea, lang)} m²` : null,445 (subject?.yearBuilt ?? head.yearBuilt) ? `${fr ? "constr." : "built"} ${subject?.yearBuilt ?? head.yearBuilt}` : null,446 subject?.landArea ? `${fr ? "terrain" : "lot"} ${num(subject.landArea, lang)} m²` : null,447 head.bedrooms != null ? `${head.bedrooms} ${fr ? "ch." : "bd"}` : null,448 ]449 .filter(Boolean)450 .join(" · ")}451 </p>452 {subject && (453 <p className="mt-2 text-[12.5px] text-ink-3">454 {subject.fromUnit455 ? fr456 ? `Caractéristiques du sujet lues au rôle d'évaluation (unité ${subject.unitId}) : type ${subject.typeProp}, aire d'étages, terrain, année.`457 : `Subject characteristics read from the assessment roll (unit ${subject.unitId}): type ${subject.typeProp}, floor area, lot, year.`458 : fr459 ? "Aucune unité du rôle jumelée : les caractéristiques du sujet viennent de l'annonce (superficie et année déclarées par le vendeur)."460 : "No roll unit matched: subject characteristics come from the listing (area and year declared by the seller)."}461 </p>462 )}463 <div className="mt-5 flex flex-wrap gap-2.5 print:hidden">464 <Link href={`/a-vendre/${encodeURIComponent(head.uid)}`} className="btn btn-ghost">465 ← {fr ? "Fiche de la propriété" : "Property listing"}466 </Link>467 <button type="button" className="btn btn-ghost" onClick={() => window.print()}>468 {fr ? "Imprimer / PDF" : "Print / PDF"}469 </button>470 <button type="button" className="btn btn-ghost" onClick={resetAll}>471 ↺ {fr ? "Réinitialiser l'atelier" : "Reset workshop"}472 </button>473 </div>474 </div>475 {head.image && (476 // eslint-disable-next-line @next/next/no-img-element477 <img src={head.image} alt="" referrerPolicy="no-referrer" className="aspect-[4/3] w-full border border-[var(--line)] object-cover" />478 )}479 </div>480 </section>481482 {!head.geolocated && (483 <p className="border-l-2 border-[var(--danger)] pl-4 text-[14px] text-ink-2">484 {fr ? "Cette annonce n'est pas géolocalisée : impossible de chercher des comparables autour d'elle." : "This listing is not geolocated: comparables cannot be searched around it."}485 </p>486 )}487488 {/* ================= 01 — bassin ================= */}489 <section>490 <Sect491 num="01"492 kicker={fr ? "Sélection" : "Selection"}493 title={fr ? "Choisir mes comparables" : "Choose my comparables"}494 right={495 <span className="vp-mono text-[10px] uppercase tracking-[0.08em] text-ink-3">496 {data ? `${pool.length} ${fr ? "candidats" : "candidates"} · ${data.comps.filter((c) => c.engineUsed).length} ${fr ? "retenus par le moteur" : "kept by the engine"}` : ""}497 </span>498 }499 />500 <p className="mt-3 max-w-3xl text-[13.5px] leading-relaxed text-ink-2">501 {fr502 ? "Ventes réelles publiées autour du sujet (et, si vous le souhaitez, annonces actives voisines — des prix demandés, pas des ventes). Cochez les propriétés que VOUS jugez comparables : même marché, même type, superficie et âge voisins, vente récente. Le moteur signale ses 12 choix, mais c'est votre jugement qui compte ici."503 : "Published real sales around the subject (and, optionally, nearby active listings — asking prices, not sales). Tick the properties YOU judge comparable: same market, same type, similar size and age, recent sale. The engine flags its 12 picks, but your judgment is what matters here."}504 </p>505506 <div className="mt-5 grid gap-4 print:hidden sm:grid-cols-2 lg:grid-cols-[1fr_1fr_1fr_1fr_auto]">507 <label className="block">508 <span className="klabel">{fr ? "Rayon de recherche" : "Search radius"}</span>509 <select value={params.radiusKm} onChange={(e) => setParams({ ...params, radiusKm: Number(e.target.value) })} className="vp-input mt-1 cursor-pointer">510 {[1, 2, 3, 5, 10, 20].map((k) => (511 <option key={k} value={k}>512 {k} km513 </option>514 ))}515 </select>516 </label>517 <label className="block">518 <span className="klabel">{fr ? "Ventes depuis" : "Sales since"}</span>519 <select value={params.months} onChange={(e) => setParams({ ...params, months: Number(e.target.value) })} className="vp-input mt-1 cursor-pointer">520 {[12, 24, 36, 60].map((m) => (521 <option key={m} value={m}>522 {m} {fr ? "mois" : "months"}523 </option>524 ))}525 </select>526 </label>527 <label className="block">528 <span className="klabel">{fr ? "Trier par" : "Sort by"}</span>529 <select value={sortBy} onChange={(e) => setSortBy(e.target.value as typeof sortBy)} className="vp-input mt-1 cursor-pointer">530 <option value="weight">{fr ? "Pertinence (poids moteur)" : "Relevance (engine weight)"}</option>531 <option value="distance">{fr ? "Distance" : "Distance"}</option>532 <option value="date">{fr ? "Date (récentes d'abord)" : "Date (newest first)"}</option>533 <option value="price">{fr ? "Prix" : "Price"}</option>534 </select>535 </label>536 <div className="flex flex-col justify-end gap-1.5 text-[13px]">537 <label className="flex items-center gap-2">538 <input type="checkbox" checked={params.sameType} onChange={(e) => setParams({ ...params, sameType: e.target.checked })} className="h-4 w-4 accent-[var(--accent)]" />539 {fr ? "Même famille de type" : "Same type family"}540 </label>541 <label className="flex items-center gap-2">542 <input type="checkbox" checked={params.includeListings} onChange={(e) => setParams({ ...params, includeListings: e.target.checked })} className="h-4 w-4 accent-[var(--accent)]" />543 {fr ? "Inclure les annonces actives" : "Include active listings"}544 </label>545 </div>546 <div className="flex flex-col justify-end gap-2">547 <button type="button" className="btn btn-primary whitespace-nowrap" onClick={takeEngine} disabled={!data}>548 {fr ? "Reprendre les 12 du moteur" : "Take the engine's 12"}549 </button>550 <button type="button" className="btn btn-ghost whitespace-nowrap" onClick={clearAll} disabled={!selected.length}>551 {fr ? "Tout retirer" : "Clear all"}552 </button>553 </div>554 </div>555556 {error && <p className="mt-4 text-[13px] text-[var(--danger)]">{error}</p>}557558 <div className="mt-6 grid gap-8 lg:grid-cols-[minmax(0,420px)_1fr]">559 {subject && (560 <div className="border border-[var(--line)]">561 <RadarMap subject={{ lat: subject.lat, lng: subject.lng, label: head.address ?? head.uid }} comps={radarComps} highlight={hover} onHover={setHover} />562 <p className="vp-mono border-t border-[var(--line)] px-3 py-2 text-[9.5px] uppercase tracking-[0.06em] text-ink-3">563 {fr ? "Gros cercles = comparables cochés. Survolez une ligne du tableau pour la repérer." : "Large circles = ticked comparables. Hover a table row to locate it."}564 </p>565 </div>566 )}567 <div className={`src-wrap ${loading ? "opacity-60" : ""}`}>568 <table className="src-table">569 <thead>570 <tr>571 <th></th>572 <th>#</th>573 <th>{fr ? "Propriété" : "Property"}</th>574 <th>{fr ? "Date" : "Date"}</th>575 <th className="text-right">{fr ? "Prix" : "Price"}</th>576 <th className="text-right">{fr ? "Dist." : "Dist."}</th>577 <th className="text-right">m²</th>578 <th className="text-right">{fr ? "Année" : "Year"}</th>579 <th>{fr ? "Type" : "Type"}</th>580 </tr>581 </thead>582 <tbody>583 {visible.map((c, i) => {584 const on = selected.includes(c.id);585 return (586 <tr587 key={c.id}588 onMouseEnter={() => setHover(c.id)}589 onMouseLeave={() => setHover(null)}590 onClick={() => toggle(c)}591 className={`cursor-pointer ${on ? "bg-[var(--accent-soft)]" : ""} ${hover === c.id ? "outline outline-1 outline-[var(--accent)]" : ""}`}592 >593 <td>594 <input type="checkbox" checked={on} onChange={() => toggle(c)} onClick={(e) => e.stopPropagation()} className="h-4 w-4 accent-[var(--accent)]" aria-label={fr ? "Retenir ce comparable" : "Keep this comparable"} />595 </td>596 <td className="vp-mono text-[11px] text-ink-3">{i + 1}</td>597 <td>598 <p className="font-semibold leading-snug">{c.street ?? "—"}</p>599 <p className="vp-mono text-[10px] uppercase tracking-[0.05em] text-ink-3">600 {c.city ?? ""}601 {c.engineUsed ? <span className="ml-2 border border-ink px-1 text-ink">{fr ? "moteur" : "engine"}</span> : null}602 {c.kind === "listing" ? <span className="ml-2 border border-[var(--danger)] px-1 text-[var(--danger)]">{fr ? "en vente" : "for sale"}</span> : null}603 </p>604 </td>605 <td className="vp-mono whitespace-nowrap text-[12px]">{c.kind === "listing" ? (fr ? "actuel" : "current") : dateFr(c.date, lang)}</td>606 <td className="vp-mono whitespace-nowrap text-right font-bold">{money(c.amount, lang)}</td>607 <td className="vp-mono whitespace-nowrap text-right text-[12px]">{fmtDist(c.distanceM)}</td>608 <td className="vp-mono text-right text-[12px]">{c.floorArea ? num(c.floorArea, lang) : "—"}</td>609 <td className="vp-mono text-right text-[12px]">{c.yearBuilt ?? "—"}</td>610 <td className="text-[12px] text-ink-2">{c.propertyType ?? "—"}</td>611 </tr>612 );613 })}614 {!loading && data && visible.length === 0 && (615 <tr>616 <td colSpan={9} className="text-center text-ink-3">617 {fr ? "Aucun candidat avec ces critères — élargissez le rayon ou la fenêtre." : "No candidate with these criteria — widen the radius or window."}618 </td>619 </tr>620 )}621 </tbody>622 </table>623 {pool.length > visible.length && (624 <button type="button" className="btn btn-ghost mt-3 w-full print:hidden" onClick={() => setShowAll(true)}>625 {fr ? `Voir les ${pool.length - visible.length} autres candidats` : `Show the ${pool.length - visible.length} other candidates`}626 </button>627 )}628 </div>629 </div>630 </section>631632 {/* ================= 02 — ajustements ================= */}633 <section>634 <Sect num="02" kicker={fr ? "Grille" : "Grid"} title={fr ? "Ajuster chaque comparable au sujet" : "Adjust each comparable to the subject"} />635 <p className="mt-3 max-w-3xl text-[13.5px] leading-relaxed text-ink-2">636 {fr637 ? "On ajuste le comparable vers le sujet : si le comparable est plus petit, on ajoute ; s'il est meilleur, on retire. Les trois ajustements du moteur sont pré-remplis (marché = indice mensuel du type ; superficie = 50 % du $/m² marginal, borné à ±25 % ; âge = 0,5 %/an, borné à ±10 %). Modifiez-les, ajoutez les vôtres (garage, terrain, état, vue…) et pondérez de 1 à 5."638 : "Comparables are adjusted toward the subject: smaller comparable → add; better comparable → subtract. The engine's three adjustments are prefilled (market = monthly type index; area = 50% of marginal $/m², capped ±25%; age = 0.5%/yr, capped ±10%). Edit them, add your own (garage, lot, condition, view…) and weight 1 to 5."}639 </p>640 {chosen.length === 0 ? (641 <p className="mt-6 border border-dashed border-[var(--line-strong)] p-6 text-center text-[13.5px] text-ink-3">642 {fr ? "Cochez au moins un comparable ci-dessus pour ouvrir la grille." : "Tick at least one comparable above to open the grid."}643 </p>644 ) : (645 <div className="mt-6 space-y-5">646 {chosen.map(({ c, s }, i) => {647 const adj = adjustedOf(c, s);648 const gross = grossAdjPct(c, s);649 return (650 <article key={c.id} className="border border-[var(--line)] bg-surface p-4 sm:p-5">651 <header className="flex flex-wrap items-baseline justify-between gap-2 border-b border-[var(--line)] pb-3">652 <div className="min-w-0">653 <p className="vp-display text-[15px] font-bold uppercase tracking-[-0.01em]">654 <span className="vp-mono mr-2 text-[11px] text-accent">C{i + 1}</span>655 {c.street ?? "—"}656 {c.city ? <span className="text-ink-3"> · {c.city}</span> : null}657 </p>658 <p className="vp-mono mt-0.5 text-[10px] uppercase tracking-[0.05em] text-ink-3">659 {c.kind === "listing" ? (fr ? "annonce active — prix demandé" : "active listing — asking price") : `${fr ? "vendu le" : "sold"} ${dateFr(c.date, lang)}`} · {fmtDist(c.distanceM)}660 {c.floorArea ? ` · ${num(c.floorArea, lang)} m²` : ""}661 {c.yearBuilt ? ` · ${c.yearBuilt}` : ""}662 {c.propertyType ? ` · ${c.propertyType}` : ""}663 {c.engineUsed ? ` · ${fr ? "retenu par le moteur" : "kept by the engine"}` : ""}664 {c.idProvinc ? (665 <>666 {" · "}667 <Link href={`/estimation/${encodeURIComponent(c.idProvinc)}`} className="text-accent underline-offset-2 hover:underline">668 {fr ? "fiche" : "record"}669 </Link>670 </>671 ) : null}672 {c.uid ? (673 <>674 {" · "}675 <Link href={`/a-vendre/${encodeURIComponent(c.uid)}`} className="text-accent underline-offset-2 hover:underline">676 {fr ? "annonce" : "listing"}677 </Link>678 </>679 ) : null}680 </p>681 </div>682 <div className="flex items-center gap-2 print:hidden">683 <button type="button" className="vp-mono text-[10.5px] uppercase tracking-[0.08em] text-ink-2 hover:text-accent-deep" onClick={() => patchState(c.id, { adjTime: c.adjTime, adjArea: c.adjArea, adjAge: c.adjAge, extras: [], rev: (s.rev ?? 0) + 1 })}>684 ↺ {fr ? "moteur" : "engine"}685 </button>686 <button type="button" className="vp-mono text-[10.5px] uppercase tracking-[0.08em] text-[var(--danger)]" onClick={() => setSelected((p) => p.filter((x) => x !== c.id))}>687 ✕ {fr ? "retirer" : "remove"}688 </button>689 </div>690 </header>691 <div className="mt-4 grid gap-x-6 gap-y-4 sm:grid-cols-2 lg:grid-cols-[1fr_1fr_1fr_1fr_1.2fr]">692 <div>693 <p className="klabel">{c.kind === "listing" ? (fr ? "Prix demandé" : "Asking price") : fr ? "Prix de vente" : "Sale price"}</p>694 <p className="vp-display mt-1 text-[20px] font-bold tracking-[-0.02em]">{money(c.amount, lang)}</p>695 {c.valeurRole ? <p className="vp-mono text-[9.5px] uppercase tracking-[0.05em] text-ink-3">{fr ? "rôle" : "roll"} {money(c.valeurRole, lang)}</p> : null}696 </div>697 <MoneyInput698 key={`t${s.rev ?? 0}`}699 label={fr ? "Ajust. marché ($)" : "Market adj. ($)"}700 value={s.adjTime}701 onChange={(v) => patchState(c.id, { adjTime: v })}702 hint={`${fr ? "moteur" : "engine"} ${signedMoney(c.adjTime, lang)} · ${c.kind === "listing" ? (fr ? "actuel" : "current") : `${num(c.monthsAgo, lang)} ${fr ? "mois" : "mo"}`}`}703 />704 <MoneyInput705 key={`a${s.rev ?? 0}`}706 label={fr ? "Ajust. superficie ($)" : "Area adj. ($)"}707 value={s.adjArea}708 onChange={(v) => patchState(c.id, { adjArea: v })}709 hint={`${fr ? "moteur" : "engine"} ${signedMoney(c.adjArea, lang)}${subjArea && c.floorArea ? ` · Δ ${subjArea - c.floorArea >= 0 ? "+" : "−"}${num(Math.abs(subjArea - c.floorArea), lang)} m²` : ""}`}710 />711 <MoneyInput712 key={`g${s.rev ?? 0}`}713 label={fr ? "Ajust. âge ($)" : "Age adj. ($)"}714 value={s.adjAge}715 onChange={(v) => patchState(c.id, { adjAge: v })}716 hint={`${fr ? "moteur" : "engine"} ${signedMoney(c.adjAge, lang)}${(subject?.yearBuilt ?? head.yearBuilt) && c.yearBuilt ? ` · Δ ${(subject?.yearBuilt ?? head.yearBuilt)! - c.yearBuilt} ${fr ? "ans" : "yrs"}` : ""}`}717 />718 <div className="border-l-2 border-accent pl-4">719 <p className="klabel">{fr ? "Prix ajusté" : "Adjusted price"}</p>720 <p className="vp-display mt-1 text-[24px] font-bold tracking-[-0.02em]">{money(adj, lang)}</p>721 <p className={`vp-mono text-[9.5px] uppercase tracking-[0.05em] ${gross > 25 ? "text-[var(--danger)]" : "text-ink-3"}`}>722 {fr ? "ajust. brut" : "gross adj."} {pct(gross, lang, 1, false)}723 {gross > 25 ? (fr ? " — comparable faible" : " — weak comparable") : ""}724 </p>725 </div>726 </div>727 {/* ajustements libres */}728 <div className="mt-4 grid gap-x-6 gap-y-3 sm:grid-cols-[1fr_auto]">729 <div className="space-y-2">730 {s.extras.map((x) => (731 <div key={x.id} className="grid grid-cols-[1fr_140px_auto] items-end gap-3">732 <label className="block">733 <span className="klabel">{fr ? "Ajustement libre" : "Custom adjustment"}</span>734 <input value={x.label} onChange={(e) => patchState(c.id, { extras: s.extras.map((y) => (y.id === x.id ? { ...y, label: e.target.value } : y)) })} placeholder={fr ? "ex. garage, piscine, état, vue…" : "e.g. garage, pool, condition, view…"} className="vp-input mt-0.5 text-[13px]" />735 </label>736 <MoneyInput label="$" value={x.amount} onChange={(v) => patchState(c.id, { extras: s.extras.map((y) => (y.id === x.id ? { ...y, amount: v } : y)) })} />737 <button type="button" className="vp-mono pb-2 text-[11px] text-[var(--danger)] print:hidden" onClick={() => patchState(c.id, { extras: s.extras.filter((y) => y.id !== x.id) })} aria-label={fr ? "Supprimer" : "Delete"}>738 ✕739 </button>740 </div>741 ))}742 <button type="button" className="vp-mono text-[10.5px] font-bold uppercase tracking-[0.08em] text-accent print:hidden" onClick={() => patchState(c.id, { extras: [...s.extras, { id: `${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, label: "", amount: 0 }] })}>743 + {fr ? "ajouter un ajustement" : "add an adjustment"}744 </button>745 </div>746 <div>747 <p className="klabel">{fr ? "Poids (1-5)" : "Weight (1-5)"}</p>748 <div className="mt-1 flex gap-1">749 {[1, 2, 3, 4, 5].map((w) => (750 <button key={w} type="button" onClick={() => patchState(c.id, { weight: w })} className={`vp-mono h-8 w-8 border text-[12px] font-bold ${s.weight === w ? "border-ink bg-ink text-paper" : "border-[var(--line-strong)] text-ink-2"}`} aria-pressed={s.weight === w}>751 {w}752 </button>753 ))}754 </div>755 </div>756 </div>757 </article>758 );759 })}760 <p className="vp-mono overflow-x-auto whitespace-nowrap border-l-2 border-accent bg-surface px-3 py-2 text-[11.5px] text-ink">761 {fr ? "prix ajusté = prix + ajust. marché + ajust. superficie + ajust. âge + Σ ajustements libres" : "adjusted price = price + market adj. + area adj. + age adj. + Σ custom adjustments"}762 </p>763 </div>764 )}765 </section>766767 {/* ================= 03 — réconciliation ================= */}768 <section>769 <Sect num="03" kicker={fr ? "Conclusion" : "Conclusion"} title={fr ? "Réconcilier ma valeur" : "Reconcile my value"} />770 {chosen.length === 0 ? (771 <p className="mt-6 text-[13.5px] text-ink-3">{fr ? "La réconciliation s'ouvre dès qu'un comparable est retenu." : "Reconciliation opens as soon as one comparable is kept."}</p>772 ) : (773 <div className="mt-6 grid gap-x-12 gap-y-8 lg:grid-cols-[1.3fr_1fr]">774 <div>775 <p className="klabel">{fr ? "Indications de valeur selon la règle de réconciliation" : "Value indications by reconciliation rule"}</p>776 <div className="mt-2 grid grid-cols-2 gap-3 sm:grid-cols-4">777 {(778 [779 ["median", fr ? "Médiane" : "Median"],780 ["mean", fr ? "Moyenne" : "Mean"],781 ["wmean", fr ? "Moyenne pondérée" : "Weighted mean"],782 ["wmedian", fr ? "Médiane pondérée" : "Weighted median"],783 ] as [Method, string][]784 ).map(([k, label]) => (785 <button key={k} type="button" onClick={() => setMethod(k)} aria-pressed={method === k} className={`border p-3 text-left ${method === k ? "border-ink bg-ink text-paper" : "border-[var(--line)] bg-surface hover:border-[var(--line-strong)]"}`}>786 <p className={`vp-mono text-[9.5px] uppercase tracking-[0.08em] ${method === k ? "text-paper/70" : "text-ink-3"}`}>{label}</p>787 <p className="vp-display mt-1 text-[17px] font-bold tracking-[-0.02em]">{indications[k] != null ? money(round100(indications[k]!), lang) : "—"}</p>788 </button>789 ))}790 </div>791 <div className="mt-6 grid grid-cols-2 gap-x-6 gap-y-3 sm:grid-cols-4">792 <div className="kv-cell">793 <p className="k">{fr ? "Comparables" : "Comparables"}</p>794 <p className="v">795 {chosen.length} <span className="text-ink-3">({engineOverlap} {fr ? "du moteur" : "engine"})</span>796 </p>797 </div>798 <div className="kv-cell">799 <p className="k">{fr ? "Étendue ajustée" : "Adjusted range"}</p>800 <p className="v text-[12.5px]">801 {money(Math.min(...adjusted), lang)} – {money(Math.max(...adjusted), lang)}802 </p>803 </div>804 <div className="kv-cell">805 <p className="k">{fr ? "Dispersion médiane" : "Median dispersion"}</p>806 <p className="v">{dispersion != null ? `±${pct(dispersion, lang, 1, false)}` : "—"}</p>807 </div>808 <div className="kv-cell">809 <p className="k">{fr ? "Ajust. brut moyen" : "Avg gross adj."}</p>810 <p className={`v ${avgGross != null && avgGross > 25 ? "text-[var(--danger)]" : ""}`}>{avgGross != null ? pct(avgGross, lang, 1, false) : "—"}</p>811 </div>812 </div>813 <p className="mt-4 max-w-2xl text-[13px] leading-relaxed text-ink-2">814 {fr815 ? "La médiane résiste aux valeurs extrêmes ; la moyenne pondérée laisse parler votre confiance dans chaque comparable. Le rapport final ne retient qu'UNE valeur conclue, arrondie, avec une fourchette qui reflète la dispersion observée."816 : "The median resists outliers; the weighted mean lets your confidence in each comparable speak. The final report keeps ONE concluded value, rounded, with a range reflecting the observed dispersion."}817 </p>818 </div>819 <div className="lg:border-l lg:border-[var(--line)] lg:pl-10">820 <label className="block">821 <span className="klabel">{fr ? "Ma valeur conclue ($)" : "My concluded value ($)"}</span>822 <input823 value={finalTxt !== "" ? finalTxt : myValue != null ? String(Math.round(myValue)) : ""}824 inputMode="numeric"825 onChange={(e) => {826 const t = e.target.value.replace(/\D/g, "");827 setFinalTxt(t);828 setFinalValue(t ? Number(t) : null);829 }}830 className="vp-input vp-display mt-1 text-[32px] font-bold tracking-[-0.03em]"831 />832 </label>833 <button834 type="button"835 className="vp-mono mt-2 text-[10.5px] font-bold uppercase tracking-[0.08em] text-accent print:hidden"836 onClick={() => {837 setFinalValue(null);838 setFinalTxt("");839 }}840 >841 ↺ {fr ? "reprendre l'indication" : "take the indication"} ({indication != null ? money(round100(indication), lang) : "—"})842 </button>843 <label className="mt-6 block">844 <span className="klabel">845 {fr ? "Fourchette" : "Range"} ±{rangePct} %846 </span>847 <input type="range" min={1} max={30} value={rangePct} onChange={(e) => setRangePct(Number(e.target.value))} className="mt-2 w-full accent-[var(--accent)]" />848 </label>849 {myValue != null && (850 <p className="vp-mono mt-2 text-[11px] uppercase tracking-[0.05em] text-ink-2">851 {money(round100(myValue * (1 - rangePct / 100)), lang)} – {money(round100(myValue * (1 + rangePct / 100)), lang)}852 {subjArea ? ` · ${num(Math.round(myValue / subjArea), lang)} $/m²` : ""}853 </p>854 )}855 {dispersion != null && (856 <button type="button" className="vp-mono mt-2 text-[10px] uppercase tracking-[0.08em] text-ink-3 print:hidden" onClick={() => setRangePct(Math.max(1, Math.min(30, Math.round(dispersion))))}>857 {fr ? "→ aligner sur la dispersion" : "→ align on dispersion"} (±{Math.round(dispersion)} %)858 </button>859 )}860 </div>861 </div>862 )}863 </section>864865 {/* ================= 04 — comparaison ================= */}866 <section>867 <Sect num="04" kicker={fr ? "Confrontation" : "Confrontation"} title={fr ? "Ma valeur face à la mesure Vrai-Prix" : "My value vs. the Vrai-Prix measure"} />868 {myValue == null ? (869 <p className="mt-6 text-[13.5px] text-ink-3">{fr ? "Concluez une valeur pour la comparer." : "Conclude a value to compare it."}</p>870 ) : (871 <div className="mt-6 space-y-8">872 <div className="border border-[var(--line)] bg-surface p-3 sm:p-5">873 <DotPlot rows={rows} me={myValue} lang={lang} />874 <p className="vp-mono mt-1 text-[9.5px] uppercase tracking-[0.06em] text-ink-3">875 {fr ? "Bandes : ma fourchette (bleu) et l'intervalle P10-P90 de Vrai-Prix (encre). Trait pointillé : ma valeur." : "Bands: my range (blue) and the Vrai-Prix P10-P90 interval (ink). Dashed line: my value."}876 </p>877 </div>878 <div className="grid gap-x-12 gap-y-8 lg:grid-cols-[1.2fr_1fr]">879 <div className="src-wrap">880 <table className="src-table">881 <thead>882 <tr>883 <th>{fr ? "Repère" : "Benchmark"}</th>884 <th className="text-right">{fr ? "Valeur" : "Value"}</th>885 <th className="text-right">{fr ? "Ma valeur − repère" : "My value − benchmark"}</th>886 <th className="text-right">%</th>887 </tr>888 </thead>889 <tbody>890 {rows891 .filter((r) => r.key !== "me")892 .map((r) => {893 const d = r.value != null ? myValue - r.value : null;894 const p = diffPct(r.value);895 return (896 <tr key={r.key} className={r.tone === "main" ? "font-bold" : ""}>897 <td>{r.label}</td>898 <td className="vp-mono text-right">{money(r.value, lang)}</td>899 <td className={`vp-mono text-right ${d != null && d < 0 ? "text-[var(--danger)]" : ""}`}>{d != null ? signedMoney(d, lang) : "—"}</td>900 <td className={`vp-mono text-right ${p != null && p < 0 ? "text-[var(--danger)]" : ""}`}>{pct(p, lang, 1)}</td>901 </tr>902 );903 })}904 </tbody>905 </table>906 </div>907 <div className="space-y-4 text-[14px] leading-relaxed text-ink-2 lg:border-l lg:border-[var(--line)] lg:pl-10">908 <p className="klabel">{fr ? "Lecture" : "Reading"}</p>909 {vpMain != null && (910 <p>911 {fr ? (912 <>913 Votre valeur conclue (<b className="text-ink">{money(myValue, lang)}</b>) est{" "}914 <b className="text-ink">{pct(diffPct(vpMain), lang, 1)}</b> par rapport à la mesure principale Vrai-Prix ({money(vpMain, lang)}915 {ev?.confidence ? `, confiance ${ev.confidence}` : ""}).{" "}916 {inBand === true ? "Elle se situe DANS l'intervalle P10-P90 de Vrai-Prix : les deux lectures sont compatibles." : inBand === false ? "Elle se situe HORS de l'intervalle P10-P90 de Vrai-Prix : l'écart mérite d'être expliqué (comparables différents, ajustements, superficie retenue)." : ""}917 </>918 ) : (919 <>920 Your concluded value (<b className="text-ink">{money(myValue, lang)}</b>) is <b className="text-ink">{pct(diffPct(vpMain), lang, 1)}</b> relative to the main Vrai-Prix measure ({money(vpMain, lang)}921 {ev?.confidence ? `, confidence ${ev.confidence}` : ""}).{" "}922 {inBand === true ? "It lies WITHIN the Vrai-Prix P10-P90 interval: both readings are compatible." : inBand === false ? "It lies OUTSIDE the Vrai-Prix P10-P90 interval: the gap deserves an explanation (different comparables, adjustments, retained area)." : ""}923 </>924 )}925 </p>926 )}927 <p>928 {fr ? (929 <>930 Face au prix demandé ({money(head.price, lang)}), votre évaluation suggère un affichage{" "}931 <b className="text-ink">{myValue < head.price ? `au-dessus de la valeur de ${pct(((head.price - myValue) / myValue) * 100, lang, 1, false)}` : `en dessous de la valeur de ${pct(((myValue - head.price) / myValue) * 100, lang, 1, false)}`}</b>. Rappel : un prix demandé n'est pas une valeur — le marché tranche à la vente.932 </>933 ) : (934 <>935 Against the asking price ({money(head.price, lang)}), your valuation suggests a listing{" "}936 <b className="text-ink">{myValue < head.price ? `priced ${pct(((head.price - myValue) / myValue) * 100, lang, 1, false)} above value` : `priced ${pct(((myValue - head.price) / myValue) * 100, lang, 1, false)} below value`}</b>. Reminder: an asking price is not a value — the market decides at the sale.937 </>938 )}939 </p>940 <p>941 {fr942 ? `${engineOverlap} de vos ${chosen.length} comparables font partie des ${ev?.nComps ?? data?.engine.nComps ?? 12} retenus par le moteur${chosen.some(({ c }) => c.kind === "listing") ? " ; vous avez aussi retenu des annonces actives (prix demandés, non des ventes — à pondérer avec prudence)" : ""}.`943 : `${engineOverlap} of your ${chosen.length} comparables are among the ${ev?.nComps ?? data?.engine.nComps ?? 12} kept by the engine${chosen.some(({ c }) => c.kind === "listing") ? "; you also kept active listings (asking prices, not sales — weight with caution)" : ""}.`}944 </p>945 {ev?.evaluatedAt && (946 <p className="vp-mono text-[10px] uppercase tracking-[0.05em] text-ink-3">947 {fr ? "Mesure Vrai-Prix calculée le" : "Vrai-Prix measure computed on"} {ev.evaluatedAt}948 {ev.unitId ? ` · ${fr ? "unité" : "unit"} ${ev.unitId}` : ""}949 </p>950 )}951 {!ev && (952 <p className="vp-mono text-[10px] uppercase tracking-[0.05em] text-ink-3">953 {fr ? "Aucune mesure stockée pour cette annonce : repère Vrai-Prix calculé à la volée (comparables du moteur seulement)." : "No stored measure for this listing: Vrai-Prix benchmark computed on the fly (engine comparables only)."}954 </p>955 )}956 </div>957 </div>958 </div>959 )}960 </section>961962 <p className="vp-mono border-t border-[var(--line)] pt-3 text-[10px] uppercase tracking-[0.05em] text-ink-3">963 {fr964 ? "Atelier d'évaluation — vos choix restent sur cet appareil. Ni la mesure Vrai-Prix ni votre évaluation ne constituent une évaluation professionnelle certifiée (OEAQ)."965 : "Valuation workshop — your choices stay on this device. Neither the Vrai-Prix measure nor your valuation is a certified professional appraisal (OEAQ)."}966 </p>967 </div>968 );969}970971// utilitaires exposés pour les tests972export { adjustedOf, grossAdjPct };973