SPB Git

spb/vrai-prix Public

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

TypeScript 96.7% CSS 3.2%
18.9 KB · 418 lines tsx
Raw Blame History
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2"use client";3import { useState } from "react";4import Link from "next/link";5import { useLang } from "./LangContext";6import LeadForm from "./LeadForm";7import RadarMap from "./RadarMap";8import {9  BuildingGlyph,10  ConfidenceDial,11  CountUp,12  EraLine,13  locNum,14  LotDiagram,15  ValueSplit,16} from "./MetricViz";17import type { EstimateResult } from "@/lib/engine";18import type { UnitEstimate } from "@/lib/estimator";19import type { TKey } from "@/lib/i18n";2021const fmt = (v: number | null | undefined, lang: string) =>22  v == null23    ? "—"24    : new Intl.NumberFormat(lang === "fr" ? "fr-CA" : "en-CA", {25        style: "currency",26        currency: "CAD",27        maximumFractionDigits: 0,28      }).format(v);2930const signed = (v: number, lang: string) =>31  `${v >= 0 ? "+" : "−"}${fmt(Math.abs(Math.round(v)), lang)}`;3233function Chip({ k, v }: { k: string; v: string | number }) {34  return (35    <div className="kv-cell">36      <p className="k">{k}</p>37      <p className="v break-words">{v}</p>38    </div>39  );40}4142export default function ResultView({ data }: { data: UnitEstimate }) {43  const { lang, t } = useLang();44  const [activeComp, setActiveComp] = useState<string | null>(null);45  const r: EstimateResult = data.result;46  const u = data.unit;47  const s = u?.specs;48  const fr = lang === "fr";49  const histMax = Math.max(...(u?.history.map((h) => h.value ?? 0) ?? [0]), 1);50  const moneyFmt = (v: number) =>51    new Intl.NumberFormat(fr ? "fr-CA" : "en-CA", {52      style: "currency",53      currency: "CAD",54      maximumFractionDigits: 0,55    }).format(Math.round(v / 100) * 100);5657  return (58    <div className="space-y-10 py-8">59      {/* fil d'ariane */}60      <nav className="vp-mono flex flex-wrap items-center gap-2 text-[11.5px] uppercase tracking-[0.06em] text-ink-3">61        <Link href="/" className="border-b-[1.5px] border-transparent hover:border-green hover:text-green">62          Vrai-Prix63        </Link>64        <span>/</span>65        <span>{u ? `${u.adresse ?? ""} · ${u.municipalite ?? ""}` : t("manualTitle")}</span>66      </nav>6768      {/* ---- Panneau prix ---- */}69      <section className="vp-card rise p-6 sm:p-8">70        <div className="flex flex-wrap items-start justify-between gap-6">71          <div className="min-w-0 flex-1">72            <span className="kicker">{t("estimatedValue")}</span>73            {u && (74              <h1 className="vp-display mt-2 text-[clamp(20px,3vw,28px)] font-bold uppercase leading-tight tracking-[-0.02em]">75                {u.adresse}76                {s?.apt ? ` app. ${s.apt}` : ""}77                {u.municipalite ? <span className="text-ink-3"> · {u.municipalite}</span> : null}78              </h1>79            )}80            <p className="vp-display mt-3 text-[clamp(44px,7vw,72px)] font-bold leading-none tracking-[-0.04em]">81              <CountUp value={r.estimate} format={moneyFmt} />82            </p>83            <div className="mt-5 flex flex-wrap items-center gap-x-6 gap-y-3">84              <div>85                <p className="klabel">{t("range")}</p>86                <p className="vp-display text-[19px] font-bold">87                  {fmt(r.low, lang)} <span className="text-ink-3">→</span> {fmt(r.high, lang)}88                </p>89              </div>90              {u?.valeurRole != null && (91                <div>92                  <p className="klabel">{t("assessed")}</p>93                  <p className="vp-display text-[19px] font-bold text-ink-2">{fmt(u.valeurRole, lang)}</p>94                </div>95              )}96              {u?.valeurRole != null && u.valeurRole > 0 && r.estimate > 0 && (97                <div>98                  <p className="klabel">{fr ? "Écart vs rôle" : "vs assessment"}</p>99                  <p className="vp-display text-[19px] font-bold text-green-deep">100                    {r.estimate >= u.valeurRole ? "+" : ""}101                    {((r.estimate / u.valeurRole - 1) * 100).toFixed(0)} %102                  </p>103                </div>104              )}105            </div>106            {u && (107              <div className="mt-6 flex flex-wrap gap-2.5">108                <a109                  href={`/api/report?id=${encodeURIComponent(u.id)}`}110                  className="btn btn-primary"111                  download112                >113                  {fr ? "Rapport PDF" : "PDF report"} ↓114                </a>115                <a116                  href={`/api/report/pro?id=${encodeURIComponent(u.id)}`}117                  className="btn btn-ghost"118                  download119                  title={fr ? "Structure de rapport d'évaluation — présentation bancaire" : "Appraisal-style structure — bank presentation"}120                >121                  {fr ? "Rapport détaillé format professionnel (banque)" : "Detailed professional report (bank)"} ↓122                </a>123              </div>124            )}125          </div>126          <div className="shrink-0">127            <ConfidenceDial128              pct={r.confidencePct}129              level={r.confidenceLevel}130              label={`${t("confidence")} — ${t(`conf${r.confidenceLevel}` as TKey)}`}131            />132          </div>133        </div>134        <p className="vp-mono mt-5 border-t-[1.5px] border-dashed border-[rgba(20,24,20,0.14)] pt-3 text-[10.5px] uppercase tracking-[0.05em] text-ink-3">135          {t("disclaimer")}136        </p>137      </section>138139      {/* ---- Portrait de la propriété (bento du registre) ---- */}140      {u && s && (141        <section>142          <span className="kicker">{fr ? "Registre officiel — dessiné par les données" : "Official record — drawn by the data"}</span>143          <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">144            {fr ? "Portrait de la propriété" : "Property portrait"}145          </h2>146          <div className="mt-4 grid gap-4 lg:grid-cols-3">147            {/* Bâtiment */}148            <div className="vp-card vp-card-hover rise p-5" style={{ animationDelay: "60ms" }}>149              <p className="klabel mb-1 border-b-[1.5px] border-dashed border-[rgba(20,24,20,0.14)] pb-2">150                {fr ? "Bâtiment" : "Building"}151              </p>152              <div className="flex justify-center py-2">153                <BuildingGlyph154                  floors={s.nbEtages}155                  dwellings={u.nbLogements}156                  genre={s.genreConstruction}157                  yearBuilt={u.anneeConstruction}158                  floorAreaM2={u.aireEtagesM2}159                  lang={lang}160                />161              </div>162              <EraLine year={u.anneeConstruction} lang={lang} />163              <div className="mt-3 grid grid-cols-2 gap-2">164                <Chip k={fr ? "Aire d'étages" : "Floor area"} v={u.aireEtagesM2 ? locNum(u.aireEtagesM2, lang, { unit: "m²" }) : "—"} />165                <Chip k={fr ? "Étages × logements" : "Storeys × dwellings"} v={`${s.nbEtages ?? "—"} × ${u.nbLogements ?? "—"}`} />166                <Chip k={fr ? "Genre" : "Style"} v={s.genreConstruction ?? "—"} />167                <Chip k={fr ? "Lien physique" : "Physical link"} v={s.lienPhysique ?? "—"} />168              </div>169            </div>170            {/* Terrain */}171            <div className="vp-card vp-card-hover rise p-5" style={{ animationDelay: "140ms" }}>172              <p className="klabel mb-1 border-b-[1.5px] border-dashed border-[rgba(20,24,20,0.14)] pb-2">173                {fr ? "Terrain — à l'échelle" : "Lot — to scale"}174              </p>175              <div className="flex justify-center py-2">176                <LotDiagram177                  areaM2={u.superficieTerrainM2}178                  frontageM={s.frontTerrainM}179                  footprintM2={180                    u.aireEtagesM2 && s.nbEtages ? u.aireEtagesM2 / Math.max(1, s.nbEtages) : u.aireEtagesM2181                  }182                  lang={lang}183                  isCondo={!!s.apt || (u.nbLogements != null && u.nbLogements >= 1 && !u.superficieTerrainM2)}184                />185              </div>186              <div className="mt-3 grid grid-cols-2 gap-2">187                <Chip k={fr ? "Superficie" : "Area"} v={u.superficieTerrainM2 ? locNum(u.superficieTerrainM2, lang, { unit: "m²" }) : "—"} />188                <Chip k={fr ? "Frontage" : "Frontage"} v={s.frontTerrainM ? locNum(s.frontTerrainM, lang, { unit: "m" }) : "—"} />189                <Chip k="CUBF" v={s.cubf ?? "—"} />190                <Chip k={fr ? "Usage" : "Use"} v={s.cubfLibelle ?? "—"} />191              </div>192            </div>193            {/* Valeur au rôle */}194            <div className="vp-card vp-card-hover rise p-5" style={{ animationDelay: "220ms" }}>195              <p className="klabel mb-1 border-b-[1.5px] border-dashed border-[rgba(20,24,20,0.14)] pb-2">196                {fr ? "Valeur au rôle 2026 — composition" : "2026 assessed value — split"}197              </p>198              <p className="vp-display py-2 text-[26px] font-bold tracking-[-0.02em]">199                {fmt(u.valeurRole, lang)}200              </p>201              <ValueSplit202                land={s.valeurTerrain}203                building={s.valeurBatiment}204                previous={s.valeurAnterieure}205                total={u.valeurRole}206                lang={lang}207                labels={{208                  land: fr ? "Terrain" : "Land",209                  building: fr ? "Bâtiment" : "Building",210                  previous: fr ? "Rôle antérieur" : "Previous roll",211                }}212              />213              <div className="mt-3 grid grid-cols-2 gap-2">214                <Chip k={fr ? "Cond. marché au" : "Market cond."} v={s.datCondMarche ?? "—"} />215                <Chip k={fr ? "Matricule" : "Roll no."} v={s.matricule ?? "—"} />216              </div>217            </div>218          </div>219          {/* registre brut complet */}220          <details className="vp-card mt-4 p-5">221            <summary className="vp-mono cursor-pointer text-[11px] font-bold uppercase tracking-[0.08em] text-green-deep">222              {fr ? "▸ Toutes les données du registre (brutes)" : "▸ All registry data (raw)"}223            </summary>224            <div className="mt-4 grid grid-cols-2 gap-2 sm:grid-cols-4">225              <Chip k={fr ? "Année construction" : "Year built"} v={u.anneeConstruction ? `${u.anneeConstruction}${s.anneeEstimee === "E" ? (fr ? " (estimée)" : " (est.)") : ""}` : "—"} />226              <Chip k={fr ? "Locaux non résid." : "Non-res. units"} v={s.nbLocauxNonResid ?? "—"} />227              <Chip k={fr ? "Chambres locatives" : "Rental rooms"} v={s.nbChambresLocatives ?? "—"} />228              <Chip k={fr ? "Unité de voisinage" : "Neighbourhood"} v={s.uniteVoisinage || "—"} />229              <Chip k={fr ? "Arrondissement" : "Borough"} v={s.arrond || "—"} />230              <Chip k={fr ? "Adresses sur l'unité" : "Addresses"} v={s.nAdresses ?? "—"} />231              <Chip k={fr ? "Valeur terrain" : "Land value"} v={fmt(s.valeurTerrain, lang)} />232              <Chip k={fr ? "Valeur bâtiment" : "Building value"} v={fmt(s.valeurBatiment, lang)} />233              <Chip k={fr ? "Rôle antérieur" : "Previous roll"} v={fmt(s.valeurAnterieure, lang)} />234              <Chip k="ID provincial" v={u.id} />235            </div>236          </details>237        </section>238      )}239240      {/* ---- Transparence du calcul ---- */}241      <section>242        <span className="kicker">{fr ? "Transparence" : "Transparency"}</span>243        <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">244          {t("whyTitle")}245        </h2>246        <div className="mt-4 grid gap-4 sm:grid-cols-2">247          <div className="vp-card vp-card-hover rise p-5">248            <p className="klabel">{t("modelPart")}</p>249            <p className="vp-display mt-2 text-[30px] font-bold tracking-[-0.03em]">250              {r.modelEstimate != null ? (251                <CountUp value={r.modelEstimate} format={moneyFmt} />252              ) : (253                "—"254              )}255            </p>256            <p className="vp-mono mt-1 text-[11px] uppercase tracking-[0.06em] text-green-deep">257              {t("weightModel")} : {(r.modelWeight * 100).toFixed(0)} %258            </p>259          </div>260          <div className="vp-card vp-card-hover rise p-5" style={{ animationDelay: "100ms" }}>261            <p className="klabel">{t("compsPart")}</p>262            <p className="vp-display mt-2 text-[30px] font-bold tracking-[-0.03em]">263              {r.compsEstimate != null ? (264                <CountUp value={r.compsEstimate} format={moneyFmt} />265              ) : (266                "—"267              )}268            </p>269            <p className="vp-mono mt-1 text-[11px] uppercase tracking-[0.06em] text-green-deep">270              {t("weightComps")} : {((1 - r.modelWeight) * 100).toFixed(0)} % · {r.nCompsUsed} comps271              {r.compsDispersionPct != null ? ` · ±${r.compsDispersionPct} %` : ""}272            </p>273          </div>274        </div>275      </section>276277      {/* ---- Historique ---- */}278      {u && u.history.some((h) => h.value != null) && (279        <section>280          <span className="kicker">2021 → 2026</span>281          <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">282            {t("history")}283          </h2>284          <div className="vp-card rise mt-4 p-5 sm:p-6">285            <div className="flex flex-col gap-2">286              {u.history.map((h, i) => {287                const prev = i > 0 ? u.history[i - 1].value : null;288                const yoy =289                  h.value != null && prev != null && prev > 0290                    ? ((h.value / prev - 1) * 100)291                    : null;292                return (293                  <div key={h.year} className="hbar-row grid grid-cols-[44px_1fr_auto] items-center gap-2 sm:grid-cols-[52px_1fr_auto] sm:gap-3">294                    <span className="vp-mono text-[12px] font-bold">{h.year}</span>295                    <span className="hbar-track">296                      <span297                        className="hbar-fill grow-x"298                        style={{299                          width: `${((h.value ?? 0) / histMax) * 100}%`,300                          animationDelay: `${i * 90}ms`,301                          background: h.year === 2026 ? "var(--ink)" : undefined,302                        }}303                      />304                    </span>305                    <span className="vp-mono flex items-center justify-end gap-2 text-[12px] font-bold">306                      {fmt(h.value, lang)}307                      {yoy != null && (308                        <span className={`rounded-[4px] border border-ink px-1 py-px text-[9px] ${yoy >= 0 ? "bg-lime-soft text-green-deep" : "bg-[var(--danger-soft)] text-[var(--danger)]"}`}>309                          {yoy >= 0 ? "+" : ""}310                          {yoy.toFixed(0)} %311                        </span>312                      )}313                    </span>314                  </div>315                );316              })}317            </div>318          </div>319        </section>320      )}321322      {/* ---- Plan des comparables (carte maison) ---- */}323      <section>324        <span className="kicker">325          {r.nCompsUsed} {fr ? "ventes réelles" : "real sales"}326        </span>327        <h2 className="vp-display mt-2 text-[22px] font-bold uppercase tracking-[-0.02em]">328          {t("compsTitle")}329        </h2>330        <div className="mt-4 grid items-start gap-4 lg:grid-cols-[1.05fr_1fr]">331          <div className="overflow-hidden rounded-[10px] border-[1.5px] border-ink shadow-[8px_8px_0_rgba(20,24,20,0.08)]">332            <RadarMap333              subject={{334                lat: u?.lat ?? r.comps[0]?.lat ?? 46.81,335                lng: u?.lng ?? r.comps[0]?.lng ?? -71.21,336                label: u?.adresse ?? (fr ? "Propriété estimée" : "Subject property"),337              }}338              comps={r.comps.map((c) => ({339                id: c.id,340                lat: c.lat,341                lng: c.lng,342                label: `${c.street ?? ""}, ${c.city ?? ""}`,343                price: fmt(c.amount, lang),344                adjusted: fmt(Math.round(c.adjustedPrice), lang),345                date: c.date,346                distanceM: c.distanceM,347                weight: c.weight,348              }))}349              highlight={activeComp}350              onHover={setActiveComp}351            />352          </div>353          <div className="src-wrap max-h-[560px] overflow-y-auto">354            <table className="src-table">355              <thead className="sticky top-0 z-10">356                <tr>357                  <th>#</th>358                  <th>{fr ? "Vente" : "Sale"}</th>359                  <th className="text-right">{t("adjusted")}</th>360                </tr>361              </thead>362              <tbody>363                {r.comps.map((c, i) => (364                  <tr365                    key={c.id}366                    onMouseEnter={() => setActiveComp(c.id)}367                    onMouseLeave={() => setActiveComp(null)}368                    className={activeComp === c.id ? "!bg-lime-soft" : ""}369                  >370                    <td>371                      <span372                        className={`vp-mono inline-flex h-6 w-6 items-center justify-center rounded-full border-[1.5px] border-ink text-[11px] font-bold ${373                          activeComp === c.id ? "bg-lime text-ink" : "bg-green text-paper"374                        }`}375                      >376                        {i + 1}377                      </span>378                    </td>379                    <td>380                      <p className="font-semibold leading-tight">381                        {c.street}, {c.city}382                      </p>383                      <p className="vp-mono mt-0.5 text-[10.5px] uppercase tracking-[0.04em] text-ink-3">384                        {c.date} · {c.distanceM < 1000 ? `${Math.round(c.distanceM)} m` : `${(c.distanceM / 1000).toFixed(1)} km`} ·{" "}385                        {t("sold")} {fmt(c.amount, lang)}386                      </p>387                      <p className="vp-mono mt-0.5 text-[10.5px] text-ink-2">388                        {t("adjTime")} {signed(c.adjTime, lang)} · {t("adjArea")} {signed(c.adjArea, lang)} ·{" "}389                        {t("adjAge")} {signed(c.adjAge, lang)}390                      </p>391                    </td>392                    <td className="vp-display text-right font-bold">393                      {fmt(Math.round(c.adjustedPrice), lang)}394                      <p className="vp-mono text-[10px] font-normal text-ink-3">395                        {t("weight")} {(c.weight * 100).toFixed(0)} %396                      </p>397                    </td>398                  </tr>399                ))}400              </tbody>401            </table>402          </div>403        </div>404      </section>405406      {/* ---- Lead ---- */}407      <section className="rounded-[10px] border-[1.5px] border-ink bg-ink p-6 text-paper shadow-[8px_8px_0_rgba(20,24,20,0.2)] sm:p-8">408        <h2 className="vp-display text-[22px] font-bold uppercase tracking-[-0.02em] text-lime">409          {t("leadTitle")}410        </h2>411        <div className="mt-4 max-w-xl">412          <LeadForm unitId={u?.id ?? null} estimate={r.estimate} />413        </div>414      </section>415    </div>416  );417}418