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 : primitives de formulaire de l'atelier (champs éditoriaux, parts en %, blocs).2"use client";3import type { AttributeSource } from "@/lib/cost/types";4import { AttrBadge } from "./Provenance";56export function Block({ num, title, children, id, aside }: { num: string; title: string; children: React.ReactNode; id?: string; aside?: React.ReactNode }) {7 return (8 <section id={id} className="sec scroll-mt-24">9 <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">10 <p className="vp-mono text-[10px] font-bold uppercase tracking-[0.12em] text-accent">{num} — {title}</p>11 {aside}12 </div>13 <div className="mt-4 space-y-4">{children}</div>14 </section>15 );16}1718export function Field({ label, children, hint, source, fr }: { label: string; children: React.ReactNode; hint?: string; source?: AttributeSource | null; fr?: boolean }) {19 return (20 <label className="block" title={hint}>21 <span className="flex items-center gap-2"><span className="klabel">{label}</span>{source && fr != null && <AttrBadge source={source} fr={fr} />}</span>22 <div className="mt-1">{children}</div>23 </label>24 );25}2627export function NumField({ value, onChange, min, max, step, unit, placeholder, disabled }: { value: number | null | undefined; onChange: (v: number | null) => void; min?: number; max?: number; step?: number; unit?: string; placeholder?: string; disabled?: boolean }) {28 return (29 <div className="flex items-baseline gap-2">30 <input type="number" inputMode="decimal" value={value ?? ""} min={min} max={max} step={step ?? 1} placeholder={placeholder} disabled={disabled}31 onChange={(e) => onChange(e.target.value === "" ? null : Number(e.target.value))} className="vp-input vp-mono text-[14px]" />32 {unit && <span className="vp-mono shrink-0 text-[10.5px] uppercase text-ink-3">{unit}</span>}33 </div>34 );35}3637export function SelectField<T extends string>({ value, onChange, options }: { value: T; onChange: (v: T) => void; options: { key: T; label: string }[] }) {38 return (39 <select value={value} onChange={(e) => onChange(e.target.value as T)} className="vp-input text-[14px]">40 {options.map((o) => (<option key={o.key} value={o.key}>{o.label}</option>))}41 </select>42 );43}4445export function Toggle({ checked, onChange, label }: { checked: boolean; onChange: (v: boolean) => void; label: string }) {46 return (47 <label className="flex items-center gap-2 text-[13.5px]">48 <input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} className="accent-[var(--accent)]" />49 {label}50 </label>51 );52}5354/** Parts en % d'un mélange (revêtements, planchers) — la somme est normalisée par le moteur. */55export function MixField<K extends string>({ value, onChange, options, fr }: { value: Partial<Record<K, number>>; onChange: (v: Partial<Record<K, number>>) => void; options: { key: K; fr: string; en: string }[]; fr: boolean }) {56 const total = Object.values(value as Record<string, number>).reduce((s, v) => s + (v || 0), 0);57 return (58 <div>59 <div className="grid grid-cols-2 gap-x-4 gap-y-1.5 sm:grid-cols-3">60 {options.map((o) => (61 <label key={o.key} className="flex items-center justify-between gap-2 text-[12.5px]">62 <span>{fr ? o.fr : o.en}</span>63 <span className="flex items-baseline gap-1">64 <input type="number" inputMode="numeric" min={0} max={100} value={value[o.key] != null ? Math.round((value[o.key] as number) * 100) : ""} placeholder="0"65 onChange={(e) => { const n = { ...value }; const v = Number(e.target.value); if (!e.target.value || v <= 0) delete n[o.key]; else n[o.key] = v / 100; onChange(n); }}66 className="vp-input vp-mono !min-h-0 w-[56px] !py-1 text-right text-[13px]" />67 <span className="vp-mono text-[10px] text-ink-3">%</span>68 </span>69 </label>70 ))}71 </div>72 <p className={`vp-mono mt-1 text-[10px] uppercase tracking-[0.05em] ${Math.abs(total - 1) > 0.02 && total > 0 ? "text-[var(--amber)]" : "text-ink-3"}`}>73 {fr ? "Total" : "Total"} {Math.round(total * 100)} % {total > 0 && Math.abs(total - 1) > 0.02 ? (fr ? "— normalisé à 100 % par le moteur" : "— normalised to 100 % by the engine") : ""}74 </p>75 </div>76 );77}7879export function PctField({ value, onChange, label, note, amount, fr, lang }: { value: number; onChange: (v: number) => void; label: string; note?: string; amount?: number; fr: boolean; lang: string }) {80 return (81 <div className="grid grid-cols-[1fr_84px_110px] items-center gap-3 border-b border-[var(--line)] py-1.5 text-[13px]" title={note}>82 <span className="flex items-center gap-1.5">{label}{note && <span className="vp-mono cursor-help text-[10px] text-ink-3" aria-label={note}>ⓘ</span>}</span>83 <span className="flex items-baseline gap-1"><input type="number" inputMode="decimal" step={0.1} min={0} max={40} value={value} onChange={(e) => onChange(Number(e.target.value) || 0)} className="vp-input vp-mono !min-h-0 !py-1 text-right text-[13px]" /><span className="vp-mono text-[10px] text-ink-3">%</span></span>84 <span className="vp-mono text-right text-[12.5px]">{amount != null ? new Intl.NumberFormat(lang === "fr" ? "fr-CA" : "en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(amount) : (fr ? "—" : "—")}</span>85 </div>86 );87}88