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%
5.4 KB · 88 lines tsx
Raw Blame History
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