Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.
TypeScript 90.2%
JavaScript 3.5%
Python 3.4%
CSS 1.9%
HTML 0.6%
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