// Vrai-Prix — méthode du coût : l'ATELIER (/cout?property=… | ?mode=construction | ?estimate=…). // Deux modes, neuf sections, recalcul déterministe à chaque changement (debounce), état local persistant, // mode exercice (masquer / estimer soi-même / révéler). Aucun calcul métier ici : tout passe par POST /api/cost/estimate. "use client"; import { useEffect, useRef, useState } from "react"; import Link from "next/link"; import { useLang } from "@/components/LangContext"; import type { AttributeSource, BuildingSpec, CostEstimate, CostInput, CostLocation, CostParams, DepreciationInput, LandInput } from "@/lib/cost/types"; import { defaultInput } from "@/lib/cost/estimate-defaults"; import { money, num } from "@/components/avendre/fmt"; import { PropertyPanel, BuildingFormPanel, type PrefillMeta } from "./BuildingForm"; import { AssemblyLinesPanel, CostTablePanel, LabourPanel } from "./CostPanels"; import { DepreciationPanel, LandPanel, ResultPanel, SourcesPanel } from "./ResultPanels"; import { Toggle } from "./fields"; interface PrefillResponse extends PrefillMeta { input: CostInput; otherReadings: CostEstimate["otherReadings"]; recent: { id: string; createdAt: string; rcn: number; value: number; letter: string }[] } const SECTIONS = ["01", "02", "03", "04", "05", "06", "07", "08", "09"]; const NAMES_FR = ["Propriété", "Caractéristiques", "Coût à neuf", "Assemblages", "Main-d'œuvre", "Dépréciation", "Terrain", "Résultat", "Sources"]; const NAMES_EN = ["Property", "Characteristics", "Cost new", "Assemblies", "Labour", "Depreciation", "Land", "Result", "Sources"]; export default function Workbench({ propertyId, estimateId, locations }: { propertyId: string | null; estimateId: string | null; locations: CostLocation[] }) { const { lang } = useLang(); const fr = lang === "fr"; const storageKey = `vrai-prix-cout:${propertyId ?? "construction"}`; const [input, setInput] = useState(null); const [meta, setMeta] = useState(null); const [est, setEst] = useState(null); const [computedKey, setComputedKey] = useState(""); const [error, setError] = useState(null); const [saving, setSaving] = useState(false); const [savedId, setSavedId] = useState(estimateId); const [exercise, setExercise] = useState(false); const [hideResult, setHideResult] = useState(false); const [hideCosts, setHideCosts] = useState(false); const [answer, setAnswer] = useState(""); const [revealed, setRevealed] = useState(false); const [restored, setRestored] = useState(false); const timer = useRef | null>(null); /* ---------------------------------------------------- chargement initial */ useEffect(() => { let cancelled = false; (async () => { try { if (estimateId) { const r = await fetch(`/api/cost/estimate/${encodeURIComponent(estimateId)}`); if (!r.ok) throw new Error((await r.json()).error ?? r.statusText); const e = (await r.json()) as CostEstimate; if (cancelled) return; setEst(e); setInput(e.input); setComputedKey(JSON.stringify(e.input)); setMeta({ unit: null, missing: [], location: e.location, locationMethod: null }); if (e.input.propertyId) { const p = await fetch(`/api/cost/property?id=${encodeURIComponent(e.input.propertyId)}&readings=0`); if (p.ok && !cancelled) { const d = (await p.json()) as PrefillResponse; setMeta({ unit: d.unit, missing: d.missing, location: d.location, locationMethod: d.locationMethod }); } } return; } let saved: CostInput | null = null; try { const raw = window.localStorage.getItem(storageKey); if (raw) saved = JSON.parse(raw) as CostInput; } catch { /* ignore */ } if (propertyId) { const r = await fetch(`/api/cost/property?id=${encodeURIComponent(propertyId)}`); if (!r.ok) throw new Error((await r.json()).error ?? r.statusText); const d = (await r.json()) as PrefillResponse; if (cancelled) return; setMeta({ unit: d.unit, missing: d.missing, location: d.location, locationMethod: d.locationMethod }); 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; setInput(use); setRestored(!!saved && use === saved); } else { const base = saved && saved.mode === "construction" ? saved : defaultInput(); if (cancelled) return; setInput(base); setRestored(!!saved && saved.mode === "construction"); setMeta({ unit: null, missing: [], location: null, locationMethod: null }); } } catch (e) { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); } })(); return () => { cancelled = true; }; }, [propertyId, estimateId, storageKey]); /* ---------------------------------------------------- recalcul (debounce) */ const key = input ? JSON.stringify(input) : ""; useEffect(() => { if (!input || key === computedKey) return; if (timer.current) clearTimeout(timer.current); timer.current = setTimeout(async () => { try { const r = await fetch("/api/cost/estimate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ input, save: false }) }); const d = await r.json(); if (!r.ok) throw new Error(d.error ?? r.statusText); setEst(d as CostEstimate); setComputedKey(key); setError(null); try { window.localStorage.setItem(storageKey, key); } catch { /* quota */ } } catch (e) { setError(e instanceof Error ? e.message : String(e)); } }, 400); return () => { if (timer.current) clearTimeout(timer.current); }; }, [key, computedKey, input, storageKey]); const pending = !!input && key !== computedKey; /* ----------------------------------------------------------- mutateurs */ const patch = (p: Partial) => setInput((i) => (i ? { ...i, ...p } : i)); const patchB = (p: Partial) => setInput((i) => (i ? { ...i, building: { ...i.building, ...p } } : i)); const patchP = (p: Partial) => setInput((i) => (i ? { ...i, params: { ...i.params, ...p } } : i)); const patchD = (p: Partial) => setInput((i) => (i ? { ...i, depreciation: { ...i.depreciation, ...p } } : i)); const patchL = (p: Partial) => setInput((i) => (i ? { ...i, land: { ...i.land, ...p } } : i)); const setSrc = (k: string, s: AttributeSource) => setInput((i) => (i ? { ...i, attributeSources: { ...i.attributeSources, [k]: s } } : i)); 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 }; }); const toggleExclude = (code: string) => setInput((i) => (i ? { ...i, excludedAssemblies: i.excludedAssemblies.includes(code) ? i.excludedAssemblies.filter((c) => c !== code) : [...i.excludedAssemblies, code] } : i)); const save = async () => { if (!input) return; setSaving(true); try { const r = await fetch("/api/cost/estimate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ input, save: true }) }); const d = await r.json(); if (!r.ok) throw new Error(d.error ?? r.statusText); setEst(d as CostEstimate); setComputedKey(key); setSavedId((d as CostEstimate).id); const url = new URL(window.location.href); url.searchParams.set("estimate", (d as CostEstimate).id); window.history.replaceState(null, "", url.toString()); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setSaving(false); } }; const refreshPrices = () => { patch({ priceDate: null }); setComputedKey(""); }; const reset = () => { try { window.localStorage.removeItem(storageKey); } catch { /* ignore */ } window.location.href = propertyId ? `/cout?property=${encodeURIComponent(propertyId)}` : "/cout?mode=construction"; }; if (error && !input) return

{fr ? "Impossible de charger l'atelier" : "Cannot load the workshop"}

{error}

← {fr ? "Retour" : "Back"}
; if (!input) return
{fr ? "Chargement de la propriété…" : "Loading property…"}
; const hide = exercise && hideResult; const hideC = exercise && hideCosts; const structure = est?.categories.find((c) => c.category === "structure"); const structureAnswer = (

{fr ? "Exercice" : "Exercise"}

{fr ? "Calculez le coût direct de la structure." : "Compute the direct cost of the structure."}

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

setAnswer(e.target.value)} inputMode="numeric" placeholder="$" className="vp-input vp-mono max-w-[200px] text-[14px]" /> {revealed && }
{revealed && structure && est && (

{fr ? "Réponse du moteur" : "Engine answer"} : {money(structure.adjusted, lang)}{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)} %)` : ""}

{est.lines.filter((l) => l.category === "structure").map((l) => ())}
{fr ? "Assemblage" : "Assembly"}{fr ? "Quantité" : "Quantity"}×{fr ? "Coût unitaire" : "Unit cost"}={fr ? "Direct" : "Direct"}
{fr ? l.nameFr : l.nameEn}{num(l.quantity, lang, 1)} {l.unit}×{num(l.unitCost, lang, 2)} $={money(l.direct, lang)}
{fr ? "Direct × facteur régional" : "Direct × regional factor"} (M {est.location.materialFactor} · MO {est.location.labourFactor} · É {est.location.equipmentFactor}){money(structure.adjusted, lang)}
)}
); return (
{/* masthead */}
{pending ? (fr ? "recalcul…" : "recomputing…") : est ? `${fr ? "calculé en" : "computed"} ${est.priceDate}` : ""}
{fr ? "Méthode du coût — atelier" : "Cost approach — workshop"}

{input.address ?? (fr ? "Bâtiment hypothétique" : "Hypothetical building")}

{input.municipality ?? (fr ? "Sans adresse — décrivez le bâtiment" : "No address — describe the building")}{est ? ` · ${fr ? est.location.nameFr : est.location.nameEn}` : ""}

{/* sommaire des sections */}
    {SECTIONS.map((s, i) => (
  1. {s} {fr ? NAMES_FR[i] : NAMES_EN[i]}
  2. ))}
{error &&

{error}

}

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

); }