SPB Git forge

spb/vrai-prix

Public

Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.

60commits 1branches 0releases
12.3 MBsize
maindefault branch
17 days agolast push
TypeScript 90.2% JavaScript 3.5% Python 3.4% CSS 1.9% HTML 0.6%
16.3 KB · 206 lines tsx
Raw Blame History
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&nbsp;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