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 — méthode du coût : l'ATELIER (/cout?property=… | ?mode=construction | ?estimate=…).2// Deux modes, neuf sections, recalcul déterministe à chaque changement (debounce), état local persistant,3// mode exercice (masquer / estimer soi-même / révéler). Aucun calcul métier ici : tout passe par POST /api/cost/estimate.4"use client";5import { useEffect, useRef, useState } from "react";6import Link from "next/link";7import { useLang } from "@/components/LangContext";8import type { AttributeSource, BuildingSpec, CostEstimate, CostInput, CostLocation, CostParams, DepreciationInput, LandInput } from "@/lib/cost/types";9import { defaultInput } from "@/lib/cost/estimate-defaults";10import { money, num } from "@/components/avendre/fmt";11import { PropertyPanel, BuildingFormPanel, type PrefillMeta } from "./BuildingForm";12import { AssemblyLinesPanel, CostTablePanel, LabourPanel } from "./CostPanels";13import { DepreciationPanel, LandPanel, ResultPanel, SourcesPanel } from "./ResultPanels";14import { Toggle } from "./fields";1516interface PrefillResponse extends PrefillMeta { input: CostInput; otherReadings: CostEstimate["otherReadings"]; recent: { id: string; createdAt: string; rcn: number; value: number; letter: string }[] }1718const SECTIONS = ["01", "02", "03", "04", "05", "06", "07", "08", "09"];19const NAMES_FR = ["Propriété", "Caractéristiques", "Coût à neuf", "Assemblages", "Main-d'œuvre", "Dépréciation", "Terrain", "Résultat", "Sources"];20const NAMES_EN = ["Property", "Characteristics", "Cost new", "Assemblies", "Labour", "Depreciation", "Land", "Result", "Sources"];2122export default function Workbench({ propertyId, estimateId, locations }: { propertyId: string | null; estimateId: string | null; locations: CostLocation[] }) {23 const { lang } = useLang();24 const fr = lang === "fr";25 const storageKey = `vrai-prix-cout:${propertyId ?? "construction"}`;26 const [input, setInput] = useState<CostInput | null>(null);27 const [meta, setMeta] = useState<PrefillMeta | null>(null);28 const [est, setEst] = useState<CostEstimate | null>(null);29 const [computedKey, setComputedKey] = useState<string>("");30 const [error, setError] = useState<string | null>(null);31 const [saving, setSaving] = useState(false);32 const [savedId, setSavedId] = useState<string | null>(estimateId);33 const [exercise, setExercise] = useState(false);34 const [hideResult, setHideResult] = useState(false);35 const [hideCosts, setHideCosts] = useState(false);36 const [answer, setAnswer] = useState("");37 const [revealed, setRevealed] = useState(false);38 const [restored, setRestored] = useState(false);39 const timer = useRef<ReturnType<typeof setTimeout> | null>(null);4041 /* ---------------------------------------------------- chargement initial */42 useEffect(() => {43 let cancelled = false;44 (async () => {45 try {46 if (estimateId) {47 const r = await fetch(`/api/cost/estimate/${encodeURIComponent(estimateId)}`);48 if (!r.ok) throw new Error((await r.json()).error ?? r.statusText);49 const e = (await r.json()) as CostEstimate;50 if (cancelled) return;51 setEst(e); setInput(e.input); setComputedKey(JSON.stringify(e.input));52 setMeta({ unit: null, missing: [], location: e.location, locationMethod: null });53 if (e.input.propertyId) {54 const p = await fetch(`/api/cost/property?id=${encodeURIComponent(e.input.propertyId)}&readings=0`);55 if (p.ok && !cancelled) { const d = (await p.json()) as PrefillResponse; setMeta({ unit: d.unit, missing: d.missing, location: d.location, locationMethod: d.locationMethod }); }56 }57 return;58 }59 let saved: CostInput | null = null;60 try { const raw = window.localStorage.getItem(storageKey); if (raw) saved = JSON.parse(raw) as CostInput; } catch { /* ignore */ }61 if (propertyId) {62 const r = await fetch(`/api/cost/property?id=${encodeURIComponent(propertyId)}`);63 if (!r.ok) throw new Error((await r.json()).error ?? r.statusText);64 const d = (await r.json()) as PrefillResponse;65 if (cancelled) return;66 setMeta({ unit: d.unit, missing: d.missing, location: d.location, locationMethod: d.locationMethod });67 const use = saved && saved.propertyId === propertyId && saved.roll?.totalValue === d.input.roll?.totalValue ? { ...saved, roll: d.input.roll, lat: d.input.lat, lng: d.input.lng } : d.input;68 setInput(use); setRestored(!!saved && use === saved);69 } else {70 const base = saved && saved.mode === "construction" ? saved : defaultInput();71 if (cancelled) return;72 setInput(base); setRestored(!!saved && saved.mode === "construction");73 setMeta({ unit: null, missing: [], location: null, locationMethod: null });74 }75 } catch (e) { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); }76 })();77 return () => { cancelled = true; };78 }, [propertyId, estimateId, storageKey]);7980 /* ---------------------------------------------------- recalcul (debounce) */81 const key = input ? JSON.stringify(input) : "";82 useEffect(() => {83 if (!input || key === computedKey) return;84 if (timer.current) clearTimeout(timer.current);85 timer.current = setTimeout(async () => {86 try {87 const r = await fetch("/api/cost/estimate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ input, save: false }) });88 const d = await r.json();89 if (!r.ok) throw new Error(d.error ?? r.statusText);90 setEst(d as CostEstimate); setComputedKey(key); setError(null);91 try { window.localStorage.setItem(storageKey, key); } catch { /* quota */ }92 } catch (e) { setError(e instanceof Error ? e.message : String(e)); }93 }, 400);94 return () => { if (timer.current) clearTimeout(timer.current); };95 }, [key, computedKey, input, storageKey]);96 const pending = !!input && key !== computedKey;9798 /* ----------------------------------------------------------- mutateurs */99 const patch = (p: Partial<CostInput>) => setInput((i) => (i ? { ...i, ...p } : i));100 const patchB = (p: Partial<BuildingSpec>) => setInput((i) => (i ? { ...i, building: { ...i.building, ...p } } : i));101 const patchP = (p: Partial<CostParams>) => setInput((i) => (i ? { ...i, params: { ...i.params, ...p } } : i));102 const patchD = (p: Partial<DepreciationInput>) => setInput((i) => (i ? { ...i, depreciation: { ...i.depreciation, ...p } } : i));103 const patchL = (p: Partial<LandInput>) => setInput((i) => (i ? { ...i, land: { ...i.land, ...p } } : i));104 const setSrc = (k: string, s: AttributeSource) => setInput((i) => (i ? { ...i, attributeSources: { ...i.attributeSources, [k]: s } } : i));105 const setQty = (code: string, q: number | null) => setInput((i) => { if (!i) return i; const o = { ...i.quantityOverrides }; if (q == null) delete o[code]; else o[code] = q; return { ...i, quantityOverrides: o }; });106 const toggleExclude = (code: string) => setInput((i) => (i ? { ...i, excludedAssemblies: i.excludedAssemblies.includes(code) ? i.excludedAssemblies.filter((c) => c !== code) : [...i.excludedAssemblies, code] } : i));107108 const save = async () => {109 if (!input) return;110 setSaving(true);111 try {112 const r = await fetch("/api/cost/estimate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ input, save: true }) });113 const d = await r.json();114 if (!r.ok) throw new Error(d.error ?? r.statusText);115 setEst(d as CostEstimate); setComputedKey(key); setSavedId((d as CostEstimate).id);116 const url = new URL(window.location.href); url.searchParams.set("estimate", (d as CostEstimate).id); window.history.replaceState(null, "", url.toString());117 } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setSaving(false); }118 };119 const refreshPrices = () => { patch({ priceDate: null }); setComputedKey(""); };120 const reset = () => { try { window.localStorage.removeItem(storageKey); } catch { /* ignore */ } window.location.href = propertyId ? `/cout?property=${encodeURIComponent(propertyId)}` : "/cout?mode=construction"; };121122 if (error && !input) return <div className="py-16"><p className="vp-display text-[22px] font-bold">{fr ? "Impossible de charger l'atelier" : "Cannot load the workshop"}</p><p className="mt-2 text-ink-2">{error}</p><Link href="/cout" className="btn btn-ghost mt-4">← {fr ? "Retour" : "Back"}</Link></div>;123 if (!input) return <div className="py-16 vp-mono text-[11px] uppercase tracking-[0.08em] text-ink-3">{fr ? "Chargement de la propriété…" : "Loading property…"}</div>;124125 const hide = exercise && hideResult;126 const hideC = exercise && hideCosts;127 const structure = est?.categories.find((c) => c.category === "structure");128 const structureAnswer = (129 <div className="border border-[var(--line-strong)] bg-surface p-4">130 <p className="kicker">{fr ? "Exercice" : "Exercise"}</p>131 <p className="vp-display mt-1 text-[16px] font-bold">{fr ? "Calculez le coût direct de la structure." : "Compute the direct cost of the structure."}</p>132 <p className="mt-1 text-[12.5px] text-ink-2">{fr ? "À partir des lignes de la catégorie Structure (section 04) : Σ quantité × coût unitaire, puis × facteur régional. Saisissez votre réponse, puis comparez." : "From the Structure lines (section 04): Σ quantity × unit cost, then × regional factor. Enter your answer, then compare."}</p>133 <div className="mt-2 flex flex-wrap items-center gap-3">134 <input value={answer} onChange={(e) => setAnswer(e.target.value)} inputMode="numeric" placeholder="$" className="vp-input vp-mono max-w-[200px] text-[14px]" />135 <button type="button" className="btn btn-primary !min-h-0 !py-2" onClick={() => setRevealed(true)}>{fr ? "Voir la correction" : "See the correction"}</button>136 {revealed && <button type="button" className="btn btn-ghost !min-h-0 !py-2" onClick={() => { setRevealed(false); setAnswer(""); }}>{fr ? "Recommencer" : "Try again"}</button>}137 </div>138 {revealed && structure && est && (139 <div className="mt-3">140 <p className="vp-mono text-[12px]">{fr ? "Réponse du moteur" : "Engine answer"} : <b>{money(structure.adjusted, lang)}</b>{answer && Number(answer.replace(/[^\d.]/g, "")) > 0 ? ` · ${fr ? "votre réponse" : "your answer"} ${money(Number(answer.replace(/[^\d.]/g, "")), lang)} (${fr ? "écart" : "gap"} ${num(((Number(answer.replace(/[^\d.]/g, "")) / structure.adjusted) - 1) * 100, lang, 1)} %)` : ""}</p>141 <div className="src-wrap mt-2"><table className="src-table"><thead><tr><th>{fr ? "Assemblage" : "Assembly"}</th><th className="text-right">{fr ? "Quantité" : "Quantity"}</th><th className="text-right">×</th><th className="text-right">{fr ? "Coût unitaire" : "Unit cost"}</th><th className="text-right">=</th><th className="text-right">{fr ? "Direct" : "Direct"}</th></tr></thead><tbody>142 {est.lines.filter((l) => l.category === "structure").map((l) => (<tr key={l.assemblyCode}><td>{fr ? l.nameFr : l.nameEn}</td><td className="vp-mono text-right">{num(l.quantity, lang, 1)} {l.unit}</td><td className="text-right">×</td><td className="vp-mono text-right">{num(l.unitCost, lang, 2)} $</td><td className="text-right">=</td><td className="vp-mono text-right">{money(l.direct, lang)}</td></tr>))}143 <tr className="bg-surface-2 font-bold"><td colSpan={5}>{fr ? "Direct × facteur régional" : "Direct × regional factor"} (M {est.location.materialFactor} · MO {est.location.labourFactor} · É {est.location.equipmentFactor})</td><td className="vp-mono text-right">{money(structure.adjusted, lang)}</td></tr>144 </tbody></table></div>145 </div>146 )}147 </div>148 );149150 return (151 <div className="py-8">152 {/* masthead */}153 <section className="rise">154 <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 border-b-2 border-ink pb-2.5">155 <nav className="vp-mono flex flex-wrap items-center gap-2 text-[10.5px] uppercase tracking-[0.08em] text-ink-3">156 <Link href="/" className="text-ink-2 hover:text-accent-deep">Vrai Prix</Link><span aria-hidden="true">/</span>157 <Link href="/cout" className="text-ink-2 hover:text-accent-deep">{fr ? "Coût" : "Cost"}</Link><span aria-hidden="true">/</span>158 <span>{input.mode === "property" ? (fr ? "Atelier — propriété" : "Workshop — property") : (fr ? "Atelier — construction" : "Workshop — construction")}</span>159 </nav>160 <span className="vp-mono text-[10px] uppercase tracking-[0.08em] text-ink-3">{pending ? (fr ? "recalcul…" : "recomputing…") : est ? `${fr ? "calculé en" : "computed"} ${est.priceDate}` : ""}</span>161 </div>162 <div className="mt-5 grid gap-x-10 gap-y-4 lg:grid-cols-[1.5fr_1fr]">163 <div>164 <span className="kicker">{fr ? "Méthode du coût — atelier" : "Cost approach — workshop"}</span>165 <h1 className="vp-display mt-2 text-[clamp(24px,3.6vw,38px)] font-bold uppercase leading-[1.02] tracking-[-0.03em]">{input.address ?? (fr ? "Bâtiment hypothétique" : "Hypothetical building")}</h1>166 <p className="mt-1 text-[14px] text-ink-2">{input.municipality ?? (fr ? "Sans adresse — décrivez le bâtiment" : "No address — describe the building")}{est ? ` · ${fr ? est.location.nameFr : est.location.nameEn}` : ""}</p>167 </div>168 <aside className="flex flex-col gap-2 lg:items-end">169 <div className="flex flex-wrap gap-2">170 <button type="button" aria-pressed={exercise} onClick={() => setExercise((v) => !v)} className={`btn !min-h-0 !px-3 !py-1.5 text-[12px] ${exercise ? "btn-primary" : "btn-ghost"}`}>✎ {fr ? "Mode exercice" : "Exercise mode"}</button>171 <button type="button" onClick={reset} className="btn btn-ghost !min-h-0 !px-3 !py-1.5 text-[12px]">{fr ? "Réinitialiser" : "Reset"}</button>172 </div>173 {exercise && (174 <div className="flex flex-wrap gap-x-4 gap-y-1">175 <Toggle checked={hideResult} onChange={setHideResult} label={fr ? "Masquer le résultat final" : "Hide final result"} />176 <Toggle checked={hideCosts} onChange={setHideCosts} label={fr ? "Masquer les coûts des lignes" : "Hide line costs"} />177 </div>178 )}179 {restored && <p className="vp-mono text-[10px] uppercase tracking-[0.05em] text-ink-3">{fr ? "état restauré depuis cet appareil" : "state restored from this device"}</p>}180 </aside>181 </div>182 {/* sommaire des sections */}183 <ol className="vp-mono mt-5 flex flex-wrap gap-x-4 gap-y-1.5 border-y border-[var(--line)] py-2.5 text-[10px] uppercase tracking-[0.08em]">184 {SECTIONS.map((s, i) => (<li key={s}><a href={`#s${s}`} className="text-ink-2 hover:text-accent"><b className="text-accent">{s}</b> {fr ? NAMES_FR[i] : NAMES_EN[i]}</a></li>))}185 </ol>186 {error && <p className="mt-3 border-l-2 border-[var(--danger)] pl-3 text-[13px] text-[var(--danger)]">{error}</p>}187 </section>188189 <div className="mt-8 space-y-12">190 <PropertyPanel input={input} meta={meta} fr={fr} locations={locations} patch={patch} setSrc={setSrc} />191 <BuildingFormPanel b={input.building} src={input.attributeSources} fr={fr} patchB={patchB} setSrc={setSrc} />192 <CostTablePanel est={est} params={input.params} fr={fr} patchP={patchP} hide={hide || hideC} />193 <AssemblyLinesPanel est={est} input={input} fr={fr} setQty={setQty} toggleExclude={toggleExclude} hide={hideC} />194 <LabourPanel est={est} fr={fr} hide={hideC} />195 <DepreciationPanel est={est} dep={input.depreciation} fr={fr} patchD={patchD} hide={hide} />196 <LandPanel input={input} fr={fr} patchL={patchL} />197 <ResultPanel est={est} fr={fr} hide={hide} exercise={exercise} onSave={save} saving={saving} savedId={savedId} onRefreshPrices={refreshPrices} structureAnswer={structureAnswer} />198 <SourcesPanel est={est} fr={fr} />199 </div>200 <p className="vp-mono mt-10 border-l-2 border-accent pl-4 text-[11px] uppercase leading-relaxed tracking-[0.05em] text-ink-2">201 {fr ? "Estimation indicative — les coûts présentés sont des estimations statistiques et ne constituent ni une soumission d'entrepreneur ni une évaluation professionnelle certifiée (OEAQ)." : "Building analysis — the costs shown are estimates and constitute neither a contractor's bid nor a certified professional appraisal (OEAQ)."}202 </p>203 </div>204 );205}206