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%
24.4 KB · 237 lines tsx
Raw Blame History
1// Vrai-Prix — méthode du coût : 03 Coût à neuf (tableau par catégorie, indirects, FG/profit/contingence, RCN, fourchette),2// 04 Assemblages (lignes + surcharges + détail du calcul), 05 Main-d'œuvre.3"use client";4import { useState } from "react";5import Link from "next/link";6import type { CostEstimate, CostInput, CostParams, EstimateLine, IndirectParams } from "@/lib/cost/types";7import { INDIRECT_LABELS, categoryLabel, tradeLabel } from "@/lib/cost/taxonomy";8import { UNIT_LABELS } from "@/lib/cost/units";9import { money, num, pct } from "@/components/avendre/fmt";10import { AttrBadge, KindIcon, kindLabel, LayerBadge } from "./Provenance";11import { Drawer, PriceSourceCard } from "./SourceDrawer";12import { Block, PctField } from "./fields";13import { RangeDist, StackBar } from "./Charts";1415const unitL = (u: string, fr: boolean) => UNIT_LABELS[u as keyof typeof UNIT_LABELS]?.[fr ? "fr" : "en"] ?? u;1617export function CostTablePanel({ est, params, fr, patchP, hide }: { est: CostEstimate | null; params: CostParams; fr: boolean; patchP: (p: Partial<CostParams>) => void; hide: boolean }) {18  const lang = fr ? "fr" : "en";19  const f = (v: number | null | undefined) => (hide ? "•••" : money(v, lang));20  const base = est?.directCost ?? 0;21  const indirectBase = base + (est?.indirectCost ?? 0);22  return (23    <Block num="03" title={fr ? "Coût de remplacement à neuf" : "Replacement cost new"} id="s03" aside={est && <span className="vp-mono text-[10px] uppercase tracking-[0.06em] text-ink-3">{fr ? "prix au" : "prices as of"} {est.priceDate} · {fr ? est.location.nameFr : est.location.nameEn}</span>}>24      {!est ? <p className="text-[13.5px] text-ink-2">{fr ? "Calcul en cours…" : "Computing…"}</p> : (25        <>26          <div className="src-wrap">27            <table className="src-table">28              <thead><tr><th>{fr ? "Catégorie" : "Category"}</th><th className="text-right">{fr ? "Matériaux" : "Materials"}</th><th className="text-right">{fr ? "Main-d'œuvre" : "Labour"}</th><th className="text-right">{fr ? "Équipement" : "Equipment"}</th><th className="text-right">{fr ? "Direct" : "Direct"}</th><th className="text-right">{fr ? "Ajust. localisation" : "Location adj."}</th><th className="text-right">{fr ? "Ajusté" : "Adjusted"}</th><th className="text-right">%</th></tr></thead>29              <tbody>30                {est.categories.map((c) => (31                  <tr key={c.category}>32                    <td className="font-semibold">{fr ? c.labelFr : c.labelEn}</td>33                    <td className="vp-mono text-right">{f(c.material)}</td>34                    <td className="vp-mono text-right">{f(c.labour)}</td>35                    <td className="vp-mono text-right">{f(c.equipment)}</td>36                    <td className="vp-mono text-right">{f(c.direct)}</td>37                    <td className="vp-mono text-right text-ink-3">{hide ? "•••" : `${c.adjusted - c.direct >= 0 ? "+" : "−"}${money(Math.abs(c.adjusted - c.direct), lang)}`}</td>38                    <td className="vp-mono text-right font-bold">{f(c.adjusted)}</td>39                    <td className="vp-mono text-right text-ink-3">{num(c.sharePct, lang, 1)}</td>40                  </tr>41                ))}42                <tr className="bg-surface-2 font-bold">43                  <td>{fr ? "Coûts directs" : "Direct costs"}</td>44                  <td className="vp-mono text-right">{f(est.directMaterial)}</td>45                  <td className="vp-mono text-right">{f(est.directLabour)}</td>46                  <td className="vp-mono text-right">{f(est.directEquipment)}</td>47                  <td className="vp-mono text-right">{f(est.categories.reduce((s, c) => s + c.direct, 0))}</td>48                  <td className="vp-mono text-right text-ink-3">{hide ? "•••" : `${est.directCost - est.categories.reduce((s, c) => s + c.direct, 0) >= 0 ? "+" : "−"}${money(Math.abs(est.directCost - est.categories.reduce((s, c) => s + c.direct, 0)), lang)}`}</td>49                  <td className="vp-mono text-right">{f(est.directCost)}</td>50                  <td className="vp-mono text-right">100</td>51                </tr>52              </tbody>53            </table>54          </div>55          {!hide && <StackBar lang={lang} parts={[{ label: fr ? "Matériaux" : "Materials", value: est.directMaterial }, { label: fr ? "Main-d'œuvre" : "Labour", value: est.directLabour }, { label: fr ? "Équipement" : "Equipment", value: est.directEquipment }]} />}56          <div className="grid gap-x-12 gap-y-6 lg:grid-cols-2">57            <div>58              <p className="klabel mb-1">{fr ? "Coûts indirects — % du coût direct (hypothèses modifiables)" : "Indirect costs — % of direct cost (editable assumptions)"}</p>59              {(Object.keys(INDIRECT_LABELS) as (keyof IndirectParams)[]).map((k) => (60                <PctField key={k} fr={fr} lang={lang} label={fr ? INDIRECT_LABELS[k].fr : INDIRECT_LABELS[k].en} note={INDIRECT_LABELS[k].noteFr} value={params.indirect[k]} amount={hide ? undefined : (base * params.indirect[k]) / 100} onChange={(v) => patchP({ indirect: { ...params.indirect, [k]: v } })} />61              ))}62              <div className="grid grid-cols-[1fr_84px_110px] gap-3 py-2 text-[13px] font-bold"><span>{fr ? "Total indirects" : "Total indirects"}</span><span className="vp-mono text-right">{num(Object.values(params.indirect).reduce((s, v) => s + v, 0), lang, 1)} %</span><span className="vp-mono text-right">{f(est.indirectCost)}</span></div>63            </div>64            <div>65              <p className="klabel mb-1">{fr ? "Entrepreneur — frais généraux et profit séparés" : "Contractor — overhead and profit kept separate"}</p>66              <PctField fr={fr} lang={lang} label={fr ? "Frais généraux entrepreneur" : "Contractor overhead"} note={fr ? "Bureau, supervision, assurances d'entreprise, véhicules — appliqués sur (direct + indirects). Hypothèse 7 %." : "Office, supervision, business insurance, vehicles — applied to (direct + indirects). Assumption 7 %."} value={params.overheadPct} amount={hide ? undefined : (indirectBase * params.overheadPct) / 100} onChange={(v) => patchP({ overheadPct: v })} />67              <PctField fr={fr} lang={lang} label={fr ? "Profit entrepreneur" : "Contractor profit"} note={fr ? "Marge bénéficiaire appliquée sur (direct + indirects + frais généraux). Hypothèse 8 %." : "Profit margin applied to (direct + indirects + overhead). Assumption 8 %."} value={params.profitPct} amount={hide ? undefined : est.contractorProfit} onChange={(v) => patchP({ profitPct: v })} />68              <PctField fr={fr} lang={lang} label={fr ? "Contingence" : "Contingency"} note={fr ? "Réserve pour imprévus du projet (sol, conditions cachées), sur (direct + indirects). Ce n'est NI le profit (rémunération du risque de l'entrepreneur) NI un frais indirect (coût réel prévisible)." : "Reserve for project unknowns (soil, hidden conditions), on (direct + indirects). It is NEITHER profit (remuneration of contractor risk) NOR an indirect cost (real foreseeable cost)."} value={params.contingencyPct} amount={hide ? undefined : est.contingency} onChange={(v) => patchP({ contingencyPct: v })} />69              <div className="mt-4 border-t-2 border-ink pt-3">70                <p className="klabel">{fr ? "Coût de remplacement à neuf (RCN)" : "Replacement cost new (RCN)"}</p>71                <p className="vp-display mt-1 text-[clamp(28px,4vw,40px)] font-bold tracking-[-0.03em]">{f(est.replacementCostNew)}</p>72                <p className="vp-mono text-[11px] uppercase tracking-[0.05em] text-ink-2">{hide ? "•••" : `${num(est.perSqft, lang, 0)} $/pi² · ${num(est.perM2, lang, 0)} $/m²`} · {fr ? "fourchette" : "range"} {hide ? "•••" : `${money(est.range.p10, lang)} – ${money(est.range.p90, lang)}`}</p>73                <p className="vp-mono mt-1 text-[10px] uppercase tracking-[0.05em] text-ink-3">RCN = {fr ? "Directs + Indirects + Frais généraux + Profit + Contingence" : "Direct + Indirect + Overhead + Profit + Contingency"}</p>74              </div>75            </div>76          </div>77          {!hide && <RangeDist low={est.range.p10} central={est.replacementCostNew} high={est.range.p90} lang={lang} marker={est.otherReadings?.rollBuilding ?? null} markerLabel={fr ? "Bâtiment au rôle" : "Roll building"} />}78        </>79      )}80    </Block>81  );82}8384/* ------------------------------------------------------------ 04 assemblages */8586export function AssemblyLinesPanel({ est, input, fr, setQty, toggleExclude, hide }: { est: CostEstimate | null; input: CostInput; fr: boolean; setQty: (code: string, q: number | null) => void; toggleExclude: (code: string) => void; hide: boolean }) {87  const lang = fr ? "fr" : "en";88  const [open, setOpen] = useState<EstimateLine | null>(null);89  const [src, setSrc] = useState<{ comp: EstimateLine["unitDetail"]["components"][number]; line: EstimateLine } | null>(null);90  const f = (v: number) => (hide ? "•••" : money(v, lang));91  const excluded = input.excludedAssemblies;92  return (93    <Block num="04" title={fr ? "Assemblages" : "Assemblies"} id="s04" aside={est && <span className="vp-mono text-[10px] uppercase tracking-[0.06em] text-ink-3">{est.lines.length} {fr ? "lignes · cliquez pour le détail du calcul" : "lines · click for calculation detail"}</span>}>94      {!est ? null : (95        <div className="src-wrap">96          <table className="src-table">97            <thead><tr><th>{fr ? "Assemblage" : "Assembly"}</th><th className="text-right">{fr ? "Quantité" : "Quantity"}</th><th>{fr ? "Formule" : "Formula"}</th><th className="text-right">{fr ? "Coût unit." : "Unit cost"}</th><th className="text-right">M</th><th className="text-right">MO</th><th className="text-right">É</th><th className="text-right">{fr ? "Ajusté" : "Adjusted"}</th><th /></tr></thead>98            <tbody>99              {est.lines.map((l) => (100                <tr key={l.assemblyCode} className={excluded.includes(l.assemblyCode) ? "opacity-50" : ""}>101                  <td>102                    <button type="button" onClick={() => setOpen(l)} className="text-left font-semibold hover:text-accent-deep">{fr ? l.nameFr : l.nameEn}</button>103                    <br /><span className="vp-mono text-[10px] uppercase text-ink-3">{l.assemblyCode} · {categoryLabel(l.category, fr)} · {fr ? "part observée" : "observed share"} {Math.round(l.observedShare * 100)} %</span>104                  </td>105                  <td className="vp-mono text-right whitespace-nowrap">106                    <span className="inline-flex items-baseline gap-1">107                      <input type="number" inputMode="decimal" min={0} value={input.quantityOverrides[l.assemblyCode] ?? l.quantity} onChange={(e) => setQty(l.assemblyCode, e.target.value === "" ? null : Number(e.target.value))} className="vp-input vp-mono !min-h-0 w-[88px] !py-0.5 text-right text-[12.5px]" aria-label={fr ? "Quantité" : "Quantity"} />108                      <span className="text-[10px] text-ink-3">{unitL(l.unit, fr)}</span>109                    </span>110                    <div className="mt-0.5 flex justify-end gap-1"><AttrBadge source={l.quantitySource} fr={fr} />{input.quantityOverrides[l.assemblyCode] != null && <button type="button" onClick={() => setQty(l.assemblyCode, null)} className="vp-mono text-[9px] uppercase text-accent">{fr ? "réinit." : "reset"}</button>}</div>111                  </td>112                  <td className="max-w-[260px] text-[11.5px] leading-snug text-ink-2">{l.quantityFormula}</td>113                  <td className="vp-mono text-right">{hide ? "•••" : `${num(l.unitCost, lang, 2)} $`}<br /><span className="text-[10px] text-ink-3">/ {unitL(l.unit, fr)}</span></td>114                  <td className="vp-mono text-right text-[12px]">{f(l.material)}</td>115                  <td className="vp-mono text-right text-[12px]">{f(l.labour)}</td>116                  <td className="vp-mono text-right text-[12px]">{f(l.equipment)}</td>117                  <td className="vp-mono text-right font-bold">{f(l.adjusted)}</td>118                  <td><button type="button" onClick={() => toggleExclude(l.assemblyCode)} className="vp-mono text-[9.5px] uppercase text-ink-3 hover:text-[var(--danger)]">{excluded.includes(l.assemblyCode) ? (fr ? "inclure" : "include") : (fr ? "exclure" : "exclude")}</button></td>119                </tr>120              ))}121              {excluded.filter((c) => !est.lines.some((l) => l.assemblyCode === c)).map((c) => (122                <tr key={c} className="opacity-60"><td colSpan={8} className="vp-mono text-[11px] uppercase text-ink-3">{c} — {fr ? "exclu" : "excluded"}</td><td><button type="button" onClick={() => toggleExclude(c)} className="vp-mono text-[9.5px] uppercase text-accent">{fr ? "inclure" : "include"}</button></td></tr>123              ))}124            </tbody>125          </table>126        </div>127      )}128      <Drawer open={!!open} onClose={() => setOpen(null)} title={fr ? "Détails du calcul" : "Calculation details"} fr={fr}>129        {open && (130          <div className="space-y-4">131            <div>132              <p className="vp-mono text-[10px] uppercase tracking-[0.08em] text-ink-3">{open.assemblyCode}</p>133              <p className="vp-display text-[18px] font-bold leading-tight">{fr ? open.nameFr : open.nameEn}</p>134              <Link href={`/cout/assemblage/${encodeURIComponent(open.assemblyCode)}`} className="vp-mono mt-1 inline-block text-[10.5px] font-bold uppercase tracking-[0.08em] text-accent">{fr ? "Fiche complète de l'assemblage" : "Full assembly card"} →</Link>135            </div>136            <div className="grid grid-cols-2 gap-x-6 gap-y-2">137              <div className="kv-cell"><p className="k">{fr ? "Quantité" : "Quantity"}</p><p className="v vp-mono text-[13px] font-medium">{num(open.quantity, lang, 2)} {unitL(open.unit, fr)} <AttrBadge source={open.quantitySource} fr={fr} /></p></div>138              <div className="kv-cell"><p className="k">{fr ? "Formule de quantité" : "Quantity formula"}</p><p className="v text-[12.5px] font-normal">{open.quantityFormula}</p></div>139              <div className="kv-cell"><p className="k">{fr ? "Coût unitaire direct" : "Unit direct cost"}</p><p className="v vp-mono text-[13px] font-medium">{num(open.unitCost, lang, 2)} $ / {unitL(open.unit, fr)}</p></div>140              <div className="kv-cell"><p className="k">{fr ? "Ligne (avant / après localisation)" : "Line (before / after location)"}</p><p className="v vp-mono text-[13px] font-medium">{money(open.direct, lang)} → {money(open.adjusted, lang)}</p></div>141              <div className="kv-cell"><p className="k">{fr ? "Écart-type de la ligne" : "Line standard deviation"}</p><p className="v vp-mono text-[13px] font-medium">± {money(open.sigma, lang)}</p></div>142              <div className="kv-cell"><p className="k">{fr ? "Vie économique · groupe" : "Economic life · group"}</p><p className="v vp-mono text-[13px] font-medium">{open.economicLife ?? "—"} {fr ? "ans" : "yrs"} · {open.conditionGroup ?? "—"}</p></div>143            </div>144            <p className="vp-mono text-[10px] uppercase tracking-[0.05em] text-ink-3">{fr ? "matériau = qté × (1 + pertes) × prix · main-d'œuvre = heures × taux employeur · par unité d'assemblage" : "material = qty × (1 + waste) × price · labour = hours × employer rate · per assembly unit"}</p>145            <div className="src-wrap">146              <table className="src-table">147                <thead><tr><th>{fr ? "Composant" : "Component"}</th><th className="text-right">{fr ? "Qté / unité" : "Qty / unit"}</th><th className="text-right">{fr ? "Pertes" : "Waste"}</th><th className="text-right">{fr ? "Prix unit." : "Unit price"}</th><th>{fr ? "Prov." : "Prov."}</th><th className="text-right">{fr ? "Matériau" : "Material"}</th><th className="text-right">{fr ? "Heures" : "Hours"}</th><th className="text-right">{fr ? "Taux" : "Rate"}</th><th className="text-right">MO</th><th className="text-right">É</th></tr></thead>148                <tbody>149                  {open.unitDetail.components.map((c, i) => (150                    <tr key={i} className="cursor-pointer" onClick={() => setSrc({ comp: c, line: open })}>151                      <td><span className="font-semibold">{fr ? c.nameFr : c.nameEn}</span>{c.itemCode && <><br /><span className="vp-mono text-[10px] text-ink-3">{c.itemCode}</span></>}{c.trade && c.labourHours > 0 && <><br /><span className="vp-mono text-[10px] text-ink-3">{tradeLabel(c.trade, fr)}</span></>}</td>152                      <td className="vp-mono text-right text-[12px]">{c.itemCode ? `${num(c.quantity, lang, 4)} ${unitL(c.unit, fr)}` : "—"}</td>153                      <td className="vp-mono text-right text-[12px]">{c.itemCode ? `${Math.round(c.wasteFactor * 100)} %` : "—"}</td>154                      <td className="vp-mono text-right text-[12px]">{c.itemCode ? `${num(c.unitPrice, lang, 2)} $` : "—"}</td>155                      <td>{c.itemCode ? <KindIcon kind={c.provenance.kind} fr={fr} source={c.provenance.source} observedAt={c.provenance.observedAt} note={c.provenance.note} /> : <LayerBadge layer="assumption" fr={fr} />}</td>156                      <td className="vp-mono text-right text-[12px]">{c.materialCost ? `${num(c.materialCost, lang, 2)} $` : "—"}</td>157                      <td className="vp-mono text-right text-[12px]">{c.labourHours ? num(c.labourHours, lang, 3) : "—"}</td>158                      <td className="vp-mono text-right text-[12px]">{c.hourlyRate != null ? <span className="inline-flex items-center gap-1">{num(c.hourlyRate, lang, 2)} $/h <KindIcon kind={c.rateProvenance?.kind ?? "assumption"} fr={fr} source={c.rateProvenance?.source} observedAt={c.rateProvenance?.effectiveDate} note={c.rateProvenance?.note} /></span> : "—"}</td>159                      <td className="vp-mono text-right text-[12px]">{c.labourCost ? `${num(c.labourCost, lang, 2)} $` : "—"}</td>160                      <td className="vp-mono text-right text-[12px]">{c.equipmentCost ? `${num(c.equipmentCost, lang, 2)} $` : "—"}</td>161                    </tr>162                  ))}163                  <tr className="bg-surface-2 font-bold"><td>{fr ? "Par unité d'assemblage" : "Per assembly unit"}</td><td colSpan={4} /><td className="vp-mono text-right">{num(open.unitDetail.material, lang, 2)} $</td><td colSpan={2} /><td className="vp-mono text-right">{num(open.unitDetail.labour, lang, 2)} $</td><td className="vp-mono text-right">{num(open.unitDetail.equipment, lang, 2)} $</td></tr>164                </tbody>165              </table>166            </div>167            <p className="vp-mono text-[10px] uppercase tracking-[0.05em] text-ink-3">{fr ? "Cliquez un composant pour sa fiche source." : "Click a component for its source card."}</p>168          </div>169        )}170      </Drawer>171      <Drawer open={!!src} onClose={() => setSrc(null)} title={fr ? "Source" : "Source"} fr={fr}>172        {src && (173          <div className="space-y-6">174            {src.comp.itemCode ? <PriceSourceCard fr={fr} itemName={fr ? src.comp.nameFr : src.comp.nameEn} unit={src.comp.unit} price={src.comp.provenance} locationFactor={est?.location.materialFactor} locationName={est ? (fr ? est.location.nameFr : est.location.nameEn) : undefined} /> : null}175            {src.comp.rateProvenance && (176              <div>177                <p className="klabel">{fr ? "Taux de main-d'œuvre" : "Labour rate"}</p>178                <p className="vp-display mt-1 text-[16px] font-bold">{tradeLabel(src.comp.trade, fr)} — {num(src.comp.hourlyRate ?? 0, lang, 2)} $/h</p>179                <p className="mt-1 inline-flex items-center gap-2 text-[12.5px] text-ink-2"><KindIcon kind={src.comp.rateProvenance.kind} fr={fr} /> {kindLabel(src.comp.rateProvenance.kind, fr)} · {src.comp.rateProvenance.source}{src.comp.rateProvenance.effectiveDate ? ` · ${fr ? "en vigueur le" : "effective"} ${src.comp.rateProvenance.effectiveDate}` : ""}</p>180                {src.comp.rateProvenance.note && <p className="mt-1 text-[12.5px] text-ink-2">{src.comp.rateProvenance.note}</p>}181                {src.comp.rateProvenance.sourceUrl && <a href={src.comp.rateProvenance.sourceUrl} target="_blank" rel="noopener noreferrer nofollow" className="vp-mono mt-1 inline-block text-[10.5px] font-bold uppercase tracking-[0.08em] text-accent">{fr ? "Document source" : "Source document"} ↗</a>}182                <p className="vp-mono mt-2 text-[10px] uppercase tracking-[0.05em] text-ink-3">{fr ? "Coût employeur complet : taux horaire + vacances (13 %) + avantages sociaux + cotisations (AE, RQAP, FSS, CCQ, CNESST…) — jamais le seul salaire." : "Full employer cost: hourly wage + vacation (13 %) + benefits + contributions (EI, QPIP, HSF, CCQ, CNESST…) — never the bare wage."}</p>183              </div>184            )}185          </div>186        )}187      </Drawer>188    </Block>189  );190}191192/* ------------------------------------------------------------ 05 main-d'œuvre */193194export function LabourPanel({ est, fr, hide }: { est: CostEstimate | null; fr: boolean; hide: boolean }) {195  const lang = fr ? "fr" : "en";196  if (!est) return null;197  const byTrade = new Map<string, { hours: number; cost: number; rate: number | null; official: boolean; source: string | null; date: string | null }>();198  for (const l of est.lines) for (const c of l.unitDetail.components) {199    if (!(c.labourHours > 0)) continue;200    const k = c.trade ?? "?";201    const cur = byTrade.get(k) ?? { hours: 0, cost: 0, rate: c.hourlyRate, official: c.rateProvenance?.kind === "official", source: c.rateProvenance?.source ?? null, date: c.rateProvenance?.effectiveDate ?? null };202    cur.hours += c.labourHours * l.quantity;203    cur.cost += c.labourCost * l.quantity * est.location.labourFactor;204    byTrade.set(k, cur);205  }206  const rows = [...byTrade.entries()].sort((a, b) => b[1].cost - a[1].cost);207  const totalH = rows.reduce((s, [, r]) => s + r.hours, 0);208  const officialShare = est.directLabour > 0 ? rows.filter(([, r]) => r.official).reduce((s, [, r]) => s + r.cost, 0) / est.directLabour : 0;209  return (210    <Block num="05" title={fr ? "Main-d'œuvre" : "Labour"} id="s05" aside={<span className="vp-mono text-[10px] uppercase tracking-[0.06em] text-ink-3">{fr ? "part des taux officiels" : "official-rate share"} : {Math.round(officialShare * 100)} %</span>}>211      <div className="grid grid-cols-3 gap-x-6">212        <div className="kv-cell"><p className="k">{fr ? "Heures totales" : "Total hours"}</p><p className="v">{hide ? "•••" : num(totalH, lang)} h</p></div>213        <div className="kv-cell"><p className="k">{fr ? "Coût de main-d'œuvre (localisé)" : "Labour cost (localised)"}</p><p className="v">{hide ? "•••" : money(est.directLabour, lang)}</p></div>214        <div className="kv-cell"><p className="k">{fr ? "Facteur régional MO" : "Regional labour factor"}</p><p className="v">× {num(est.location.labourFactor, lang, 2)}</p></div>215      </div>216      <div className="src-wrap">217        <table className="src-table">218          <thead><tr><th>{fr ? "Métier" : "Trade"}</th><th className="text-right">{fr ? "Heures" : "Hours"}</th><th className="text-right">{fr ? "Taux employeur" : "Employer rate"}</th><th>{fr ? "Source · date" : "Source · date"}</th><th className="text-right">{fr ? "Coût" : "Cost"}</th><th className="text-right">%</th></tr></thead>219          <tbody>220            {rows.map(([t, r]) => (221              <tr key={t}>222                <td className="font-semibold">{tradeLabel(t, fr)}</td>223                <td className="vp-mono text-right">{hide ? "•••" : num(r.hours, lang)}</td>224                <td className="vp-mono text-right">{r.rate != null ? `${num(r.rate, lang, 2)} $/h` : "—"} <KindIcon kind={r.official ? "official" : "assumption"} fr={fr} /></td>225                <td className="text-[12px] text-ink-2">{r.official ? `${r.source}${r.date ? ` · ${r.date}` : ""}` : (fr ? "Taux de repli (hypothèse) — aucune grille" : "Fallback rate (assumption) — no grid")}</td>226                <td className="vp-mono text-right font-bold">{hide ? "•••" : money(r.cost, lang)}</td>227                <td className="vp-mono text-right text-ink-3">{est.directLabour ? pct((r.cost / est.directLabour) * 100, lang, 1, false) : "—"}</td>228              </tr>229            ))}230          </tbody>231        </table>232      </div>233      <p className="vp-mono text-[10px] uppercase leading-relaxed tracking-[0.05em] text-ink-3">{fr ? "Heures = Σ (heures par unité d'assemblage × quantité). Taux = coût employeur complet des grilles APCHQ (conventions CCQ, secteur résidentiel léger, compagnon). Les heures de productivité sont des hypothèses Vrai-Prix." : "Hours = Σ (hours per assembly unit × quantity). Rate = full employer cost from APCHQ grids (CCQ agreements, light residential sector, journeyperson). Productivity hours are Vrai-Prix assumptions."}</p>234    </Block>235  );236}237