// Vrai-Prix — méthode du coût : catalogue des assemblages (/cout/assemblage) avec filtre par catégorie / recherche // et, si ?item=, la fiche d'un article (prix canonique, historique, assemblages qui l'utilisent). "use client"; import { useState } from "react"; import Link from "next/link"; import { useLang } from "@/components/LangContext"; import type { CostItem, ItemPrice, UxCategory } from "@/lib/cost/types"; import { UX_CATEGORIES, categoryLabel } from "@/lib/cost/taxonomy"; import { UNIT_LABELS } from "@/lib/cost/units"; import { num } from "@/components/avendre/fmt"; import { KindIcon, KindLegend, fmtDate } from "./Provenance"; import { PriceSourceCard } from "./SourceDrawer"; import { Sparkline } from "./Charts"; export interface CatalogRow { code: string; nameFr: string; nameEn: string; unit: string; category: UxCategory; quality: string | null; economicLife: number | null; material: number; labour: number; equipment: number; direct: number; observedShare: number; freshestObservation: string | null; components: number } export interface ItemCard { item: CostItem; price: ItemPrice | null; history: { date: string; price: number; source: string; url: string | null; regular: boolean; kind: string; outlier: boolean }[]; usedBy: { code: string; nameFr: string; nameEn: string; unit: string; quantity: number }[] } export default function AssemblyCatalog({ rows, asOf, item }: { rows: CatalogRow[]; asOf: string; item: ItemCard | null }) { const { lang } = useLang(); const fr = lang === "fr"; const [q, setQ] = useState(""); const [cat, setCat] = useState(""); const unitL = (x: string) => UNIT_LABELS[x as keyof typeof UNIT_LABELS]?.[fr ? "fr" : "en"] ?? x; const fold = (s: string) => s.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase(); const list = rows.filter((r) => (!cat || r.category === cat) && (!q || fold(fr ? r.nameFr : r.nameEn).includes(fold(q)) || r.code.toLowerCase().includes(q.toLowerCase()))); const cats = UX_CATEGORIES.filter((c) => rows.some((r) => r.category === c.key)); const hist = item ? (() => { const byDate = new Map(); for (const h of item.history) if (!h.outlier && h.kind !== "reference") byDate.set(h.date, [...(byDate.get(h.date) ?? []), h.price]); return [...byDate.entries()].map(([date, ps]) => ({ date, price: ps.sort((a, b) => a - b)[Math.floor(ps.length / 2)] })).sort((a, b) => a.date.localeCompare(b.date)); })() : []; return (
{rows.length} {fr ? "assemblages · prix au" : "assemblies · prices as of"} {asOf} · Montréal
{fr ? "Catalogue" : "Catalogue"}

{fr ? "Les assemblages de construction" : "Construction assemblies"}

{fr ? "Chaque assemblage décompose une composante réelle (mur, toiture, fenêtre, cuisine…) en articles chiffrés, heures de métier et équipement. Le coût direct par unité est recalculé à chaque synchronisation des sources." : "Each assembly breaks a real component (wall, roof, window, kitchen…) down into priced items, trade hours and equipment. The direct cost per unit is recomputed at every source synchronisation."}

{item && (

{fr ? "Article" : "Item"} · {item.item.code}

{fr ? item.item.nameFr : item.item.nameEn}

MasterFormat {item.item.masterformat} · {item.item.division} · {categoryLabel(item.item.category, fr)} · {fr ? "pertes par défaut" : "default waste"} {Math.round(item.item.defaultWastePct * 100)} %

{fr ? "Historique (médiane par date, aberrations exclues)" : "History (median per date, outliers excluded)"}

{item.history.length > 0 && (
{[...item.history].reverse().slice(0, 40).map((h, i) => ())}
{fr ? "Date" : "Date"}{fr ? "Source" : "Source"}{fr ? "Prix" : "Price"}
{h.date}{h.url ? {h.source} ↗ : h.source}{num(h.price, lang, 2)} ${h.outlier ? (fr ? "aberrant" : "outlier") : !h.regular ? (fr ? "promo" : "sale") : ""}
)}

{fr ? "Utilisé par" : "Used by"}

    {item.usedBy.map((u) => (
  • {fr ? u.nameFr : u.nameEn} · {num(u.quantity, lang, 3)} / {fr ? "unité" : "unit"}
  • ))} {!item.usedBy.length &&
  • —
  • }
)}
setQ(e.target.value)} placeholder={fr ? "Rechercher un assemblage…" : "Search an assembly…"} className="vp-input max-w-sm text-base" aria-label={fr ? "Rechercher" : "Search"} />
{cats.map((c) => ())}
{list.map((r) => ( ))} {!list.length && }
{fr ? "Assemblage" : "Assembly"}{fr ? "Catégorie" : "Category"}{fr ? "Matériaux" : "Materials"}{fr ? "Main-d'œuvre" : "Labour"}{fr ? "Équip." : "Equip."}{fr ? "Direct / unité" : "Direct / unit"}{fr ? "Observé" : "Observed"}{fr ? "Vie" : "Life"}{fr ? "Mise à jour" : "Updated"}
{fr ? r.nameFr : r.nameEn}
{r.code} · {r.components} {fr ? "composants" : "components"}
{categoryLabel(r.category, fr)} {num(r.material, lang, 2)} {num(r.labour, lang, 2)} {num(r.equipment, lang, 2)} {num(r.direct, lang, 2)} $ / {unitL(r.unit)} = 0.5 ? "observed" : r.observedShare > 0 ? "derived" : "reference"} fr={fr} /> {Math.round(r.observedShare * 100)} % {r.economicLife ?? "—"} {r.freshestObservation ? fmtDate(r.freshestObservation, fr) : (fr ? "référence" : "reference")}
{fr ? "Aucun assemblage." : "No assembly."}
); }