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%
7.7 KB · 107 lines tsx
Raw Blame History
1// Vrai-Prix — méthode du coût : tiroir latéral générique + fiche « Source » d'un prix2// (source, date, URL, valeur observée, unité d'origine, conversion, facteur régional, méthode, synchro, confiance).3"use client";4import { useEffect } from "react";5import type { ItemPrice, Provenance } from "@/lib/cost/types";6import { UNIT_LABELS } from "@/lib/cost/units";7import { fmtDate, KindIcon, kindLabel } from "./Provenance";8import { money, num } from "@/components/avendre/fmt";910export function Drawer({ open, onClose, title, children, fr }: { open: boolean; onClose: () => void; title: string; children: React.ReactNode; fr: boolean }) {11  useEffect(() => {12    if (!open) return;13    const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };14    document.addEventListener("keydown", onKey);15    const prev = document.body.style.overflow;16    document.body.style.overflow = "hidden";17    return () => { document.removeEventListener("keydown", onKey); document.body.style.overflow = prev; };18  }, [open, onClose]);19  if (!open) return null;20  return (21    <div className="fixed inset-0 z-[var(--z-modal,900)] flex justify-end" role="dialog" aria-modal="true" aria-label={title}>22      <button type="button" aria-label={fr ? "Fermer" : "Close"} onClick={onClose} className="absolute inset-0 bg-[rgba(22,24,27,0.45)]" />23      <aside className="relative flex h-full w-full max-w-[520px] flex-col overflow-y-auto border-l border-[var(--line-strong)] bg-paper shadow-xl">24        <div className="sticky top-0 flex items-center justify-between border-b-2 border-ink bg-paper px-5 py-3">25          <p className="vp-display text-[15px] font-bold uppercase tracking-[-0.01em]">{title}</p>26          <button type="button" onClick={onClose} className="vp-mono flex h-10 w-10 items-center justify-center text-[15px]" aria-label={fr ? "Fermer" : "Close"}>✕</button>27        </div>28        <div className="px-5 pb-[max(24px,env(safe-area-inset-bottom))] pt-4">{children}</div>29      </aside>30    </div>31  );32}3334export function KV({ k, v, mono = false }: { k: string; v: React.ReactNode; mono?: boolean }) {35  return (36    <div className="kv-cell">37      <p className="k">{k}</p>38      <p className={`v ${mono ? "vp-mono text-[13px] font-medium" : ""}`}>{v}</p>39    </div>40  );41}4243/** Contenu d'un tiroir source pour un prix canonique d'article. */44export function PriceSourceCard({ price, fr, itemName, unit, locationFactor, locationName, lastSync }: { price: ItemPrice | Provenance | null; fr: boolean; itemName: string; unit?: string; locationFactor?: number; locationName?: string; lastSync?: string | null }) {45  const lang = fr ? "fr" : "en";46  if (!price) return <p className="text-[13.5px] text-ink-2">{fr ? "Donnée non disponible." : "Data not available."}</p>;47  const isItem = "itemCode" in price;48  const kind = price.kind;49  const unitLabel = unit ? (UNIT_LABELS[unit as keyof typeof UNIT_LABELS]?.[lang] ?? unit) : "";50  return (51    <div className="space-y-4">52      <div>53        <p className="klabel">{fr ? "Article" : "Item"}</p>54        <p className="vp-display mt-1 text-[17px] font-bold">{itemName}</p>55        <p className="mt-1 inline-flex items-center gap-2 text-[12.5px] text-ink-2"><KindIcon kind={kind} fr={fr} source={isItem ? price.sources[0]?.source : price.source} observedAt={price.observedAt} note={price.note} /> {kindLabel(kind, fr)}</p>56      </div>57      <div className="grid grid-cols-2 gap-x-6 gap-y-2">58        {isItem ? (59          <>60            <KV k={fr ? "Prix canonique" : "Canonical price"} v={`${num(price.price, lang, 2)} $ / ${unitLabel}`} mono />61            <KV k={fr ? "Fourchette (min–max sources)" : "Range (min–max sources)"} v={`${num(price.low, lang, 2)} – ${num(price.high, lang, 2)} $`} mono />62            <KV k={fr ? "Nombre de sources" : "Source count"} v={price.sourceCount} mono />63            <KV k={fr ? "Date d'observation" : "Observation date"} v={fmtDate(price.observedAt, fr)} />64            <KV k={fr ? "Confiance" : "Confidence"} v={`${price.confidence} / 100`} mono />65            <KV k={fr ? "Méthode" : "Method"} v={fr ? (price.sourceCount >= 2 ? "Médiane robuste multi-source (prix régulier préféré, aberrations écartées)" : price.kind === "indexed" ? "Actualisation par indice StatCan" : price.kind === "reference" ? "Prix de référence interne (hypothèse)" : "Observation unique") : price.sourceCount >= 2 ? "Robust multi-source median (regular price preferred, outliers flagged)" : price.kind === "indexed" ? "StatCan index update" : price.kind === "reference" ? "Internal reference price (assumption)" : "Single observation"} />66          </>67        ) : (68          <>69            <KV k={fr ? "Source" : "Source"} v={price.source} />70            <KV k={fr ? "Date" : "Date"} v={fmtDate(price.observedAt ?? price.effectiveDate, fr)} />71            <KV k={fr ? "Confiance" : "Confidence"} v={`${price.confidence} / 100`} mono />72          </>73        )}74        {locationFactor != null && <KV k={fr ? "Facteur régional (matériaux)" : "Regional factor (materials)"} v={`× ${num(locationFactor, lang, 3)}${locationName ? ` — ${locationName}` : ""}`} mono />}75        {lastSync !== undefined && <KV k={fr ? "Dernière synchronisation" : "Last synchronisation"} v={lastSync ? fmtDate(lastSync, fr) : fr ? "Donnée non disponible" : "Data not available"} />}76      </div>77      {isItem && price.indexed && (78        <p className="border-l-2 border-accent pl-3 text-[12.5px] text-ink-2">79          {fr ? `Prix du ${fmtDate(price.indexed.fromDate, fr)} actualisé au ${fmtDate(price.indexed.toDate, fr)} avec l'indice ${price.indexed.index} (× ${num(price.indexed.factor, lang, 3)}).` : `Price of ${fmtDate(price.indexed.fromDate, fr)} updated to ${fmtDate(price.indexed.toDate, fr)} with the ${price.indexed.index} index (× ${num(price.indexed.factor, lang, 3)}).`}80        </p>81      )}82      {price.note && <p className="border-l-2 border-[var(--amber)] pl-3 text-[12.5px] text-ink-2">{price.note}</p>}83      {isItem && price.sources.length > 0 && (84        <div className="src-wrap">85          <table className="src-table">86            <thead><tr><th>{fr ? "Source" : "Source"}</th><th className="text-right">{fr ? "Valeur observée" : "Observed value"}</th><th>{fr ? "Unité d'origine" : "Original unit"}</th><th>{fr ? "Conversion" : "Conversion"}</th><th>{fr ? "Date" : "Date"}</th></tr></thead>87            <tbody>88              {price.sources.map((s, i) => (89                <tr key={i}>90                  <td>{s.url ? <a href={s.url} target="_blank" rel="noopener noreferrer nofollow" className="border-b border-transparent text-accent hover:border-accent">{s.source} ↗</a> : s.source}{!s.regular && <span className="vp-mono ml-1 text-[9px] uppercase text-[var(--amber)]">{fr ? "promo" : "sale"}</span>}</td>91                  <td className="vp-mono text-right">{s.conversionFactor !== 1 ? `${num(s.price / s.conversionFactor, lang, 2)} $` : `${num(s.price, lang, 2)} $`}</td>92                  <td className="vp-mono text-[12px]">{s.sourceUnit}</td>93                  <td className="vp-mono text-[12px]">{s.conversionFactor === 1 ? "—" : `× ${s.conversionFactor < 0.01 ? s.conversionFactor.toExponential(2) : num(s.conversionFactor, lang, 4)} → ${num(s.price, lang, 2)} $/${unitLabel}`}</td>94                  <td className="vp-mono text-[12px]">{s.observedAt}</td>95                </tr>96              ))}97            </tbody>98          </table>99        </div>100      )}101      <p className="vp-mono text-[10px] uppercase tracking-[0.05em] text-ink-3">{fr ? "Chaque nombre affiché est retraçable : source → observation → conversion → prix canonique → formule → résultat." : "Every displayed number is traceable: source → observation → conversion → canonical price → formula → result."}</p>102    </div>103  );104}105106export { money };107