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%
19.8 KB · 145 lines tsx
Raw Blame History
1// Vrai-Prix — méthode du coût : 01 Propriété (données du rôle + provenance) et 02 Caractéristiques (BuildingSpec complet).2"use client";3import Link from "next/link";4import type { AttributeSource, BuildingSpec, CostInput, CostLocation, Quality } from "@/lib/cost/types";5import { BUILDING_TYPES, QUALITIES } from "@/lib/cost/taxonomy";6import { money, num } from "@/components/avendre/fmt";7import { AttrBadge } from "./Provenance";8import { Block, Field, MixField, NumField, SelectField, Toggle } from "./fields";910export interface PrefillMeta {11  unit: { id: string; adresse: string | null; municipalite: string | null; typeProp: string; genre: string | null; lien: string | null; cubf: number | null; cubfLibelle: string | null; nbLogements: number | null; aireM2: number | null; terrainM2: number | null } | null;12  missing: string[];13  location: CostLocation | null;14  locationMethod: string | null;15}1617const Q = (fr: boolean) => QUALITIES.map((q) => ({ key: q.key, label: fr ? q.fr : q.en }));1819export function PropertyPanel({ input, meta, fr, locations, patch, setSrc }: { input: CostInput; meta: PrefillMeta | null; fr: boolean; locations: CostLocation[]; patch: (p: Partial<CostInput>) => void; setSrc: (k: string, s: AttributeSource) => void }) {20  const lang = fr ? "fr" : "en";21  const u = meta?.unit;22  const src = input.attributeSources;23  const isProp = input.mode === "property";24  return (25    <Block num="01" title={fr ? "Propriété" : "Property"} id="s01" aside={<span className={`vp-mono border px-2 py-0.5 text-[10px] font-bold uppercase tracking-[0.08em] ${isProp ? "border-ink bg-ink text-paper" : "border-accent text-accent"}`}>{isProp ? (fr ? "Mode propriété" : "Property mode") : (fr ? "Mode construction" : "Construction mode")}</span>}>26      {isProp && u ? (27        <>28          <div>29            <p className="vp-display text-[19px] font-bold leading-tight">{input.address ?? u.adresse ?? "—"}</p>30            <p className="text-[13.5px] text-ink-2">{u.municipalite ?? "—"}{input.lat != null && input.lng != null ? ` · ${input.lat.toFixed(5)}, ${input.lng.toFixed(5)}` : ""}</p>31            <div className="mt-2 flex flex-wrap gap-2.5">32              <Link href={`/estimation/${encodeURIComponent(u.id)}`} className="vp-mono text-[11px] font-bold uppercase tracking-[0.08em] text-accent">{fr ? "Rapport de valeur Vrai-Prix" : "Vrai-Prix value report"} →</Link>33              <Link href="/cout" className="vp-mono text-[11px] font-bold uppercase tracking-[0.08em] text-ink-2">{fr ? "Changer d'adresse" : "Change address"}</Link>34            </div>35          </div>36          <div className="grid grid-cols-2 gap-x-6 gap-y-2 sm:grid-cols-3">37            {([38              [fr ? "Genre de propriété" : "Property kind", u.typeProp, "MAMH"],39              [fr ? "Genre de construction" : "Construction kind", u.genre || "—", "MAMH"],40              [fr ? "Lien physique" : "Physical link", u.lien || "—", "MAMH"],41              ["CUBF", u.cubf != null ? `${u.cubf} — ${u.cubfLibelle ?? ""}` : "—", "MAMH"],42              [fr ? "Année de construction" : "Year built", input.building.yearBuilt ?? "—", src.yearBuilt ?? "assumed"],43              [fr ? "Aire d'étages (rôle)" : "Floor area (roll)", u.aireM2 ? `${num(u.aireM2, lang)} m² · ${num(input.building.grossFloorAreaSqft, lang)} pi²` : "—", src.grossFloorAreaSqft ?? "assumed"],44              [fr ? "Étages" : "Storeys", input.building.stories, src.stories ?? "assumed"],45              [fr ? "Logements" : "Dwellings", u.nbLogements ?? 1, src.units ?? "assumed"],46              [fr ? "Superficie du terrain" : "Lot area", u.terrainM2 ? `${num(u.terrainM2, lang)} m²` : "—", "MAMH"],47              [fr ? "Valeur du terrain au rôle" : "Land value (roll)", money(input.roll?.landValue, lang), "MAMH"],48              [fr ? "Valeur du bâtiment au rôle" : "Building value (roll)", money(input.roll?.buildingValue, lang), "MAMH"],49              [fr ? "Valeur totale au rôle 2026" : "Total roll value 2026", money(input.roll?.totalValue, lang), "MAMH"],50            ] as [string, string | number, AttributeSource][]).map(([k, v, s]) => (51              <div key={k} className="kv-cell">52                <p className="k flex items-center gap-1.5">{k} <AttrBadge source={s} fr={fr} /></p>53                <p className="v">{v}</p>54              </div>55            ))}56          </div>57          {meta?.missing?.length ? (58            <p className="border-l-2 border-[var(--amber)] pl-3 text-[12.5px] text-ink-2">59              {fr ? `${meta.missing.length} caractéristiques ne sont pas au rôle (sous-sol, garage, revêtements, toiture, chauffage, salles de bain, qualité…) : elles sont supposées par défaut — complétez-les à la section 02 pour améliorer l'indice de confiance.` : `${meta.missing.length} attributes are not on the roll (basement, garage, cladding, roof, heating, bathrooms, quality…): they are default assumptions — complete them in section 02 to improve the confidence index.`}60            </p>61          ) : null}62        </>63      ) : (64        <div className="grid gap-4 sm:grid-cols-2">65          <Field label={fr ? "Municipalité (facteur régional)" : "Municipality (regional factor)"}>66            <input value={input.municipality ?? ""} onChange={(e) => { patch({ municipality: e.target.value || null }); setSrc("municipality", "user"); }} placeholder="Gatineau, Québec, Montréal…" className="vp-input text-[14px]" />67          </Field>68          <Field label={fr ? "Ou région imposée" : "Or forced region"}>69            <select value={input.locationCode ?? ""} onChange={(e) => patch({ locationCode: e.target.value || null })} className="vp-input text-[14px]">70              <option value="">{fr ? "Automatique (municipalité → centre → rural)" : "Automatic (municipality → centre → rural)"}</option>71              {locations.map((l) => (<option key={l.code} value={l.code}>{fr ? l.nameFr : l.nameEn} (× {num(l.overallFactor, lang, 3)})</option>))}72            </select>73          </Field>74          <p className="text-[12.5px] text-ink-2 sm:col-span-2">{fr ? "Bâtiment hypothétique : aucune donnée du rôle. Décrivez-le à la section 02 et saisissez une valeur de terrain à la section 07." : "Hypothetical building: no roll data. Describe it in section 02 and enter a land value in section 07."}</p>75        </div>76      )}77      {meta?.location && (78        <p className="vp-mono text-[10.5px] uppercase tracking-[0.05em] text-ink-3">79          {fr ? "Localisation" : "Location"} : <b className="text-ink">{fr ? meta.location.nameFr : meta.location.nameEn}</b> · M × {num(meta.location.materialFactor, lang, 2)} · MO × {num(meta.location.labourFactor, lang, 2)} · É × {num(meta.location.equipmentFactor, lang, 2)} · {meta.locationMethod}80        </p>81      )}82    </Block>83  );84}8586export function BuildingFormPanel({ b, src, fr, patchB, setSrc }: { b: BuildingSpec; src: Partial<Record<string, AttributeSource>>; fr: boolean; patchB: (p: Partial<BuildingSpec>) => void; setSrc: (k: string, s: AttributeSource) => void }) {87  const set = <K extends keyof BuildingSpec>(k: K, v: BuildingSpec[K]) => { patchB({ [k]: v } as Partial<BuildingSpec>); setSrc(String(k), "user"); };88  const qOpts = Q(fr);89  return (90    <Block num="02" title={fr ? "Caractéristiques du bâtiment" : "Building characteristics"} id="s02" aside={<span className="vp-mono text-[10px] uppercase tracking-[0.06em] text-ink-3">{fr ? "chaque champ porte son origine" : "each field carries its origin"}</span>}>91      <div className="grid gap-x-6 gap-y-4 sm:grid-cols-2 lg:grid-cols-3">92        <Field label={fr ? "Type de bâtiment" : "Building type"} source={src.type} fr={fr}><SelectField value={b.type} onChange={(v) => set("type", v)} options={BUILDING_TYPES.map((t) => ({ key: t.key, label: fr ? t.fr : t.en }))} /></Field>93        <Field label={fr ? "Qualité générale" : "Overall quality"} source={src.quality} fr={fr}><SelectField value={b.quality} onChange={(v) => { set("quality", v); }} options={qOpts} /></Field>94        <Field label={fr ? "Logements" : "Dwellings"} source={src.units} fr={fr}><NumField value={b.units} min={1} max={12} onChange={(v) => set("units", v ?? 1)} /></Field>95        <Field label={fr ? "Aire d'étages brute (hors sous-sol)" : "Gross floor area (excl. basement)"} source={src.grossFloorAreaSqft} fr={fr}><NumField value={b.grossFloorAreaSqft} min={200} max={30000} step={10} unit="pi²" onChange={(v) => set("grossFloorAreaSqft", v ?? 1800)} /></Field>96        <Field label={fr ? "Empreinte au sol (optionnel)" : "Footprint (optional)"} source={src.footprintSqft ?? "derived"} fr={fr} hint={fr ? "Vide = aire ÷ étages" : "Empty = area ÷ storeys"}><NumField value={b.footprintSqft} min={100} max={20000} step={10} unit="pi²" placeholder={`${Math.round(b.grossFloorAreaSqft / Math.max(1, b.stories))}`} onChange={(v) => set("footprintSqft", v)} /></Field>97        <Field label={fr ? "Étages" : "Storeys"} source={src.stories} fr={fr}><NumField value={b.stories} min={1} max={6} onChange={(v) => set("stories", v ?? 1)} /></Field>98        <Field label={fr ? "Année de construction" : "Year built"} source={src.yearBuilt} fr={fr}><NumField value={b.yearBuilt} min={1700} max={2026} onChange={(v) => set("yearBuilt", v)} /></Field>99        <Field label={fr ? "Sous-sol" : "Basement"} source={src.basement} fr={fr}><SelectField value={b.basement} onChange={(v) => set("basement", v)} options={[["none", "Aucun (dalle sur sol)", "None (slab on grade)"], ["crawl", "Vide sanitaire", "Crawl space"], ["unfinished", "Non fini", "Unfinished"], ["partial", "Partiellement fini", "Partially finished"], ["finished", "Fini", "Finished"], ["walkout", "Fini avec sortie extérieure", "Finished walkout"]].map(([k, f, e]) => ({ key: k as BuildingSpec["basement"], label: fr ? f : e }))} /></Field>100        <Field label={fr ? "Sous-sol fini (%)" : "Basement finished (%)"} source={src.basement} fr={fr}><NumField value={Math.round(b.basementFinishedPct * 100)} min={0} max={100} step={5} unit="%" onChange={(v) => set("basementFinishedPct", Math.min(1, Math.max(0, (v ?? 0) / 100)))} /></Field>101        <Field label={fr ? "Garage" : "Garage"} source={src.garage} fr={fr}><SelectField value={b.garage.type} onChange={(v) => set("garage", { ...b.garage, type: v, spaces: v === "none" ? 0 : Math.max(1, b.garage.spaces) })} options={[["none", "Aucun", "None"], ["attached", "Attenant", "Attached"], ["detached", "Détaché", "Detached"], ["integrated", "Intégré", "Integrated"], ["carport", "Abri d'auto", "Carport"]].map(([k, f, e]) => ({ key: k as BuildingSpec["garage"]["type"], label: fr ? f : e }))} /></Field>102        <Field label={fr ? "Places de garage" : "Garage spaces"} source={src.garage} fr={fr}><NumField value={b.garage.spaces} min={0} max={4} disabled={b.garage.type === "none"} onChange={(v) => set("garage", { ...b.garage, spaces: v ?? 0 })} /></Field>103        <Field label={fr ? "Aire du garage (optionnel)" : "Garage area (optional)"} source={src.garage} fr={fr}><NumField value={b.garage.areaSqft} min={100} max={2000} step={10} unit="pi²" disabled={b.garage.type === "none"} onChange={(v) => set("garage", { ...b.garage, areaSqft: v })} /></Field>104        <Field label={fr ? "Structure" : "Structure"} source={src.structure} fr={fr}><SelectField value={b.structure} onChange={(v) => set("structure", v)} options={[["wood_frame", "Ossature de bois", "Wood frame"], ["steel", "Acier", "Steel"], ["concrete", "Béton", "Concrete"], ["log", "Bois rond", "Log"], ["masonry", "Maçonnerie porteuse", "Load-bearing masonry"]].map(([k, f, e]) => ({ key: k as BuildingSpec["structure"], label: fr ? f : e }))} /></Field>105        <Field label={fr ? "Fondation" : "Foundation"} source={src.foundation} fr={fr}><SelectField value={b.foundation} onChange={(v) => set("foundation", v)} options={[["poured_concrete", "Béton coulé", "Poured concrete"], ["concrete_block", "Blocs de béton", "Concrete block"], ["slab_on_grade", "Dalle sur sol", "Slab on grade"], ["piers", "Pieux / pilotis", "Piers"], ["stone", "Pierre", "Stone"]].map(([k, f, e]) => ({ key: k as BuildingSpec["foundation"], label: fr ? f : e }))} /></Field>106        <Field label={fr ? "Toiture" : "Roof covering"} source={src.roof} fr={fr}><SelectField value={b.roof} onChange={(v) => set("roof", v)} options={[["asphalt_shingle", "Bardeau d'asphalte", "Asphalt shingle"], ["metal", "Tôle d'acier", "Steel"], ["membrane", "Membrane (toit plat)", "Membrane (flat roof)"], ["cedar", "Bardeau de cèdre", "Cedar shake"], ["slate_tile", "Ardoise / tuile", "Slate / tile"]].map(([k, f, e]) => ({ key: k as BuildingSpec["roof"], label: fr ? f : e }))} /></Field>107        <Field label={fr ? "Géométrie du toit" : "Roof geometry"} source={src.roof} fr={fr}><SelectField value={b.roofGeometry} onChange={(v) => set("roofGeometry", v)} options={[["gable", "À deux versants", "Gable"], ["hip", "À quatre versants", "Hip"], ["flat", "Plat", "Flat"], ["mansard", "Mansardé", "Mansard"], ["complex", "Complexe", "Complex"]].map(([k, f, e]) => ({ key: k as BuildingSpec["roofGeometry"], label: fr ? f : e }))} /></Field>108        <Field label={fr ? "Pente (x / 12)" : "Pitch (x / 12)"} source={src.roof} fr={fr}><NumField value={b.roofPitch} min={0} max={18} disabled={b.roofGeometry === "flat"} onChange={(v) => set("roofPitch", v ?? 6)} /></Field>109        <Field label={fr ? "Type de fenêtres" : "Window type"} source={src.windows} fr={fr}><SelectField value={b.windows} onChange={(v) => set("windows", v)} options={[["pvc", "PVC", "PVC"], ["hybrid", "Hybride PVC/aluminium", "Hybrid PVC/aluminum"], ["aluminum", "Aluminium", "Aluminum"], ["wood", "Bois", "Wood"]].map(([k, f, e]) => ({ key: k as BuildingSpec["windows"], label: fr ? f : e }))} /></Field>110        <Field label={fr ? "Nombre de fenêtres (optionnel)" : "Window count (optional)"} source={src.windowCount ?? "derived"} fr={fr} hint={fr ? "Vide = aire × 0,011" : "Empty = area × 0.011"}><NumField value={b.windowCount} min={0} max={200} placeholder={`${Math.max(4, Math.round(b.grossFloorAreaSqft * 0.011))}`} onChange={(v) => set("windowCount", v)} /></Field>111        <Field label={fr ? "Chauffage" : "Heating"} source={src.heating} fr={fr}><SelectField value={b.heating} onChange={(v) => set("heating", v)} options={[["electric_baseboard", "Plinthes électriques", "Electric baseboards"], ["heat_pump", "Thermopompe centrale", "Central heat pump"], ["furnace_electric", "Fournaise électrique", "Electric furnace"], ["furnace_gas", "Fournaise au gaz", "Gas furnace"], ["furnace_oil", "Fournaise au mazout", "Oil furnace"], ["hydronic", "Eau chaude (hydronique)", "Hydronic"], ["geothermal", "Géothermie", "Geothermal"], ["wood", "Bois", "Wood"]].map(([k, f, e]) => ({ key: k as BuildingSpec["heating"], label: fr ? f : e }))} /></Field>112        <div className="flex flex-col gap-2 pt-5">113          <Toggle checked={b.hasAirConditioning} onChange={(v) => set("hasAirConditioning", v)} label={fr ? "Climatisation" : "Air conditioning"} />114          <Toggle checked={b.hasAirExchanger} onChange={(v) => set("hasAirExchanger", v)} label={fr ? "Échangeur d'air (VRC)" : "Air exchanger (HRV)"} />115        </div>116        <Field label={fr ? "Cuisines" : "Kitchens"} source={src.kitchens ?? src.kitchenQuality} fr={fr}><NumField value={b.kitchens} min={0} max={12} onChange={(v) => set("kitchens", v ?? 1)} /></Field>117        <Field label={fr ? "Qualité de la cuisine" : "Kitchen quality"} source={src.kitchenQuality} fr={fr}><SelectField value={b.kitchenQuality} onChange={(v) => set("kitchenQuality", v as Quality)} options={qOpts} /></Field>118        <Field label={fr ? "Salles de bain" : "Bathrooms"} source={src.bathrooms} fr={fr}><NumField value={b.bathrooms} min={0} max={12} onChange={(v) => set("bathrooms", v ?? 0)} /></Field>119        <Field label={fr ? "Salles d'eau" : "Powder rooms"} source={src.powderRooms ?? src.bathrooms} fr={fr}><NumField value={b.powderRooms} min={0} max={6} onChange={(v) => set("powderRooms", v ?? 0)} /></Field>120        <Field label={fr ? "Qualité des salles de bain" : "Bathroom quality"} source={src.bathroomQuality ?? src.bathrooms} fr={fr}><SelectField value={b.bathroomQuality} onChange={(v) => set("bathroomQuality", v as Quality)} options={qOpts} /></Field>121        <Field label={fr ? "Chambres (info)" : "Bedrooms (info)"} source={src.bedrooms} fr={fr}><NumField value={b.bedrooms} min={0} max={20} onChange={(v) => set("bedrooms", v)} /></Field>122      </div>123      <div className="grid gap-x-10 gap-y-4 lg:grid-cols-2">124        <Field label={fr ? "Revêtements extérieurs (parts)" : "Exterior cladding (shares)"} source={src.siding} fr={fr}>125          <MixField fr={fr} value={b.siding} onChange={(v) => set("siding", v)} options={[{ key: "vinyl", fr: "Vinyle", en: "Vinyl" }, { key: "brick", fr: "Brique", en: "Brick" }, { key: "fiber_cement", fr: "Fibrociment", en: "Fiber cement" }, { key: "wood", fr: "Bois", en: "Wood" }, { key: "stone", fr: "Pierre", en: "Stone" }, { key: "stucco", fr: "Stuc / acrylique", en: "Stucco" }, { key: "aluminum", fr: "Aluminium", en: "Aluminum" }, { key: "steel", fr: "Acier", en: "Steel" }] as { key: keyof BuildingSpec["siding"]; fr: string; en: string }[]} />126        </Field>127        <Field label={fr ? "Planchers (parts) — vide = défaut selon la qualité" : "Flooring (shares) — empty = quality default"} source={src.flooring ?? "assumed"} fr={fr}>128          <MixField fr={fr} value={b.flooring} onChange={(v) => set("flooring", v)} options={[{ key: "hardwood", fr: "Bois franc", en: "Hardwood" }, { key: "engineered", fr: "Ingénierie", en: "Engineered" }, { key: "vinyl_plank", fr: "Vinyle SPC", en: "Vinyl plank" }, { key: "laminate", fr: "Stratifié", en: "Laminate" }, { key: "ceramic", fr: "Céramique", en: "Ceramic" }, { key: "carpet", fr: "Tapis", en: "Carpet" }] as { key: keyof BuildingSpec["flooring"]; fr: string; en: string }[]} />129        </Field>130      </div>131      <div className="grid gap-x-6 gap-y-4 sm:grid-cols-2 lg:grid-cols-4">132        <Field label={fr ? "Terrasse" : "Deck"} source={src.deckSqft ?? "assumed"} fr={fr}><NumField value={b.deckSqft} min={0} max={3000} step={10} unit="pi²" onChange={(v) => set("deckSqft", v ?? 0)} /></Field>133        <Field label={fr ? "Entrée" : "Driveway"} source={src.driveway ?? "assumed"} fr={fr}><SelectField value={b.driveway} onChange={(v) => set("driveway", v)} options={[["none", "Aucune", "None"], ["asphalt", "Asphalte", "Asphalt"], ["pavers", "Pavés", "Pavers"], ["gravel", "Gravier", "Gravel"], ["concrete", "Béton", "Concrete"]].map(([k, f, e]) => ({ key: k as BuildingSpec["driveway"], label: fr ? f : e }))} /></Field>134        <Field label={fr ? "Aire d'entrée" : "Driveway area"} source={src.drivewaySqft ?? "assumed"} fr={fr}><NumField value={b.drivewaySqft} min={0} max={10000} step={10} unit="pi²" disabled={b.driveway === "none"} onChange={(v) => set("drivewaySqft", v ?? 0)} /></Field>135        <Field label={fr ? "Clôture" : "Fence"} source={src.fenceLinFt ?? "assumed"} fr={fr}><NumField value={b.fenceLinFt} min={0} max={2000} step={5} unit="pi lin." onChange={(v) => set("fenceLinFt", v ?? 0)} /></Field>136        <Field label={fr ? "Aménagement paysager (gazon)" : "Landscaping (sod)"} source={src.landscapingSqft ?? "assumed"} fr={fr}><NumField value={b.landscapingSqft} min={0} max={40000} step={50} unit="pi²" onChange={(v) => set("landscapingSqft", v ?? 0)} /></Field>137        <Field label={fr ? "Piscine" : "Pool"} source={src.pool ?? "assumed"} fr={fr}><SelectField value={b.pool} onChange={(v) => set("pool", v)} options={[["none", "Aucune", "None"], ["above_ground", "Hors terre", "Above ground"], ["inground", "Creusée", "Inground"]].map(([k, f, e]) => ({ key: k as BuildingSpec["pool"], label: fr ? f : e }))} /></Field>138      </div>139      <p className="vp-mono text-[10px] uppercase leading-relaxed tracking-[0.05em] text-ink-3">140        {fr ? "Coût de REMPLACEMENT : quelle que soit l'année, le bâtiment est chiffré avec les composantes modernes équivalentes (2 × 6 R24, R50 au comble, poutrelles ajourées)." : "REPLACEMENT cost: whatever the year, the building is priced with modern equivalent components (2 × 6 R24, R50 attic, I-joists)."}141      </p>142    </Block>143  );144}145