File created successfully at: /Users/USER/Documents/forfun/p-app-arm3/src/index.js (file state is current in your context — no need to Read it back)

import { Hub } from "./hub.js";
export { Hub };

// ── The Procurement Sandbox we integrate against (docs + mock are one deploy) ──
const BASE = "https://partifact-mock.thanhvuttv.workers.dev";
const API = `${BASE}/api/2026-01`;

// Pre-loaded demo credentials (per the Authentication doc). The sandbox bundles
// BOTH roles so one caller can drive the whole two-party loop: the repairer
// places/reads, the supplier confirms. Webhook secrets are the seeded pwh_ keys.
const CREDS = {
  repairer: {
    key: "partifact_demo_repairer_3f8a1c0d9e2b4a67b1c2",
    integration: "0c000000-0000-4000-8000-000000000001",
    secret: "pwh_demo_repairer_a1b2c3d4e5f6",
  },
  supplier: {
    key: "partifact_demo_supplier_8b4e2f1a6c0d3e9f7a25",
    integration: "0c000000-0000-4000-8000-000000000002",
    secret: "pwh_demo_supplier_9a8b7c6d5e4f",
  },
};
const SECRET_BY_INTEGRATION = {
  [CREDS.repairer.integration]: CREDS.repairer.secret,
  [CREDS.supplier.integration]: CREDS.supplier.secret,
};

// Single-confirmable-supplier constraint (D53): the sandbox seeds three supplier
// orgs but only S1 has a confirming credential, so only orders placed entirely
// with S1 can ever reach order_confirmed.
const CONFIRMABLE_SUPPLIER_ID = "0b000000-0000-4000-8000-000000000010";
const CONFIRMABLE_SUPPLIER_NAME = "Christchurch Toyota — Parts";

// The wire has no "find job by VIN" method (jobs.get resolves only by external
// ref / id). The sandbox seeds exactly one job, so we map its known vehicle
// identifiers to the job's external reference and verify the VIN on the way back.
const KNOWN_JOBS = [
  {
    vin: "JTDBR32E730012345",
    external: "CCC-2026-04817",
    plate: "MJL472",
    jobNumber: "J-4817",
    claim: "AMI-CLM-771204",
  },
];

const json = (data, status = 200) =>
  new Response(JSON.stringify(data), {
    status,
    headers: { "Content-Type": "application/json" },
  });

/** One RPC-over-POST call to the sandbox, with the role's two auth headers + tenant. */
async function callApi(method, body, role, tenant) {
  const c = CREDS[role];
  let res, text;
  try {
    res = await fetch(`${API}/${method}`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${c.key}`,
        "Partly-Integration-ID": c.integration,
        "x-partifact-tenant": tenant,
      },
      body: JSON.stringify(body ?? {}),
    });
    text = await res.text();
  } catch (e) {
    // Transport fault — the only retryable class (Errors doc, D41).
    return { ok: false, status: 0, data: { type: "network_error", retryable: true }, error: String(e) };
  }
  let data;
  try { data = JSON.parse(text); } catch { data = text; } // parts.insert can return a bare string (D24)
  return { ok: res.ok, status: res.status, data };
}

/** Register a webhook consumer URL for one integration in this tenant's world. */
async function subscribe(integration, url, tenant) {
  try {
    const res = await fetch(`${BASE}/__webhooks/subscribe`, {
      method: "POST",
      headers: { "Content-Type": "application/json", "x-partifact-tenant": tenant },
      body: JSON.stringify({ integration_id: integration, url }),
    });
    return await res.json();
  } catch (e) {
    return { ok: false, reason: String(e) };
  }
}

/**
 * Verify a Procurement Sandbox webhook — ported exactly from the Webhooks doc.
 * HMAC-SHA256 over the RAW body bytes, base64-decode the header, length-check,
 * constant-time compare, then the ±5-minute replay window on webhook_timestamp.
 */
async function verifyWebhook(rawBody, signatureHeader, secret, nowMs = Date.now()) {
  if (!signatureHeader) return { ok: false, reason: "missing_signature" };

  const key = await crypto.subtle.importKey(
    "raw",
    new TextEncoder().encode(secret),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign"],
  );
  const sigBuf = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(rawBody));
  const expected = new Uint8Array(sigBuf);

  let received;
  try {
    const bin = atob(signatureHeader);
    received = Uint8Array.from(bin, (c) => c.charCodeAt(0));
  } catch {
    return { ok: false, reason: "bad_signature" };
  }
  if (received.length !== expected.length) return { ok: false, reason: "bad_signature" };
  let diff = 0;
  for (let i = 0; i < expected.length; i++) diff |= expected[i] ^ received[i];
  if (diff !== 0) return { ok: false, reason: "bad_signature" };

  let ts;
  try { ts = JSON.parse(rawBody).webhook_timestamp; }
  catch { return { ok: false, reason: "missing_timestamp" }; }
  if (typeof ts !== "string") return { ok: false, reason: "missing_timestamp" };
  const tsMs = Date.parse(ts);
  if (Number.isNaN(tsMs)) return { ok: false, reason: "missing_timestamp" };
  if (Math.abs(nowMs - tsMs) > 5 * 60 * 1000) return { ok: false, reason: "stale_timestamp" };

  return { ok: true };
}

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    const path = url.pathname;

    // Live channel: browser ⇄ Hub(tenant) over WebSocket.
    if (path === "/ws") {
      const tenant = url.searchParams.get("tenant");
      if (!tenant) return new Response("missing tenant", { status: 400 });
      return env.HUB.getByName(tenant).fetch(request);
    }

    // Inbound signed webhook from the sandbox: /webhook/<tenant>.
    if (path.startsWith("/webhook/")) {
      if (request.method !== "POST") return new Response("method not allowed", { status: 405 });
      const tenant = decodeURIComponent(path.slice("/webhook/".length));
      return handleWebhook(request, env, tenant);
    }

    // App API: browser → worker → sandbox.
    if (path.startsWith("/api/")) return handleApi(request, env, url);

    // Everything else → static SPA assets.
    return env.ASSETS.fetch(request);
  },
};

async function handleWebhook(request, env, tenant) {
  const raw = await request.text(); // RAW bytes first — never re-serialize before verifying.
  const sig = request.headers.get("partly-hmac-sha256");

  let body = null;
  try { body = JSON.parse(raw); } catch { /* keep null; verify will fail cleanly */ }

  const integrationId = body?.integration_id;
  const secret = integrationId ? SECRET_BY_INTEGRATION[integrationId] : null;
  const verify = secret
    ? await verifyWebhook(raw, sig, secret)
    : { ok: false, reason: "unknown_integration" };

  let deduped = false;
  try {
    const r = await env.HUB.getByName(tenant).ingest({ body, verify });
    deduped = r.deduped;
  } catch { /* never fail the delivery on our side */ }

  // Ack fast (200) regardless — the contract is at-least-once and best-effort.
  return json({ ok: true, verified: verify.ok, reason: verify.reason ?? null, deduped });
}

async function handleApi(request, env, url) {
  const action = url.pathname.slice("/api/".length);

  if (action === "state") {
    const tenant = url.searchParams.get("tenant");
    if (!tenant) return json({ error: "missing tenant" }, 400);
    return json(await env.HUB.getByName(tenant).getState());
  }

  if (request.method !== "POST") return json({ error: "method not allowed" }, 405);
  let req;
  try { req = await request.json(); } catch { return json({ error: "bad body" }, 400); }
  if (!req.tenant) return json({ error: "missing tenant" }, 400);

  switch (action) {
    case "open": return apiOpen(req, url);
    case "job": return apiJob(req);
    case "place": return apiPlace(req);
    case "confirm": return apiConfirm(req);
    default: return json({ error: "unknown action" }, 404);
  }
}

function resolveJob(query) {
  const q = String(query || "").trim().toUpperCase();
  if (!q) return null;
  return (
    KNOWN_JOBS.find(
      (j) =>
        j.vin.toUpperCase() === q ||
        j.external.toUpperCase() === q ||
        j.plate.toUpperCase() === q ||
        j.jobNumber.toUpperCase() === q ||
        j.claim.toUpperCase() === q,
    ) || null
  );
}

/** Open a job from a VIN (or RO/plate/claim): warm the catalogue, register
 *  webhooks for this tenant, fetch the job header, and verify the VIN. */
async function apiOpen(req, url) {
  const { tenant } = req;
  const match = resolveJob(req.query);
  if (!match) {
    return json({
      found: false,
      message:
        "No open repair job matches that VIN/reference in this sandbox. " +
        "Try the seeded 2019 Toyota Corolla — VIN JTDBR32E730012345 (RO CCC-2026-04817).",
    });
  }

  // 1) Lifecycle step 1 — faithful catalogue warmup for the VIN (no state change).
  const prepare = await callApi("tier1.prepare", { identifiers: [{ vin: match.vin }] }, "repairer", tenant);

  // 2) Register webhook subscriptions for BOTH integrations → our /webhook/<tenant>.
  //    Idempotent; registrations persist for the life of the sandbox slot.
  const origin = new URL(url).origin;
  const hookUrl = `${origin}/webhook/${encodeURIComponent(tenant)}`;
  const subscriptions = {
    repairer: await subscribe(CREDS.repairer.integration, hookUrl, tenant),
    supplier: await subscribe(CREDS.supplier.integration, hookUrl, tenant),
  };

  // 3) Fetch the job header and verify the VIN against the wire vehicle record.
  const jobRes = await callApi("repairer.jobs.get", { identity: { external: match.external } }, "repairer", tenant);
  if (!jobRes.ok) return json({ found: false, error: jobRes.data, status: jobRes.status });

  const job = jobRes.data;
  const vinMatches = job?.vehicle?.chassis_number === match.vin;
  return json({
    found: true,
    external: match.external,
    vin: match.vin,
    vinMatches,
    job,
    prepare: prepare.data,
    subscriptions,
    webhook_url: hookUrl,
  });
}

/** Composite job view (mirrors the MCP get_job aggregation the wire doesn't do). */
async function apiJob(req) {
  const { tenant, external } = req;
  if (!external) return json({ error: "missing external" }, 400);

  const [job, parts, basket, procurements] = await Promise.all([
    callApi("repairer.jobs.get", { identity: { external } }, "repairer", tenant),
    callApi("repairer.jobs.parts.list", { job_identity: { external } }, "repairer", tenant),
    callApi("repairer.jobs.baskets.latest.get", { job_identity: { external } }, "repairer", tenant),
    callApi("repairer.procurements.list", { job_identity: { external } }, "repairer", tenant),
  ]);
  // Invoices (proposed extension) appear only after order_confirmed.
  const invoices = await callApi("repairer.procurements.invoices.list", { job_identity: { external } }, "repairer", tenant);

  return json({
    job: job.data,
    parts: parts.data,
    basket: basket.data,
    procurements: procurements.data,
    invoices: invoices.data,
    confirmable_supplier: { id: CONFIRMABLE_SUPPLIER_ID, name: CONFIRMABLE_SUPPLIER_NAME },
  });
}

/** Place a procurement — with the D53 single-confirmable-supplier guard up front. */
async function apiPlace(req) {
  const { tenant, external } = req;
  const selections = req.offer_selections || [];
  if (!external || selections.length === 0) {
    return json({ ok: false, error: "missing external or offer_selections" }, 400);
  }

  const badIds = [
    ...new Set(selections.map((s) => s.supplier_business_id).filter((id) => id !== CONFIRMABLE_SUPPLIER_ID)),
  ];
  if (badIds.length > 0) {
    // Mirror the MCP's pre-wire guard so a dead-end order is never created.
    return json(
      {
        ok: false,
        guard: "supplier_scope",
        code: "unprocessable",
        http_status: 0,
        message:
          "No order was placed. All selected offers must come from a single supplier this " +
          `server can later confirm as — "${CONFIRMABLE_SUPPLIER_NAME}" (its OEM "Toyota Genuine …" ` +
          "offers). An order placed with any other supplier could never reach order_confirmed, so it " +
          `is refused up front. Re-select offers from ${CONFIRMABLE_SUPPLIER_NAME}, then place again.`,
        detail:
          `You selected offers from supplier_business_id(s) ${badIds.join(", ")}. The only confirmable ` +
          `supplier in this demo is "${CONFIRMABLE_SUPPLIER_NAME}" (business_id ${CONFIRMABLE_SUPPLIER_ID}).`,
      },
      422,
    );
  }

  const res = await callApi(
    "repairer.procurements.insert",
    { job_identity: { external }, offer_selections: selections },
    "repairer",
    tenant,
  );
  return json({ ok: res.ok, status: res.status, data: res.data });
}

/** Confirm a procurement — supplier scope (order_requested → order_confirmed). */
async function apiConfirm(req) {
  const { tenant } = req;
  const id = req.procurement_id;
  if (!id) return json({ ok: false, error: "missing procurement_id" }, 400);
  const res = await callApi("supplier.procurements.confirm", { identity: { id } }, "supplier", tenant);
  return json({ ok: res.ok, status: res.status, data: res.data });
}


I run a collision repair shop. Build me a small web app — a ‘Parts Desk’ — where my parts manager can open a repair job from a VIN, see recommended supplier offers with prices, place and confirm a par…


Build parts desk web app for collision repair shop


Now the frontend — a polished, dependency-free SPA. Starting with the HTML shell and styles.


Write

<!doctype html>

    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Parts Desk — Collision Parts Procurement</title>
    <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E%F0%9F%94%A7%3C/text%3E%3C/svg%3E" />
    <link rel="stylesheet" href="/styles.css" />

        🔧

PARTS DESK

Collision parts procurement

          connecting…

          sandbox

        <button class="btn ghost" id="resetBtn" title="Start a clean, freshly-seeded sandbox">New sandbox</button>

Open a repair job

Enter a vehicle VIN to pull the active collision-repair job (RO number, plate, or claim also work).

          <form id="searchForm" class="search-row" autocomplete="off">
            <input id="vinInput" class="vin-input mono" placeholder="VIN — e.g. JTDBR32E730012345" spellcheck="false" />
            <button class="btn primary" id="openBtn" type="submit">Open job</button>
          </form>

            Seeded demo vehicle:
            <button class="chip-btn mono" data-fill="JTDBR32E730012345">VIN JTDBR32E730012345</button>
            <button class="chip-btn mono" data-fill="CCC-2026-04817">RO CCC-2026-04817</button>
            <button class="chip-btn mono" data-fill="MJL472">Plate MJL472</button>

Parts & supplier offers

            <button class="btn primary" id="placeBtn">Place order</button>

            Prices are trade_price, ex-GST. Currency is a presentation label on
            baskets — the wire carries no currency on offers (D52); it’s confirmed on the order & invoice
            (currency_code).

Orders

            status updates arrive live over verified webhooks →

Reconciled invoices

            created on order confirmation

Live events

            0

No webhook deliveries yet. Place or confirm an order to see signed events arrive.

            ✓ verified HMAC-SHA256 over raw bytes, ±5-min window
            dup deduped on message_id


File created successfully at: /Users/USER/Documents/forfun/p-app-arm3/public/index.html (file state is current in your context — no need to Read it back)

<!doctype html>

    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Parts Desk — Collision Parts Procurement</title>
    <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E%F0%9F%94%A7%3C/text%3E%3C/svg%3E" />
    <link rel="stylesheet" href="/styles.css" />

        🔧

PARTS DESK

Collision parts procurement

          connecting…

          sandbox

        <button class="btn ghost" id="resetBtn" title="Start a clean, freshly-seeded sandbox">New sandbox</button>

Open a repair job

Enter a vehicle VIN to pull the active collision-repair job (RO number, plate, or claim also work).

          <form id="searchForm" class="search-row" autocomplete="off">
            <input id="vinInput" class="vin-input mono" placeholder="VIN — e.g. JTDBR32E730012345" spellcheck="false" />
            <button class="btn primary" id="openBtn" type="submit">Open job</button>
          </form>

            Seeded demo vehicle:
            <button class="chip-btn mono" data-fill="JTDBR32E730012345">VIN JTDBR32E730012345</button>
            <button class="chip-btn mono" data-fill="CCC-2026-04817">RO CCC-2026-04817</button>
            <button class="chip-btn mono" data-fill="MJL472">Plate MJL472</button>

Parts & supplier offers

            <button class="btn primary" id="placeBtn">Place order</button>

            Prices are trade_price, ex-GST. Currency is a presentation label on
            baskets — the wire carries no currency on offers (D52); it’s confirmed on the order & invoice
            (currency_code).

Orders

            status updates arrive live over verified webhooks →

Reconciled invoices

            created on order confirmation

Live events

            0

No webhook deliveries yet. Place or confirm an order to see signed events arrive.

            ✓ verified HMAC-SHA256 over raw bytes, ±5-min window
            dup deduped on message_id


:root {
  --bg: #0e1116;
  --bg-2: #151a22;
  --card: #1a212b;
  --card-2: #212a36;
  --line: #2a3340;
  --line-2: #364150;
  --text: #e6edf3;
  --muted: #8b97a7;
  --muted-2: #6b7686;
  --amber: #f5a623;
  --amber-dim: #b9791a;
  --green: #34d399;
  --green-dim: #157f5a;
  --red: #f06a6a;
  --blue: #5fb0ff;
  --radius: 2px;
  --mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
  --sans: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}

* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
  background: var(--bg);
  color: var(--text);
  font-family: var(--sans);
  font-size: 14px;
  line-height: 1.45;
  -webkit-font-smoothing: antialiased;
}
.mono { font-family: var(--mono); }
.muted { color: var(--muted); }

/* ── Top bar ───────────────────────────────────────────── */
.topbar {
  display: flex; align-items: center; justify-content: space-between;
  padding: 12px 20px;
  background: linear-gradient(180deg, #161c25, #11161d);
  border-bottom: 1px solid var(--line);
  position: sticky; top: 0; z-index: 10;
}
.brand { display: flex; align-items: center; gap: 12px; }
.logo { font-size: 26px; filter: saturate(0.85); }
.brand-name { font-weight: 700; letter-spacing: 2px; font-size: 16px; }
.brand-sub { color: var(--muted); font-size: 11px; letter-spacing: 0.5px; }
.topbar-right { display: flex; align-items: center; gap: 10px; }

.conn { display: flex; align-items: center; gap: 7px; font-size: 12px; color: var(--muted); padding: 5px 10px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-2); }
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted-2); box-shadow: 0 0 0 0 rgba(52,211,153,0); }
.dot.live { background: var(--green); animation: pulse 2s infinite; }
.dot.off { background: var(--red); }
@keyframes pulse { 0% { box-shadow: 0 0 0 0 rgba(52,211,153,.5);} 70% { box-shadow: 0 0 0 6px rgba(52,211,153,0);} 100% { box-shadow: 0 0 0 0 rgba(52,211,153,0);} }

.sandbox-chip { font-size: 12px; padding: 5px 10px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-2); }
.sandbox-chip .mono { color: var(--amber); }

/* ── Buttons ───────────────────────────────────────────── */
.btn {
  font-family: inherit; font-size: 13px; font-weight: 600;
  padding: 8px 14px; border-radius: var(--radius); border: 1px solid var(--line-2);
  background: var(--card-2); color: var(--text); cursor: pointer; transition: background .12s, border-color .12s, opacity .12s;
}
.btn:hover { background: #28323f; }
.btn:disabled { opacity: .45; cursor: not-allowed; }
.btn.primary { background: var(--amber); border-color: var(--amber); color: #1a1206; }
.btn.primary:hover:not(:disabled) { background: #ffb733; }
.btn.ghost { background: transparent; }
.btn.confirm { background: var(--green-dim); border-color: #1d9e70; color: #eafff6; }
.btn.confirm:hover:not(:disabled) { background: #1d9e70; }
.btn.sm { padding: 6px 11px; font-size: 12px; }

/* ── Layout ────────────────────────────────────────────── */
.layout {
  display: grid; grid-template-columns: 1fr 360px; gap: 16px;
  max-width: 1240px; margin: 18px auto; padding: 0 20px;
  align-items: start;
}
.col-main { display: flex; flex-direction: column; gap: 16px; min-width: 0; }
.col-side { position: sticky; top: 76px; }
@media (max-width: 940px) {
  .layout { grid-template-columns: 1fr; }
  .col-side { position: static; }
}

/* ── Cards ─────────────────────────────────────────────── */
.card { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); padding: 16px 18px; }
.card-head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; margin-bottom: 12px; }
.card-title { font-size: 13px; font-weight: 700; letter-spacing: 1px; text-transform: uppercase; color: var(--text); margin: 0; }
.card-note { font-size: 11px; color: var(--muted); }
.card-help { color: var(--muted); margin: 2px 0 14px; font-size: 13px; }

/* ── Search ────────────────────────────────────────────── */
.search-row { display: flex; gap: 10px; }
.vin-input { flex: 1; padding: 11px 13px; background: var(--bg); border: 1px solid var(--line-2); border-radius: var(--radius); color: var(--text); font-size: 15px; letter-spacing: 1px; }
.vin-input:focus { outline: none; border-color: var(--amber); }
.hints { margin-top: 12px; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; font-size: 12px; }
.chip-btn { background: var(--bg-2); border: 1px dashed var(--line-2); color: var(--blue); padding: 4px 9px; border-radius: var(--radius); cursor: pointer; font-size: 11.5px; }
.chip-btn:hover { border-color: var(--blue); }
.search-msg { margin-top: 12px; padding: 10px 12px; border-radius: var(--radius); font-size: 13px; }
.search-msg.err { background: rgba(240,106,106,.1); border: 1px solid rgba(240,106,106,.4); color: #ffc4c4; }

/* ── Job header ────────────────────────────────────────── */
.job-card { display: flex; flex-direction: column; gap: 12px; }
.job-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.job-vehicle { font-size: 19px; font-weight: 700; }
.job-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px 18px; }
.kv { display: flex; flex-direction: column; gap: 2px; }
.kv .k { font-size: 10.5px; letter-spacing: .5px; text-transform: uppercase; color: var(--muted-2); }
.kv .v { font-size: 13.5px; }
.vin-ok { color: var(--green); font-size: 11px; }
.vin-bad { color: var(--red); font-size: 11px; }

/* ── Parts list ────────────────────────────────────────── */
.parts-list { display: flex; flex-direction: column; gap: 10px; }
.part {
  border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-2);
  padding: 12px 14px;
}
.part-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 4px; }
.part-name { font-weight: 600; font-size: 14px; }
.part-meta { font-size: 11px; color: var(--muted); margin-bottom: 8px; }
.offers { display: flex; flex-direction: column; gap: 6px; }
.offer {
  display: flex; align-items: center; gap: 10px; padding: 8px 10px;
  border: 1px solid var(--line); border-radius: var(--radius); background: var(--card);
  cursor: pointer; transition: border-color .12s, background .12s;
}
.offer:hover { border-color: var(--line-2); }
.offer.selected { border-color: var(--amber); background: rgba(245,166,35,.08); }
.offer.disabled { opacity: .62; cursor: not-allowed; }
.offer input { accent-color: var(--amber); width: 15px; height: 15px; }
.offer-main { flex: 1; min-width: 0; }
.offer-name { font-size: 13px; }
.offer-supplier { font-size: 11px; color: var(--muted); display: flex; align-items: center; gap: 6px; }
.offer-price { font-family: var(--mono); font-size: 14px; font-weight: 600; white-space: nowrap; }
.offer-price .cur { font-size: 10px; color: var(--muted); font-weight: 400; }
.tag { font-size: 9.5px; letter-spacing: .4px; text-transform: uppercase; padding: 2px 6px; border-radius: var(--radius); border: 1px solid; }
.tag.oem { color: var(--amber); border-color: var(--amber-dim); }
.tag.aftm { color: var(--blue); border-color: #2f6da3; }
.tag.compare { color: var(--muted-2); border-color: var(--line-2); }

.status-chip { font-size: 10px; letter-spacing: .4px; text-transform: uppercase; padding: 3px 8px; border-radius: var(--radius); border: 1px solid; white-space: nowrap; }
.status-chip.estimated { color: var(--muted); border-color: var(--line-2); background: var(--bg); }
.status-chip.ordered { color: var(--blue); border-color: #2f6da3; background: rgba(95,176,255,.08); }

.part.done .offers { display: none; }

/* ── Order bar ─────────────────────────────────────────── */
.order-bar {
  margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--line);
  display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap;
}
.order-summary { font-size: 13px; color: var(--muted); }
.order-summary b { color: var(--text); font-family: var(--mono); }

.footnote { margin: 14px 0 0; font-size: 11px; color: var(--muted-2); line-height: 1.5; border-top: 1px dashed var(--line); padding-top: 10px; }
.footnote .mono { color: var(--muted); }

/* ── Orders ────────────────────────────────────────────── */
.orders-list { display: flex; flex-direction: column; gap: 12px; }
.order {
  border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-2); padding: 13px 15px;
}
.order.flash { animation: flash 1.1s ease; }
@keyframes flash { 0% { background: rgba(52,211,153,.18); } 100% { background: var(--bg-2); } }
.order-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.order-ref { font-family: var(--mono); font-weight: 600; font-size: 14px; }
.order-sub { font-size: 11px; color: var(--muted); margin-top: 2px; }
.order-lines { margin-top: 10px; display: flex; flex-direction: column; gap: 4px; font-size: 12.5px; }
.order-line { display: flex; justify-content: space-between; gap: 10px; color: var(--muted); }
.order-line .desc { color: var(--text); }
.order-line .amt { font-family: var(--mono); }
.order-foot { margin-top: 10px; padding-top: 10px; border-top: 1px dashed var(--line); display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.order-total { font-family: var(--mono); font-size: 14px; }
.order-total .cur { font-size: 10px; color: var(--muted); }

.badge-status { font-size: 11px; font-weight: 700; letter-spacing: .5px; text-transform: uppercase; padding: 4px 10px; border-radius: var(--radius); border: 1px solid; }
.badge-status.order_requested { color: var(--amber); border-color: var(--amber-dim); background: rgba(245,166,35,.08); }
.badge-status.order_confirmed { color: var(--green); border-color: var(--green-dim); background: rgba(52,211,153,.1); }
.badge-status.cancelled { color: var(--red); border-color: #7f2a2a; }
.badge-status.confirming { color: var(--blue); border-color: #2f6da3; }
.badge-status .spin { display: inline-block; animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }

.dead-end { font-size: 11px; color: var(--red); margin-top: 8px; }

/* ── Invoice ───────────────────────────────────────────── */
.invoice-list { display: flex; flex-direction: column; gap: 12px; }
.invoice { border: 1px solid var(--green-dim); border-radius: var(--radius); background: rgba(52,211,153,.05); padding: 13px 15px; }
.invoice-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 10px; }
.invoice-title { font-weight: 700; }
.invoice-table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
.invoice-table th { text-align: left; color: var(--muted-2); font-weight: 500; font-size: 10.5px; text-transform: uppercase; letter-spacing: .4px; padding: 4px 6px; border-bottom: 1px solid var(--line); }
.invoice-table td { padding: 6px; border-bottom: 1px solid var(--line); }
.invoice-table td.num { font-family: var(--mono); text-align: right; }
.invoice-table tfoot td { font-family: var(--mono); font-weight: 700; border-bottom: none; padding-top: 8px; }
.recon { font-size: 10px; text-transform: uppercase; letter-spacing: .4px; padding: 2px 7px; border-radius: var(--radius); border: 1px solid var(--green-dim); color: var(--green); }

/* ── Live feed ─────────────────────────────────────────── */
.feed-card { display: flex; flex-direction: column; max-height: calc(100vh - 110px); }
.feed-count { font-family: var(--mono); font-size: 12px; background: var(--bg-2); border: 1px solid var(--line); padding: 1px 8px; border-radius: var(--radius); color: var(--amber); }
.hook-readout { font-size: 10.5px; color: var(--muted); background: var(--bg); border: 1px solid var(--line); border-radius: var(--radius); padding: 8px 10px; margin-bottom: 10px; word-break: break-all; }
.hook-readout .lbl { color: var(--muted-2); text-transform: uppercase; letter-spacing: .4px; font-size: 9.5px; }
.hook-readout .url { color: var(--blue); }
.feed { overflow-y: auto; display: flex; flex-direction: column; gap: 8px; flex: 1; }
.feed-empty { color: var(--muted-2); font-size: 12.5px; padding: 16px 4px; text-align: center; }
.event {
  border: 1px solid var(--line); border-left-width: 3px; border-radius: var(--radius);
  background: var(--bg-2); padding: 9px 11px; font-size: 12px; animation: slidein .25s ease;
}
@keyframes slidein { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
.event.verified { border-left-color: var(--green); }
.event.rejected { border-left-color: var(--red); }
.event-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 4px; }
.event-type { font-family: var(--mono); font-size: 11.5px; color: var(--text); }
.event-time { font-size: 10px; color: var(--muted-2); white-space: nowrap; }
.event-body { font-size: 11px; color: var(--muted); display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.event-mid { font-family: var(--mono); font-size: 10px; color: var(--muted-2); }
.badge { font-size: 9.5px; letter-spacing: .3px; padding: 2px 6px; border-radius: var(--radius); border: 1px solid; white-space: nowrap; }
.badge.ok { color: var(--green); border-color: var(--green-dim); background: rgba(52,211,153,.08); }
.badge.bad { color: var(--red); border-color: #7f2a2a; background: rgba(240,106,106,.08); }
.badge.dup { color: var(--amber); border-color: var(--amber-dim); }
.badge.kind { color: var(--blue); border-color: #2f6da3; }
.feed-legend { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--line); display: flex; flex-direction: column; gap: 5px; font-size: 10.5px; color: var(--muted); }

/* ── Toast ─────────────────────────────────────────────── */
.toast {
  position: fixed; bottom: 22px; left: 50%; transform: translateX(-50%);
  background: var(--card-2); border: 1px solid var(--line-2); border-radius: var(--radius);
  padding: 12px 18px; font-size: 13px; max-width: 560px; z-index: 50;
  box-shadow: 0 6px 24px rgba(0,0,0,.4);
}
.toast.err { border-color: rgba(240,106,106,.5); color: #ffc4c4; }
.toast.ok { border-color: var(--green-dim); }


File created successfully at: /Users/USER/Documents/forfun/p-app-arm3/public/styles.css (file state is current in your context — no need to Read it back)

:root {
  --bg: #0e1116;
  --bg-2: #151a22;
  --card: #1a212b;
  --card-2: #212a36;
  --line: #2a3340;
  --line-2: #364150;
  --text: #e6edf3;
  --muted: #8b97a7;
  --muted-2: #6b7686;
  --amber: #f5a623;
  --amber-dim: #b9791a;
  --green: #34d399;
  --green-dim: #157f5a;
  --red: #f06a6a;
  --blue: #5fb0ff;
  --radius: 2px;
  --mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
  --sans: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}

* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
  background: var(--bg);
  color: var(--text);
  font-family: var(--sans);
  font-size: 14px;
  line-height: 1.45;
  -webkit-font-smoothing: antialiased;
}
.mono { font-family: var(--mono); }
.muted { color: var(--muted); }

/* ── Top bar ───────────────────────────────────────────── */
.topbar {
  display: flex; align-items: center; justify-content: space-between;
  padding: 12px 20px;
  background: linear-gradient(180deg, #161c25, #11161d);
  border-bottom: 1px solid var(--line);
  position: sticky; top: 0; z-index: 10;
}
.brand { display: flex; align-items: center; gap: 12px; }
.logo { font-size: 26px; filter: saturate(0.85); }
.brand-name { font-weight: 700; letter-spacing: 2px; font-size: 16px; }
.brand-sub { color: var(--muted); font-size: 11px; letter-spacing: 0.5px; }
.topbar-right { display: flex; align-items: center; gap: 10px; }

.conn { display: flex; align-items: center; gap: 7px; font-size: 12px; color: var(--muted); padding: 5px 10px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-2); }
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted-2); box-shadow: 0 0 0 0 rgba(52,211,153,0); }
.dot.live { background: var(--green); animation: pulse 2s infinite; }
.dot.off { background: var(--red); }
@keyframes pulse { 0% { box-shadow: 0 0 0 0 rgba(52,211,153,.5);} 70% { box-shadow: 0 0 0 6px rgba(52,211,153,0);} 100% { box-shadow: 0 0 0 0 rgba(52,211,153,0);} }

.sandbox-chip { font-size: 12px; padding: 5px 10px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-2); }
.sandbox-chip .mono { color: var(--amber); }

/* ── Buttons ───────────────────────────────────────────── */
.btn {
  font-family: inherit; font-size: 13px; font-weight: 600;
  padding: 8px 14px; border-radius: var(--radius); border: 1px solid var(--line-2);
  background: var(--card-2); color: var(--text); cursor: pointer; transition: background .12s, border-color .12s, opacity .12s;
}
.btn:hover { background: #28323f; }
.btn:disabled { opacity: .45; cursor: not-allowed; }
.btn.primary { background: var(--amber); border-color: var(--amber); color: #1a1206; }
.btn.primary:hover:not(:disabled) { background: #ffb733; }
.btn.ghost { background: transparent; }
.btn.confirm { background: var(--green-dim); border-color: #1d9e70; color: #eafff6; }
.btn.confirm:hover:not(:disabled) { background: #1d9e70; }
.btn.sm { padding: 6px 11px; font-size: 12px; }

/* ── Layout ────────────────────────────────────────────── */
.layout {
  display: grid; grid-template-columns: 1fr 360px; gap: 16px;
  max-width: 1240px; margin: 18px auto; padding: 0 20px;
  align-items: start;
}
.col-main { display: flex; flex-direction: column; gap: 16px; min-width: 0; }
.col-side { position: sticky; top: 76px; }
@media (max-width: 940px) {
  .layout { grid-template-columns: 1fr; }
  .col-side { position: static; }
}

/* ── Cards ─────────────────────────────────────────────── */
.card { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); padding: 16px 18px; }
.card-head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; margin-bottom: 12px; }
.card-title { font-size: 13px; font-weight: 700; letter-spacing: 1px; text-transform: uppercase; color: var(--text); margin: 0; }
.card-note { font-size: 11px; color: var(--muted); }
.card-help { color: var(--muted); margin: 2px 0 14px; font-size: 13px; }

/* ── Search ────────────────────────────────────────────── */
.search-row { display: flex; gap: 10px; }
.vin-input { flex: 1; padding: 11px 13px; background: var(--bg); border: 1px solid var(--line-2); border-radius: var(--radius); color: var(--text); font-size: 15px; letter-spacing: 1px; }
.vin-input:focus { outline: none; border-color: var(--amber); }
.hints { margin-top: 12px; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; font-size: 12px; }
.chip-btn { background: var(--bg-2); border: 1px dashed var(--line-2); color: var(--blue); padding: 4px 9px; border-radius: var(--radius); cursor: pointer; font-size: 11.5px; }
.chip-btn:hover { border-color: var(--blue); }
.search-msg { margin-top: 12px; padding: 10px 12px; border-radius: var(--radius); font-size: 13px; }
.search-msg.err { background: rgba(240,106,106,.1); border: 1px solid rgba(240,106,106,.4); color: #ffc4c4; }

/* ── Job header ────────────────────────────────────────── */
.job-card { display: flex; flex-direction: column; gap: 12px; }
.job-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.job-vehicle { font-size: 19px; font-weight: 700; }
.job-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px 18px; }
.kv { display: flex; flex-direction: column; gap: 2px; }
.kv .k { font-size: 10.5px; letter-spacing: .5px; text-transform: uppercase; color: var(--muted-2); }
.kv .v { font-size: 13.5px; }
.vin-ok { color: var(--green); font-size: 11px; }
.vin-bad { color: var(--red); font-size: 11px; }

/* ── Parts list ────────────────────────────────────────── */
.parts-list { display: flex; flex-direction: column; gap: 10px; }
.part {
  border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-2);
  padding: 12px 14px;
}
.part-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 4px; }
.part-name { font-weight: 600; font-size: 14px; }
.part-meta { font-size: 11px; color: var(--muted); margin-bottom: 8px; }
.offers { display: flex; flex-direction: column; gap: 6px; }
.offer {
  display: flex; align-items: center; gap: 10px; padding: 8px 10px;
  border: 1px solid var(--line); border-radius: var(--radius); background: var(--card);
  cursor: pointer; transition: border-color .12s, background .12s;
}
.offer:hover { border-color: var(--line-2); }
.offer.selected { border-color: var(--amber); background: rgba(245,166,35,.08); }
.offer.disabled { opacity: .62; cursor: not-allowed; }
.offer input { accent-color: var(--amber); width: 15px; height: 15px; }
.offer-main { flex: 1; min-width: 0; }
.offer-name { font-size: 13px; }
.offer-supplier { font-size: 11px; color: var(--muted); display: flex; align-items: center; gap: 6px; }
.offer-price { font-family: var(--mono); font-size: 14px; font-weight: 600; white-space: nowrap; }
.offer-price .cur { font-size: 10px; color: var(--muted); font-weight: 400; }
.tag { font-size: 9.5px; letter-spacing: .4px; text-transform: uppercase; padding: 2px 6px; border-radius: var(--radius); border: 1px solid; }
.tag.oem { color: var(--amber); border-color: var(--amber-dim); }
.tag.aftm { color: var(--blue); border-color: #2f6da3; }
.tag.compare { color: var(--muted-2); border-color: var(--line-2); }

.status-chip { font-size: 10px; letter-spacing: .4px; text-transform: uppercase; padding: 3px 8px; border-radius: var(--radius); border: 1px solid; white-space: nowrap; }
.status-chip.estimated { color: var(--muted); border-color: var(--line-2); background: var(--bg); }
.status-chip.ordered { color: var(--blue); border-color: #2f6da3; background: rgba(95,176,255,.08); }

.part.done .offers { display: none; }

/* ── Order bar ─────────────────────────────────────────── */
.order-bar {
  margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--line);
  display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap;
}
.order-summary { font-size: 13px; color: var(--muted); }
.order-summary b { color: var(--text); font-family: var(--mono); }

.footnote { margin: 14px 0 0; font-size: 11px; color: var(--muted-2); line-height: 1.5; border-top: 1px dashed var(--line); padding-top: 10px; }
.footnote .mono { color: var(--muted); }

/* ── Orders ────────────────────────────────────────────── */
.orders-list { display: flex; flex-direction: column; gap: 12px; }
.order {
  border: 1px solid var(--line); border-radius: var(--radius); background: var(--bg-2); padding: 13px 15px;
}
.order.flash { animation: flash 1.1s ease; }
@keyframes flash { 0% { background: rgba(52,211,153,.18); } 100% { background: var(--bg-2); } }
.order-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.order-ref { font-family: var(--mono); font-weight: 600; font-size: 14px; }
.order-sub { font-size: 11px; color: var(--muted); margin-top: 2px; }
.order-lines { margin-top: 10px; display: flex; flex-direction: column; gap: 4px; font-size: 12.5px; }
.order-line { display: flex; justify-content: space-between; gap: 10px; color: var(--muted); }
.order-line .desc { color: var(--text); }
.order-line .amt { font-family: var(--mono); }
.order-foot { margin-top: 10px; padding-top: 10px; border-top: 1px dashed var(--line); display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.order-total { font-family: var(--mono); font-size: 14px; }
.order-total .cur { font-size: 10px; color: var(--muted); }

.badge-status { font-size: 11px; font-weight: 700; letter-spacing: .5px; text-transform: uppercase; padding: 4px 10px; border-radius: var(--radius); border: 1px solid; }
.badge-status.order_requested { color: var(--amber); border-color: var(--amber-dim); background: rgba(245,166,35,.08); }
.badge-status.order_confirmed { color: var(--green); border-color: var(--green-dim); background: rgba(52,211,153,.1); }
.badge-status.cancelled { color: var(--red); border-color: #7f2a2a; }
.badge-status.confirming { color: var(--blue); border-color: #2f6da3; }
.badge-status .spin { display: inline-block; animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }

.dead-end { font-size: 11px; color: var(--red); margin-top: 8px; }

/* ── Invoice ───────────────────────────────────────────── */
.invoice-list { display: flex; flex-direction: column; gap: 12px; }
.invoice { border: 1px solid var(--green-dim); border-radius: var(--radius); background: rgba(52,211,153,.05); padding: 13px 15px; }
.invoice-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 10px; }
.invoice-title { font-weight: 700; }
.invoice-table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
.invoice-table th { text-align: left; color: var(--muted-2); font-weight: 500; font-size: 10.5px; text-transform: uppercase; letter-spacing: .4px; padding: 4px 6px; border-bottom: 1px solid var(--line); }
.invoice-table td { padding: 6px; border-bottom: 1px solid var(--line); }
.invoice-table td.num { font-family: var(--mono); text-align: right; }
.invoice-table tfoot td { font-family: var(--mono); font-weight: 700; border-bottom: none; padding-top: 8px; }
.recon { font-size: 10px; text-transform: uppercase; letter-spacing: .4px; padding: 2px 7px; border-radius: var(--radius); border: 1px solid var(--green-dim); color: var(--green); }

/* ── Live feed ─────────────────────────────────────────── */
.feed-card { display: flex; flex-direction: column; max-height: calc(100vh - 110px); }
.feed-count { font-family: var(--mono); font-size: 12px; background: var(--bg-2); border: 1px solid var(--line); padding: 1px 8px; border-radius: var(--radius); color: var(--amber); }
.hook-readout { font-size: 10.5px; color: var(--muted); background: var(--bg); border: 1px solid var(--line); border-radius: var(--radius); padding: 8px 10px; margin-bottom: 10px; word-break: break-all; }
.hook-readout .lbl { color: var(--muted-2); text-transform: uppercase; letter-spacing: .4px; font-size: 9.5px; }
.hook-readout .url { color: var(--blue); }
.feed { overflow-y: auto; display: flex; flex-direction: column; gap: 8px; flex: 1; }
.feed-empty { color: var(--muted-2); font-size: 12.5px; padding: 16px 4px; text-align: center; }
.event {
  border: 1px solid var(--line); border-left-width: 3px; border-radius: var(--radius);
  background: var(--bg-2); padding: 9px 11px; font-size: 12px; animation: slidein .25s ease;
}
@keyframes slidein { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
.event.verified { border-left-color: var(--green); }
.event.rejected { border-left-color: var(--red); }
.event-top { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 4px; }
.event-type { font-family: var(--mono); font-size: 11.5px; color: var(--text); }
.event-time { font-size: 10px; color: var(--muted-2); white-space: nowrap; }
.event-body { font-size: 11px; color: var(--muted); display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.event-mid { font-family: var(--mono); font-size: 10px; color: var(--muted-2); }
.badge { font-size: 9.5px; letter-spacing: .3px; padding: 2px 6px; border-radius: var(--radius); border: 1px solid; white-space: nowrap; }
.badge.ok { color: var(--green); border-color: var(--green-dim); background: rgba(52,211,153,.08); }
.badge.bad { color: var(--red); border-color: #7f2a2a; background: rgba(240,106,106,.08); }
.badge.dup { color: var(--amber); border-color: var(--amber-dim); }
.badge.kind { color: var(--blue); border-color: #2f6da3; }
.feed-legend { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--line); display: flex; flex-direction: column; gap: 5px; font-size: 10.5px; color: var(--muted); }

/* ── Toast ─────────────────────────────────────────────── */
.toast {
  position: fixed; bottom: 22px; left: 50%; transform: translateX(-50%);
  background: var(--card-2); border: 1px solid var(--line-2); border-radius: var(--radius);
  padding: 12px 18px; font-size: 13px; max-width: 560px; z-index: 50;
  box-shadow: 0 6px 24px rgba(0,0,0,.4);
}
.toast.err { border-color: rgba(240,106,106,.5); color: #ffc4c4; }
.toast.ok { border-color: var(--green-dim); }


Now the client logic.


"use strict";

// ── Sandbox tenant: each browser gets its own isolated, freshly-seeded world ──
function newTenant() {
  return "desk-" + Math.random().toString(36).slice(2, 8) + Math.random().toString(36).slice(2, 6);
}
let TENANT = localStorage.getItem("parts_desk_tenant");
if (!TENANT) { TENANT = newTenant(); localStorage.setItem("parts_desk_tenant", TENANT); }

const $ = (id) => document.getElementById(id);
const el = (tag, cls, html) => { const n = document.createElement(tag); if (cls) n.className = cls; if (html != null) n.innerHTML = html; return n; };
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
const money = (s) => { const n = Number(s); return Number.isFinite(n) ? n.toLocaleString("en-NZ", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : esc(s); };
const shortId = (s) => (s ? String(s).slice(0, 8) : "—");
const fmtTime = (iso) => { try { return new Date(iso).toLocaleTimeString("en-NZ", { hour12: false }); } catch { return ""; } };

// ── App state ─────────────────────────────────────────────
const state = {
  external: null,
  view: null,                 // composite job view from /api/job
  selections: {},             // job_part_id -> offer
  confirmable: { id: null, name: null },
  seenEvents: new Set(),      // message_ids already shown in the feed
  confirming: new Set(),      // procurement_ids mid-confirm (optimistic badge)
  reloadTimer: null,
};

// ── API helpers ───────────────────────────────────────────
async function api(action, body) {
  const res = await fetch(`/api/${action}`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ tenant: TENANT, ...body }),
  });
  return res.json();
}

function toast(msg, kind = "", ms = 4200) {
  const t = $("toast");
  t.className = "toast " + kind;
  t.textContent = msg;
  t.hidden = false;
  clearTimeout(toast._t);
  toast._t = setTimeout(() => { t.hidden = true; }, ms);
}

// ── Boot ──────────────────────────────────────────────────
$("tenantName").textContent = TENANT;
$("resetBtn").addEventListener("click", () => {
  TENANT = newTenant();
  localStorage.setItem("parts_desk_tenant", TENANT);
  location.reload();
});
$("searchForm").addEventListener("submit", (e) => { e.preventDefault(); openJob(); });
document.querySelectorAll(".chip-btn").forEach((b) =>
  b.addEventListener("click", () => { $("vinInput").value = b.dataset.fill; $("vinInput").focus(); }),
);
$("placeBtn").addEventListener("click", placeOrder);

connectWS();
loadFeed();

// ── Open a job from a VIN / reference ──────────────────────
async function openJob() {
  const q = $("vinInput").value.trim();
  if (!q) return;
  $("openBtn").disabled = true;
  $("openBtn").textContent = "Opening…";
  $("searchMsg").hidden = true;
  try {
    const r = await api("open", { query: q });
    if (!r.found) {
      const m = $("searchMsg");
      m.className = "search-msg err";
      m.textContent = r.message || "No job found.";
      m.hidden = false;
      return;
    }
    state.external = r.external;
    renderHookReadout(r);
    await reloadJob();
    $("searchCard").hidden = true;
    toast(`Opened job ${r.external}` + (r.vinMatches ? " — VIN verified ✓" : ""), "ok");
  } catch (e) {
    toast("Failed to open job: " + e, "err");
  } finally {
    $("openBtn").disabled = false;
    $("openBtn").textContent = "Open job";
  }
}

// ── Load / refresh the composite job view ──────────────────
async function reloadJob() {
  if (!state.external) return;
  const v = await api("job", { external: state.external });
  state.view = v;
  if (v.confirmable_supplier) state.confirmable = v.confirmable_supplier;
  renderJob();
}

function scheduleReload(delay = 250) {
  clearTimeout(state.reloadTimer);
  state.reloadTimer = setTimeout(reloadJob, delay);
}

// ── Render: job header ─────────────────────────────────────
function renderJob() {
  const v = state.view;
  if (!v || !v.job) return;
  renderJobHeader(v);
  renderParts(v);
  renderOrders(v);
  renderInvoices(v);
}

function renderJobHeader(v) {
  const job = v.job;
  const veh = job.vehicle || {};
  // The richer vehicle description ("2019 Toyota Corolla …") rides on procurements.
  const variant = v.procurements?.items?.[0]?.vehicle?.variant?.description;
  const card = $("jobCard");
  card.hidden = false;
  card.innerHTML = "";
  const top = el("div", "job-top");
  top.appendChild(el("div", "job-vehicle", esc(variant || "Repair job") +
    `<span class="${veh.chassis_number ? "vin-ok" : "vin-bad"}" style="margin-left:10px">${veh.chassis_number ? "● VIN verified" : ""}</span>`));
  top.appendChild(el("button", "btn ghost sm", "Change job"));
  top.querySelector("button").addEventListener("click", () => {
    $("searchCard").hidden = false; $("vinInput").value = ""; $("vinInput").focus();
  });
  card.appendChild(top);

  const grid = el("div", "job-grid");
  const kv = (k, val) => { const d = el("div", "kv"); d.appendChild(el("div", "k", esc(k))); d.appendChild(el("div", "v mono", esc(val ?? "—"))); return d; };
  grid.appendChild(kv("VIN", veh.chassis_number));
  grid.appendChild(kv("Plate", veh.plate_number ? `${veh.plate_number} (${veh.plate_state || ""})` : "—"));
  grid.appendChild(kv("RO / external", job.external_id));
  grid.appendChild(kv("Job no.", job.job_number));
  grid.appendChild(kv("Claim", job.claim_number));
  grid.appendChild(kv("Insurer", job.work_provider_id ? shortId(job.work_provider_id) : "cash / self-pay"));
  card.appendChild(grid);
}

// ── Render: parts + offers ─────────────────────────────────
function renderParts(v) {
  const card = $("partsCard");
  card.hidden = false;
  const parts = v.parts?.items || [];
  const offers = v.basket?.offers || [];
  const suppliers = {};
  (v.basket?.suppliers || []).forEach((s) => { suppliers[s.business_id] = s.name; });
  const confId = state.confirmable.id;

  const list = $("partsList");
  list.innerHTML = "";

  // Drop selections for parts that are no longer estimated.
  const estimatedIds = new Set(parts.filter((p) => p.status === "estimated").map((p) => p.id));
  Object.keys(state.selections).forEach((pid) => { if (!estimatedIds.has(pid)) delete state.selections[pid]; });

  $("partsNote").textContent = `${parts.length} parts · ${parts.filter((p) => p.status === "ordered").length} ordered`;

  parts.forEach((part) => {
    const partOffers = offers.filter((o) => (o.job_part_ids || []).includes(part.id));
    const ordered = part.status === "ordered";
    const row = el("div", "part" + (ordered ? " done" : ""));

    const head = el("div", "part-head");
    head.appendChild(el("div", "part-name", esc(part.description)));
    head.appendChild(el("span", "status-chip " + part.status, esc(part.status)));
    row.appendChild(head);

    const meta = [];
    if (part.external_id) meta.push(part.external_id);
    if (part.note) meta.push("note: " + part.note);
    row.appendChild(el("div", "part-meta", esc(meta.join("  ·  ")) || " "));

    if (!ordered) {
      const offersBox = el("div", "offers");
      if (partOffers.length === 0) {
        offersBox.appendChild(el("div", "part-meta", "No supplier offers priced for this part."));
      }
      partOffers.forEach((o) => {
        const isOem = (o.name || "").toLowerCase().includes("genuine");
        const confirmable = o.supplier_business_id === confId;
        const offer = el("label", "offer" + (confirmable ? "" : " disabled"));
        const radio = el("input");
        radio.type = "radio";
        radio.name = "part_" + part.id;
        radio.disabled = !confirmable;
        radio.checked = state.selections[part.id]?.sellable_id === o.sellable_id;
        radio.addEventListener("change", () => { if (confirmable) selectOffer(part, o); });
        offer.appendChild(radio);

        const main = el("div", "offer-main");
        main.appendChild(el("div", "offer-name", esc(o.name)));
        const sup = el("div", "offer-supplier");
        sup.innerHTML =
          `<span>${esc(suppliers[o.supplier_business_id] || "supplier")}</span>` +
          (isOem ? `<span class="tag oem">OEM</span>` : `<span class="tag aftm">aftermarket</span>`) +
          (confirmable ? "" : `<span class="tag compare">compare only</span>`) +
          (o.quantity > 1 ? `<span class="muted">×${o.quantity}</span>` : "");
        main.appendChild(sup);
        offer.appendChild(main);

        const price = el("div", "offer-price");
        price.innerHTML = `${money(o.trade_price)} <span class="cur">NZD*</span>`;
        offer.appendChild(price);

        if (radio.checked) offer.classList.add("selected");
        offersBox.appendChild(offer);
      });

      const hasConfirmable = partOffers.some((o) => o.supplier_business_id === confId);
      if (partOffers.length > 0 && !hasConfirmable) {
        offersBox.appendChild(el("div", "dead-end",
          `No ${esc(state.confirmable.name)} offer for this part in the sandbox — these can be compared but not ordered (couldn’t be confirmed).`));
      }
      row.appendChild(offersBox);
    }

    list.appendChild(row);
  });

  // Pre-select the confirmable (recommended) offer for any unselected estimated part.
  parts.filter((p) => p.status === "estimated").forEach((part) => {
    if (state.selections[part.id]) return;
    const rec = offers.find((o) => (o.job_part_ids || []).includes(part.id) && o.supplier_business_id === confId);
    if (rec) state.selections[part.id] = rec;
  });
  // Reflect any pre-selection in the DOM + summary without a full re-render loop.
  syncSelectionUI();
  renderOrderBar();
}

function selectOffer(part, offer) {
  state.selections[part.id] = offer;
  syncSelectionUI();
  renderOrderBar();
}

function syncSelectionUI() {
  document.querySelectorAll("#partsList .offer").forEach((node) => {
    const r = node.querySelector("input");
    if (!r) return;
    node.classList.toggle("selected", r.checked);
  });
  // Ensure radios match selection state (covers pre-selection).
  Object.entries(state.selections).forEach(([pid, o]) => {
    const r = document.querySelector(`input[name="part_${pid}"][type="radio"]`);
    if (r && !r.checked) {
      const labels = document.querySelectorAll(`input[name="part_${pid}"]`);
      labels.forEach((x) => {
        const checked = x.closest(".offer") && x === x; // noop guard
      });
    }
  });
}

function renderOrderBar() {
  const sels = Object.values(state.selections);
  const bar = $("orderBar");
  if (sels.length === 0) { bar.hidden = true; return; }
  bar.hidden = false;
  let total = 0;
  sels.forEach((o) => { total += Number(o.trade_price) * (o.quantity || 1); });
  $("orderSummary").innerHTML =
    `<b>${sels.length}</b> part${sels.length > 1 ? "s" : ""} selected from <b>${esc(state.confirmable.name)}</b> · total <b>${money(total)}</b> NZD`;
  $("placeBtn").textContent = `Place order — ${money(total)} NZD`;
}

// ── Place order ────────────────────────────────────────────
async function placeOrder() {
  const sels = Object.entries(state.selections);
  if (sels.length === 0) return;
  const offer_selections = sels.map(([job_part_id, o]) => ({
    sellable_id: o.sellable_id,
    supplier_business_id: o.supplier_business_id,
    job_part_ids: [job_part_id],
    quantity: o.quantity || 1,
  }));
  $("placeBtn").disabled = true;
  const old = $("placeBtn").textContent;
  $("placeBtn").textContent = "Placing…";
  try {
    const r = await api("place", { external: state.external, offer_selections });
    if (!r.ok) {
      toast((r.message || r.error || (r.data && JSON.stringify(r.data)) || "Order rejected"), "err", 8000);
      return;
    }
    state.selections = {};
    toast(`Order placed — ${r.data?.procurement?.reference || ""} (order_requested). Confirm it below.`, "ok");
    await reloadJob();
  } catch (e) {
    toast("Place failed: " + e, "err");
  } finally {
    $("placeBtn").disabled = false;
    $("placeBtn").textContent = old;
  }
}

// ── Render: orders ─────────────────────────────────────────
function renderOrders(v) {
  const items = v.procurements?.items || [];
  const card = $("ordersCard");
  if (items.length === 0) { card.hidden = true; return; }
  card.hidden = false;
  const list = $("ordersList");
  list.innerHTML = "";

  items.forEach((p) => {
    const confirmable = p.supplier_business?.id === state.confirmable.id;
    const isConfirming = state.confirming.has(p.id);
    const statusKey = isConfirming && p.status === "order_requested" ? "confirming" : p.status;
    const order = el("div", "order");
    order.dataset.pid = p.id;

    const rowTop = el("div", "order-row");
    const left = el("div");
    left.appendChild(el("div", "order-ref", esc(p.reference || shortId(p.id))));
    left.appendChild(el("div", "order-sub", esc(p.supplier_business?.name || "") + " · " + shortId(p.id)));
    rowTop.appendChild(left);

    const badge = el("span", "badge-status " + statusKey);
    badge.innerHTML = statusKey === "confirming"
      ? `<span class="spin">↻</span> confirming…`
      : esc(p.status);
    rowTop.appendChild(badge);
    order.appendChild(rowTop);

    // Lines from the supplier's offered items.
    const lines = el("div", "order-lines");
    let total = 0;
    (p.offered_items || []).forEach((oi) => {
      const so = oi.supplied_offer || {};
      const lt = Number(so.trade_price) * (so.quantity || 1);
      total += lt;
      const line = el("div", "order-line");
      line.innerHTML = `<span class="desc">${esc(so.description)}${(so.quantity || 1) > 1 ? ` ×${so.quantity}` : ""}</span><span class="amt">${money(so.trade_price)}</span>`;
      lines.appendChild(line);
    });
    order.appendChild(lines);

    const foot = el("div", "order-foot");
    foot.appendChild(el("div", "order-total", `Total <b>${money(total)}</b> <span class="cur">${esc(p.currency_code || "NZD")}</span>`));
    if (p.status === "order_requested") {
      if (confirmable) {
        const btn = el("button", "btn confirm sm", isConfirming ? "Confirming…" : "Confirm order");
        btn.disabled = isConfirming;
        btn.addEventListener("click", () => confirmOrder(p.id));
        foot.appendChild(btn);
      } else {
        foot.appendChild(el("div", "dead-end",
          `Cannot confirm — ${esc(p.supplier_business?.name || "this supplier")} has no confirming credential in the sandbox (dead-end order).`));
      }
    }
    order.appendChild(foot);
    list.appendChild(order);
  });
}

// ── Confirm order (status flips live via the inbound webhook) ──
async function confirmOrder(pid) {
  state.confirming.add(pid);
  renderJob();
  try {
    const r = await api("confirm", { procurement_id: pid });
    if (!r.ok) {
      state.confirming.delete(pid);
      const reason = (r.data && r.data.type) || r.error || "confirm failed";
      toast(`Confirm rejected: ${reason}` + (reason === "not_found" ? " — order may be with a non-confirmable supplier." : ""), "err", 7000);
      renderJob();
      return;
    }
    // Don't optimistically flip — let the verified order_confirmed webhook drive it.
    // Safety net in case a delivery is delayed:
    setTimeout(() => { state.confirming.delete(pid); reloadJob(); }, 4000);
  } catch (e) {
    state.confirming.delete(pid);
    toast("Confirm failed: " + e, "err");
    renderJob();
  }
}

// ── Render: invoices ───────────────────────────────────────
function renderInvoices(v) {
  const items = v.invoices?.items || [];
  const card = $("invoiceCard");
  if (items.length === 0) { card.hidden = true; return; }
  card.hidden = false;
  const list = $("invoiceList");
  list.innerHTML = "";

  items.forEach((inv) => {
    const box = el("div", "invoice");
    const head = el("div", "invoice-head");
    head.appendChild(el("div", "invoice-title",
      `${esc(inv.document_type)} · ${esc(inv.reference || shortId(inv.id))}`));
    head.appendChild(el("span", "recon", esc(inv.status)));
    box.appendChild(head);

    const t = el("table", "invoice-table");
    t.innerHTML =
      `<thead><tr><th>Description</th><th>Qty</th><th>Unit</th><th>Line</th><th>Reconciliation</th></tr></thead>`;
    const tb = el("tbody");
    (inv.line_items || []).forEach((li) => {
      const tr = el("tr");
      tr.innerHTML =
        `<td>${esc(li.description)}</td>` +
        `<td class="num">${esc(li.quantity)}</td>` +
        `<td class="num">${money(li.unit_price)}</td>` +
        `<td class="num">${money(li.line_total)}</td>` +
        `<td><span class="recon">${esc(li.reconciliation)}</span></td>`;
      tb.appendChild(tr);
    });
    t.appendChild(tb);
    const tf = el("tfoot");
    tf.innerHTML = `<tr><td colspan="3">Total</td><td class="num">${money(inv.total)}</td><td>${esc(inv.currency_code)}</td></tr>`;
    t.appendChild(tf);
    box.appendChild(t);
    list.appendChild(box);
  });
}

// ── Live webhook feed ──────────────────────────────────────
function renderHookReadout(openResp) {
  const r = $("hookReadout");
  r.hidden = false;
  const subOk = openResp.subscriptions?.repairer?.ok && openResp.subscriptions?.supplier?.ok;
  r.innerHTML =
    `<div class="lbl">webhook endpoint</div><div class="url">${esc(openResp.webhook_url)}</div>` +
    `<div style="margin-top:6px">subscriptions: ${subOk ? '<span class="badge ok">repairer ✓ · supplier ✓</span>' : '<span class="badge bad">check failed</span>'}</div>`;
}

function addEvent(ev, animate) {
  if (ev.message_id && state.seenEvents.has(ev.message_id)) {
    // Already shown — mark the existing card as also-deduped.
    return;
  }
  if (ev.message_id) state.seenEvents.add(ev.message_id);
  $("feedEmpty").hidden = true;

  const verified = ev.verified;
  const node = el("div", "event " + (verified ? "verified" : "rejected"));
  const top = el("div", "event-top");
  top.appendChild(el("div", "event-type", esc(ev.event_type || "event")));
  top.appendChild(el("div", "event-time", fmtTime(ev.received_at || ev.webhook_timestamp)));
  node.appendChild(top);

  const body = el("div", "event-body");
  const statusBit = ev.status ? `<span class="badge kind">${esc(ev.status)}</span>` : "";
  const vBadge = verified
    ? `<span class="badge ok">✓ verified</span>`
    : `<span class="badge bad">✗ ${esc(ev.reason || "unverified")}</span>`;
  const dupBadge = ev.deduped ? `<span class="badge dup">dup</span>` : "";
  body.innerHTML = `${vBadge}${statusBit}${dupBadge}<span class="event-mid">${esc(shortId(ev.message_id))}</span>`;
  node.appendChild(body);

  const feed = $("feed");
  feed.insertBefore(node, feed.firstChild === $("feedEmpty") ? null : feed.firstChild);
  $("feedCount").textContent = state.seenEvents.size;

  // A verified procurement/job event means the world changed → re-fetch by id.
  if (verified && /procurements|jobs/.test(ev.event_type || "")) {
    const pid = ev.payload?.procurement_id;
    if (pid) state.confirming.delete(pid);
    scheduleReload(200);
    if (pid) flashOrder(pid);
  }
}

function flashOrder(pid) {
  setTimeout(() => {
    const node = document.querySelector(`.order[data-pid="${pid}"]`);
    if (node) { node.classList.remove("flash"); void node.offsetWidth; node.classList.add("flash"); }
  }, 350);
}

async function loadFeed() {
  try {
    const r = await fetch(`/api/state?tenant=${encodeURIComponent(TENANT)}`);
    const data = await r.json();
    (data.events || []).slice().reverse().forEach((ev) => addEvent(ev, false));
  } catch { /* ignore */ }
}

// ── WebSocket live channel ─────────────────────────────────
function connectWS() {
  const proto = location.protocol === "https:" ? "wss" : "ws";
  let ws;
  try { ws = new WebSocket(`${proto}://${location.host}/ws?tenant=${encodeURIComponent(TENANT)}`); }
  catch { setConn(false); setTimeout(connectWS, 2500); return; }

  let ping;
  ws.addEventListener("open", () => {
    setConn(true);
    ping = setInterval(() => { try { ws.send("ping"); } catch {} }, 25000);
    loadFeed(); // catch up on anything missed
  });
  ws.addEventListener("message", (e) => {
    if (e.data === "pong") return;
    let msg; try { msg = JSON.parse(e.data); } catch { return; }
    if (msg.kind === "webhook") addEvent(msg, true);
  });
  ws.addEventListener("close", () => { setConn(false); clearInterval(ping); setTimeout(connectWS, 2500); });
  ws.addEventListener("error", () => { try { ws.close(); } catch {} });
}

function setConn(live) {
  $("connDot").className = "dot " + (live ? "live" : "off");
  $("connText").textContent = live ? "live" : "offline";
}


File created successfully at: /Users/USER/Documents/forfun/p-app-arm3/public/app.js (file state is current in your context — no need to Read it back)

"use strict";

// ── Sandbox tenant: each browser gets its own isolated, freshly-seeded world ──
function newTenant() {
  return "desk-" + Math.random().toString(36).slice(2, 8) + Math.random().toString(36).slice(2, 6);
}
let TENANT = localStorage.getItem("parts_desk_tenant");
if (!TENANT) { TENANT = newTenant(); localStorage.setItem("parts_desk_tenant", TENANT); }

const $ = (id) => document.getElementById(id);
const el = (tag, cls, html) => { const n = document.createElement(tag); if (cls) n.className = cls; if (html != null) n.innerHTML = html; return n; };
const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
const money = (s) => { const n = Number(s); return Number.isFinite(n) ? n.toLocaleString("en-NZ", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : esc(s); };
const shortId = (s) => (s ? String(s).slice(0, 8) : "—");
const fmtTime = (iso) => { try { return new Date(iso).toLocaleTimeString("en-NZ", { hour12: false }); } catch { return ""; } };

// ── App state ─────────────────────────────────────────────
const state = {
  external: null,
  view: null,                 // composite job view from /api/job
  selections: {},             // job_part_id -> offer
  confirmable: { id: null, name: null },
  seenEvents: new Set(),      // message_ids already shown in the feed
  confirming: new Set(),      // procurement_ids mid-confirm (optimistic badge)
  reloadTimer: null,
};

// ── API helpers ───────────────────────────────────────────
async function api(action, body) {
  const res = await fetch(`/api/${action}`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ tenant: TENANT, ...body }),
  });
  return res.json();
}

function toast(msg, kind = "", ms = 4200) {
  const t = $("toast");
  t.className = "toast " + kind;
  t.textContent = msg;
  t.hidden = false;
  clearTimeout(toast._t);
  toast._t = setTimeout(() => { t.hidden = true; }, ms);
}

// ── Boot ──────────────────────────────────────────────────
$("tenantName").textContent = TENANT;
$("resetBtn").addEventListener("click", () => {
  TENANT = newTenant();
  localStorage.setItem("parts_desk_tenant", TENANT);
  location.reload();
});
$("searchForm").addEventListener("submit", (e) => { e.preventDefault(); openJob(); });
document.querySelectorAll(".chip-btn").forEach((b) =>
  b.addEventListener("click", () => { $("vinInput").value = b.dataset.fill; $("vinInput").focus(); }),
);
$("placeBtn").addEventListener("click", placeOrder);

connectWS();
loadFeed();

// ── Open a job from a VIN / reference ──────────────────────
async function openJob() {
  const q = $("vinInput").value.trim();
  if (!q) return;
  $("openBtn").disabled = true;
  $("openBtn").textContent = "Opening…";
  $("searchMsg").hidden = true;
  try {
    const r = await api("open", { query: q });
    if (!r.found) {
      const m = $("searchMsg");
      m.className = "search-msg err";
      m.textContent = r.message || "No job found.";
      m.hidden = false;
      return;
    }
    state.external = r.external;
    renderHookReadout(r);
    await reloadJob();
    $("searchCard").hidden = true;
    toast(`Opened job ${r.external}` + (r.vinMatches ? " — VIN verified ✓" : ""), "ok");
  } catch (e) {
    toast("Failed to open job: " + e, "err");
  } finally {
    $("openBtn").disabled = false;
    $("openBtn").textContent = "Open job";
  }
}

// ── Load / refresh the composite job view ──────────────────
async function reloadJob() {
  if (!state.external) return;
  const v = await api("job", { external: state.external });
  state.view = v;
  if (v.confirmable_supplier) state.confirmable = v.confirmable_supplier;
  renderJob();
}

function scheduleReload(delay = 250) {
  clearTimeout(state.reloadTimer);
  state.reloadTimer = setTimeout(reloadJob, delay);
}

// ── Render: job header ─────────────────────────────────────
function renderJob() {
  const v = state.view;
  if (!v || !v.job) return;
  renderJobHeader(v);
  renderParts(v);
  renderOrders(v);
  renderInvoices(v);
}

function renderJobHeader(v) {
  const job = v.job;
  const veh = job.vehicle || {};
  // The richer vehicle description ("2019 Toyota Corolla …") rides on procurements.
  const variant = v.procurements?.items?.[0]?.vehicle?.variant?.description;
  const card = $("jobCard");
  card.hidden = false;
  card.innerHTML = "";
  const top = el("div", "job-top");
  top.appendChild(el("div", "job-vehicle", esc(variant || "Repair job") +
    `<span class="${veh.chassis_number ? "vin-ok" : "vin-bad"}" style="margin-left:10px">${veh.chassis_number ? "● VIN verified" : ""}</span>`));
  top.appendChild(el("button", "btn ghost sm", "Change job"));
  top.querySelector("button").addEventListener("click", () => {
    $("searchCard").hidden = false; $("vinInput").value = ""; $("vinInput").focus();
  });
  card.appendChild(top);

  const grid = el("div", "job-grid");
  const kv = (k, val) => { const d = el("div", "kv"); d.appendChild(el("div", "k", esc(k))); d.appendChild(el("div", "v mono", esc(val ?? "—"))); return d; };
  grid.appendChild(kv("VIN", veh.chassis_number));
  grid.appendChild(kv("Plate", veh.plate_number ? `${veh.plate_number} (${veh.plate_state || ""})` : "—"));
  grid.appendChild(kv("RO / external", job.external_id));
  grid.appendChild(kv("Job no.", job.job_number));
  grid.appendChild(kv("Claim", job.claim_number));
  grid.appendChild(kv("Insurer", job.work_provider_id ? shortId(job.work_provider_id) : "cash / self-pay"));
  card.appendChild(grid);
}

// ── Render: parts + offers ─────────────────────────────────
function renderParts(v) {
  const card = $("partsCard");
  card.hidden = false;
  const parts = v.parts?.items || [];
  const offers = v.basket?.offers || [];
  const suppliers = {};
  (v.basket?.suppliers || []).forEach((s) => { suppliers[s.business_id] = s.name; });
  const confId = state.confirmable.id;

  const list = $("partsList");
  list.innerHTML = "";

  // Drop selections for parts that are no longer estimated.
  const estimatedIds = new Set(parts.filter((p) => p.status === "estimated").map((p) => p.id));
  Object.keys(state.selections).forEach((pid) => { if (!estimatedIds.has(pid)) delete state.selections[pid]; });

  $("partsNote").textContent = `${parts.length} parts · ${parts.filter((p) => p.status === "ordered").length} ordered`;

  parts.forEach((part) => {
    const partOffers = offers.filter((o) => (o.job_part_ids || []).includes(part.id));
    const ordered = part.status === "ordered";
    const row = el("div", "part" + (ordered ? " done" : ""));

    const head = el("div", "part-head");
    head.appendChild(el("div", "part-name", esc(part.description)));
    head.appendChild(el("span", "status-chip " + part.status, esc(part.status)));
    row.appendChild(head);

    const meta = [];
    if (part.external_id) meta.push(part.external_id);
    if (part.note) meta.push("note: " + part.note);
    row.appendChild(el("div", "part-meta", esc(meta.join("  ·  ")) || " "));

    if (!ordered) {
      const offersBox = el("div", "offers");
      if (partOffers.length === 0) {
        offersBox.appendChild(el("div", "part-meta", "No supplier offers priced for this part."));
      }
      partOffers.forEach((o) => {
        const isOem = (o.name || "").toLowerCase().includes("genuine");
        const confirmable = o.supplier_business_id === confId;
        const offer = el("label", "offer" + (confirmable ? "" : " disabled"));
        const radio = el("input");
        radio.type = "radio";
        radio.name = "part_" + part.id;
        radio.disabled = !confirmable;
        radio.checked = state.selections[part.id]?.sellable_id === o.sellable_id;
        radio.addEventListener("change", () => { if (confirmable) selectOffer(part, o); });
        offer.appendChild(radio);

        const main = el("div", "offer-main");
        main.appendChild(el("div", "offer-name", esc(o.name)));
        const sup = el("div", "offer-supplier");
        sup.innerHTML =
          `<span>${esc(suppliers[o.supplier_business_id] || "supplier")}</span>` +
          (isOem ? `<span class="tag oem">OEM</span>` : `<span class="tag aftm">aftermarket</span>`) +
          (confirmable ? "" : `<span class="tag compare">compare only</span>`) +
          (o.quantity > 1 ? `<span class="muted">×${o.quantity}</span>` : "");
        main.appendChild(sup);
        offer.appendChild(main);

        const price = el("div", "offer-price");
        price.innerHTML = `${money(o.trade_price)} <span class="cur">NZD*</span>`;
        offer.appendChild(price);

        if (radio.checked) offer.classList.add("selected");
        offersBox.appendChild(offer);
      });

      const hasConfirmable = partOffers.some((o) => o.supplier_business_id === confId);
      if (partOffers.length > 0 && !hasConfirmable) {
        offersBox.appendChild(el("div", "dead-end",
          `No ${esc(state.confirmable.name)} offer for this part in the sandbox — these can be compared but not ordered (couldn’t be confirmed).`));
      }
      row.appendChild(offersBox);
    }

    list.appendChild(row);
  });

  // Pre-select the confirmable (recommended) offer for any unselected estimated part.
  parts.filter((p) => p.status === "estimated").forEach((part) => {
    if (state.selections[part.id]) return;
    const rec = offers.find((o) => (o.job_part_ids || []).includes(part.id) && o.supplier_business_id === confId);
    if (rec) state.selections[part.id] = rec;
  });
  // Reflect any pre-selection in the DOM + summary without a full re-render loop.
  syncSelectionUI();
  renderOrderBar();
}

function selectOffer(part, offer) {
  state.selections[part.id] = offer;
  syncSelectionUI();
  renderOrderBar();
}

function syncSelectionUI() {
  document.querySelectorAll("#partsList .offer").forEach((node) => {
    const r = node.querySelector("input");
    if (!r) return;
    node.classList.toggle("selected", r.checked);
  });
  // Ensure radios match selection state (covers pre-selection).
  Object.entries(state.selections).forEach(([pid, o]) => {
    const r = document.querySelector(`input[name="part_${pid}"][type="radio"]`);
    if (r && !r.checked) {
      const labels = document.querySelectorAll(`input[name="part_${pid}"]`);
      labels.forEach((x) => {
        const checked = x.closest(".offer") && x === x; // noop guard
      });
    }
  });
}

function renderOrderBar() {
  const sels = Object.values(state.selections);
  const bar = $("orderBar");
  if (sels.length === 0) { bar.hidden = true; return; }
  bar.hidden = false;
  let total = 0;
  sels.forEach((o) => { total += Number(o.trade_price) * (o.quantity || 1); });
  $("orderSummary").innerHTML =
    `<b>${sels.length}</b> part${sels.length > 1 ? "s" : ""} selected from <b>${esc(state.confirmable.name)}</b> · total <b>${money(total)}</b> NZD`;
  $("placeBtn").textContent = `Place order — ${money(total)} NZD`;
}

// ── Place order ────────────────────────────────────────────
async function placeOrder() {
  const sels = Object.entries(state.selections);
  if (sels.length === 0) return;
  const offer_selections = sels.map(([job_part_id, o]) => ({
    sellable_id: o.sellable_id,
    supplier_business_id: o.supplier_business_id,
    job_part_ids: [job_part_id],
    quantity: o.quantity || 1,
  }));
  $("placeBtn").disabled = true;
  const old = $("placeBtn").textContent;
  $("placeBtn").textContent = "Placing…";
  try {
    const r = await api("place", { external: state.external, offer_selections });
    if (!r.ok) {
      toast((r.message || r.error || (r.data && JSON.stringify(r.data)) || "Order rejected"), "err", 8000);
      return;
    }
    state.selections = {};
    toast(`Order placed — ${r.data?.procurement?.reference || ""} (order_requested). Confirm it below.`, "ok");
    await reloadJob();
  } catch (e) {
    toast("Place failed: " + e, "err");
  } finally {
    $("placeBtn").disabled = false;
    $("placeBtn").textContent = old;
  }
}

// ── Render: orders ─────────────────────────────────────────
function renderOrders(v) {
  const items = v.procurements?.items || [];
  const card = $("ordersCard");
  if (items.length === 0) { card.hidden = true; return; }
  card.hidden = false;
  const list = $("ordersList");
  list.innerHTML = "";

  items.forEach((p) => {
    const confirmable = p.supplier_business?.id === state.confirmable.id;
    const isConfirming = state.confirming.has(p.id);
    const statusKey = isConfirming && p.status === "order_requested" ? "confirming" : p.status;
    const order = el("div", "order");
    order.dataset.pid = p.id;

    const rowTop = el("div", "order-row");
    const left = el("div");
    left.appendChild(el("div", "order-ref", esc(p.reference || shortId(p.id))));
    left.appendChild(el("div", "order-sub", esc(p.supplier_business?.name || "") + " · " + shortId(p.id)));
    rowTop.appendChild(left);

    const badge = el("span", "badge-status " + statusKey);
    badge.innerHTML = statusKey === "confirming"
      ? `<span class="spin">↻</span> confirming…`
      : esc(p.status);
    rowTop.appendChild(badge);
    order.appendChild(rowTop);

    // Lines from the supplier's offered items.
    const lines = el("div", "order-lines");
    let total = 0;
    (p.offered_items || []).forEach((oi) => {
      const so = oi.supplied_offer || {};
      const lt = Number(so.trade_price) * (so.quantity || 1);
      total += lt;
      const line = el("div", "order-line");
      line.innerHTML = `<span class="desc">${esc(so.description)}${(so.quantity || 1) > 1 ? ` ×${so.quantity}` : ""}</span><span class="amt">${money(so.trade_price)}</span>`;
      lines.appendChild(line);
    });
    order.appendChild(lines);

    const foot = el("div", "order-foot");
    foot.appendChild(el("div", "order-total", `Total <b>${money(total)}</b> <span class="cur">${esc(p.currency_code || "NZD")}</span>`));
    if (p.status === "order_requested") {
      if (confirmable) {
        const btn = el("button", "btn confirm sm", isConfirming ? "Confirming…" : "Confirm order");
        btn.disabled = isConfirming;
        btn.addEventListener("click", () => confirmOrder(p.id));
        foot.appendChild(btn);
      } else {
        foot.appendChild(el("div", "dead-end",
          `Cannot confirm — ${esc(p.supplier_business?.name || "this supplier")} has no confirming credential in the sandbox (dead-end order).`));
      }
    }
    order.appendChild(foot);
    list.appendChild(order);
  });
}

// ── Confirm order (status flips live via the inbound webhook) ──
async function confirmOrder(pid) {
  state.confirming.add(pid);
  renderJob();
  try {
    const r = await api("confirm", { procurement_id: pid });
    if (!r.ok) {
      state.confirming.delete(pid);
      const reason = (r.data && r.data.type) || r.error || "confirm failed";
      toast(`Confirm rejected: ${reason}` + (reason === "not_found" ? " — order may be with a non-confirmable supplier." : ""), "err", 7000);
      renderJob();
      return;
    }
    // Don't optimistically flip — let the verified order_confirmed webhook drive it.
    // Safety net in case a delivery is delayed:
    setTimeout(() => { state.confirming.delete(pid); reloadJob(); }, 4000);
  } catch (e) {
    state.confirming.delete(pid);
    toast("Confirm failed: " + e, "err");
    renderJob();
  }
}

// ── Render: invoices ───────────────────────────────────────
function renderInvoices(v) {
  const items = v.invoices?.items || [];
  const card = $("invoiceCard");
  if (items.length === 0) { card.hidden = true; return; }
  card.hidden = false;
  const list = $("invoiceList");
  list.innerHTML = "";

  items.forEach((inv) => {
    const box = el("div", "invoice");
    const head = el("div", "invoice-head");
    head.appendChild(el("div", "invoice-title",
      `${esc(inv.document_type)} · ${esc(inv.reference || shortId(inv.id))}`));
    head.appendChild(el("span", "recon", esc(inv.status)));
    box.appendChild(head);

    const t = el("table", "invoice-table");
    t.innerHTML =
      `<thead><tr><th>Description</th><th>Qty</th><th>Unit</th><th>Line</th><th>Reconciliation</th></tr></thead>`;
    const tb = el("tbody");
    (inv.line_items || []).forEach((li) => {
      const tr = el("tr");
      tr.innerHTML =
        `<td>${esc(li.description)}</td>` +
        `<td class="num">${esc(li.quantity)}</td>` +
        `<td class="num">${money(li.unit_price)}</td>` +
        `<td class="num">${money(li.line_total)}</td>` +
        `<td><span class="recon">${esc(li.reconciliation)}</span></td>`;
      tb.appendChild(tr);
    });
    t.appendChild(tb);
    const tf = el("tfoot");
    tf.innerHTML = `<tr><td colspan="3">Total</td><td class="num">${money(inv.total)}</td><td>${esc(inv.currency_code)}</td></tr>`;
    t.appendChild(tf);
    box.appendChild(t);
    list.appendChild(box);
  });
}

// ── Live webhook feed ──────────────────────────────────────
function renderHookReadout(openResp) {
  const r = $("hookReadout");
  r.hidden = false;
  const subOk = openResp.subscriptions?.repairer?.ok && openResp.subscriptions?.supplier?.ok;
  r.innerHTML =
    `<div class="lbl">webhook endpoint</div><div class="url">${esc(openResp.webhook_url)}</div>` +
    `<div style="margin-top:6px">subscriptions: ${subOk ? '<span class="badge ok">repairer ✓ · supplier ✓</span>' : '<span class="badge bad">check failed</span>'}</div>`;
}

function addEvent(ev, animate) {
  if (ev.message_id && state.seenEvents.has(ev.message_id)) {
    // Already shown — mark the existing card as also-deduped.
    return;
  }
  if (ev.message_id) state.seenEvents.add(ev.message_id);
  $("feedEmpty").hidden = true;

  const verified = ev.verified;
  const node = el("div", "event " + (verified ? "verified" : "rejected"));
  const top = el("div", "event-top");
  top.appendChild(el("div", "event-type", esc(ev.event_type || "event")));
  top.appendChild(el("div", "event-time", fmtTime(ev.received_at || ev.webhook_timestamp)));
  node.appendChild(top);

  const body = el("div", "event-body");
  const statusBit = ev.status ? `<span class="badge kind">${esc(ev.status)}</span>` : "";
  const vBadge = verified
    ? `<span class="badge ok">✓ verified</span>`
    : `<span class="badge bad">✗ ${esc(ev.reason || "unverified")}</span>`;
  const dupBadge = ev.deduped ? `<span class="badge dup">dup</span>` : "";
  body.innerHTML = `${vBadge}${statusBit}${dupBadge}<span class="event-mid">${esc(shortId(ev.message_id))}</span>`;
  node.appendChild(body);

  const feed = $("feed");
  feed.insertBefore(node, feed.firstChild === $("feedEmpty") ? null : feed.firstChild);
  $("feedCount").textContent = state.seenEvents.size;

  // A verified procurement/job event means the world changed → re-fetch by id.
  if (verified && /procurements|jobs/.test(ev.event_type || "")) {
    const pid = ev.payload?.procurement_id;
    if (pid) state.confirming.delete(pid);
    scheduleReload(200);
    if (pid) flashOrder(pid);
  }
}

function flashOrder(pid) {
  setTimeout(() => {
    const node = document.querySelector(`.order[data-pid="${pid}"]`);
    if (node) { node.classList.remove("flash"); void node.offsetWidth; node.classList.add("flash"); }
  }, 350);
}

async function loadFeed() {
  try {
    const r = await fetch(`/api/state?tenant=${encodeURIComponent(TENANT)}`);
    const data = await r.json();
    (data.events || []).slice().reverse().forEach((ev) => addEvent(ev, false));
  } catch { /* ignore */ }
}

// ── WebSocket live channel ─────────────────────────────────
function connectWS() {
  const proto = location.protocol === "https:" ? "wss" : "ws";
  let ws;
  try { ws = new WebSocket(`${proto}://${location.host}/ws?tenant=${encodeURIComponent(TENANT)}`); }
  catch { setConn(false); setTimeout(connectWS, 2500); return; }

  let ping;
  ws.addEventListener("open", () => {
    setConn(true);
    ping = setInterval(() => { try { ws.send("ping"); } catch {} }, 25000);
    loadFeed(); // catch up on anything missed
  });
  ws.addEventListener("message", (e) => {
    if (e.data === "pong") return;
    let msg; try { msg = JSON.parse(e.data); } catch { return; }
    if (msg.kind === "webhook") addEvent(msg, true);
  });
  ws.addEventListener("close", () => { setConn(false); clearInterval(ping); setTimeout(connectWS, 2500); });
  ws.addEventListener("error", () => { try { ws.close(); } catch {} });
}

function setConn(live) {
  $("connDot").className = "dot " + (live ? "live" : "off");
  $("connText").textContent = live ? "live" : "offline";
}


The `syncSelectionUI` helper has a leftover no-op and the pre-selection ordering is wrong (radios render before selections are computed). Let me fix that section cleanly — compute pre-selections *before* rendering so radios reflect them.


170	    head.appendChild(el("span", "status-chip " + part.status, esc(part.status)));
171	    row.appendChild(head);
172
173	    const meta = [];
174	    if (part.external_id) meta.push(part.external_id);
175	    if (part.note) meta.push("note: " + part.note);
176	    row.appendChild(el("div", "part-meta", esc(meta.join("  ·  ")) || " "));
177
178	    if (!ordered) {
179	      const offersBox = el("div", "offers");
180	      if (partOffers.length === 0) {
181	        offersBox.appendChild(el("div", "part-meta", "No supplier offers priced for this part."));
182	      }
183	      partOffers.forEach((o) => {
184	        const isOem = (o.name || "").toLowerCase().includes("genuine");
185	        const confirmable = o.supplier_business_id === confId;
186	        const offer = el("label", "offer" + (confirmable ? "" : " disabled"));
187	        const radio = el("input");
188	        radio.type = "radio";
189	        radio.name = "part_" + part.id;
190	        radio.disabled = !confirmable;
191	        radio.checked = state.selections[part.id]?.sellable_id === o.sellable_id;
192	        radio.addEventListener("change", () => { if (confirmable) selectOffer(part, o); });
193	        offer.appendChild(radio);
194
195	        const main = el("div", "offer-main");
196	        main.appendChild(el("div", "offer-name", esc(o.name)));
197	        const sup = el("div", "offer-supplier");
198	        sup.innerHTML =
199	          `<span>${esc(suppliers[o.supplier_business_id] || "supplier")}</span>` +
200	          (isOem ? `<span class="tag oem">OEM</span>` : `<span class="tag aftm">aftermarket</span>`) +
201	          (confirmable ? "" : `<span class="tag compare">compare only</span>`) +
202	          (o.quantity > 1 ? `<span class="muted">×${o.quantity}</span>` : "");
203	        main.appendChild(sup);
204	        offer.appendChild(main);
205
206	        const price = el("div", "offer-price");
207	        price.innerHTML = `${money(o.trade_price)} <span class="cur">NZD*</span>`;
208	        offer.appendChild(price);
209
210	        if (radio.checked) offer.classList.add("selected");
211	        offersBox.appendChild(offer);
212	      });
213
214	      const hasConfirmable = partOffers.some((o) => o.supplier_business_id === confId);
215	      if (partOffers.length > 0 && !hasConfirmable) {
216	        offersBox.appendChild(el("div", "dead-end",
217	          `No ${esc(state.confirmable.name)} offer for this part in the sandbox — these can be compared but not ordered (couldn’t be confirmed).`));
218	      }
219	      row.appendChild(offersBox);
220	    }
221
222	    list.appendChild(row);
223	  });
224
225	  // Pre-select the confirmable (recommended) offer for any unselected estimated part.
226	  parts.filter((p) => p.status === "estimated").forEach((part) => {
227	    if (state.selections[part.id]) return;
228	    const rec = offers.find((o) => (o.job_part_ids || []).includes(part.id) && o.supplier_business_id === confId);
229	    if (rec) state.selections[part.id] = rec;
230	  });
231	  // Reflect any pre-selection in the DOM + summary without a full re-render loop.
232	  syncSelectionUI();
233	  renderOrderBar();
234	}
235
236	function selectOffer(part, offer) {
237	  state.selections[part.id] = offer;
238	  syncSelectionUI();
239	  renderOrderBar();
240	}
241
242	function syncSelectionUI() {
243	  document.querySelectorAll("#partsList .offer").forEach((node) => {
244	    const r = node.querySelector("input");
245	    if (!r) return;
246	    node.classList.toggle("selected", r.checked);
247	  });
248	  // Ensure radios match selection state (covers pre-selection).
249	  Object.entries(state.selections).forEach(([pid, o]) => {
250	    const r = document.querySelector(`input[name="part_${pid}"][type="radio"]`);
251	    if (r && !r.checked) {
252	      const labels = document.querySelectorAll(`input[name="part_${pid}"]`);
253	      labels.forEach((x) => {
254	        const checked = x.closest(".offer") && x === x; // noop guard
255	      });
256	    }
257	  });
258	}
259
260	function renderOrderBar() {
261	  const sels = Object.values(state.selections);
262	  const bar = $("orderBar");
263	  if (sels.length === 0) { bar.hidden = true; return; }
264	  bar.hidden = false;

    head.appendChild(el("span", "status-chip " + part.status, esc(part.status)));
    row.appendChild(head);

    const meta = [];
    if (part.external_id) meta.push(part.external_id);
    if (part.note) meta.push("note: " + part.note);
    row.appendChild(el("div", "part-meta", esc(meta.join("  ·  ")) || " "));

    if (!ordered) {
      const offersBox = el("div", "offers");
      if (partOffers.length === 0) {
        offersBox.appendChild(el("div", "part-meta", "No supplier offers priced for this part."));
      }
      partOffers.forEach((o) => {
        const isOem = (o.name || "").toLowerCase().includes("genuine");
        const confirmable = o.supplier_business_id === confId;
        const offer = el("label", "offer" + (confirmable ? "" : " disabled"));
        const radio = el("input");
        radio.type = "radio";
        radio.name = "part_" + part.id;
        radio.disabled = !confirmable;
        radio.checked = state.selections[part.id]?.sellable_id === o.sellable_id;
        radio.addEventListener("change", () => { if (confirmable) selectOffer(part, o); });
        offer.appendChild(radio);

        const main = el("div", "offer-main");
        main.appendChild(el("div", "offer-name", esc(o.name)));
        const sup = el("div", "offer-supplier");
        sup.innerHTML =
          `<span>${esc(suppliers[o.supplier_business_id] || "supplier")}</span>` +
          (isOem ? `<span class="tag oem">OEM</span>` : `<span class="tag aftm">aftermarket</span>`) +
          (confirmable ? "" : `<span class="tag compare">compare only</span>`) +
          (o.quantity > 1 ? `<span class="muted">×${o.quantity}</span>` : "");
        main.appendChild(sup);
        offer.appendChild(main);

        const price = el("div", "offer-price");
        price.innerHTML = `${money(o.trade_price)} <span class="cur">NZD*</span>`;
        offer.appendChild(price);

        if (radio.checked) offer.classList.add("selected");
        offersBox.appendChild(offer);
      });

      const hasConfirmable = partOffers.some((o) => o.supplier_business_id === confId);
      if (partOffers.length > 0 && !hasConfirmable) {
        offersBox.appendChild(el("div", "dead-end",
          `No ${esc(state.confirmable.name)} offer for this part in the sandbox — these can be compared but not ordered (couldn’t be confirmed).`));
      }
      row.appendChild(offersBox);
    }

    list.appendChild(row);
  });

  // Pre-select the confirmable (recommended) offer for any unselected estimated part.
  parts.filter((p) => p.status === "estimated").forEach((part) => {
    if (state.selections[part.id]) return;
    const rec = offers.find((o) => (o.job_part_ids || []).includes(part.id) && o.supplier_business_id === confId);
    if (rec) state.selections[part.id] = rec;
  });
  // Reflect any pre-selection in the DOM + summary without a full re-render loop.
  syncSelectionUI();
  renderOrderBar();
}

function selectOffer(part, offer) {
  state.selections[part.id] = offer;
  syncSelectionUI();
  renderOrderBar();
}

function syncSelectionUI() {
  document.querySelectorAll("#partsList .offer").forEach((node) => {
    const r = node.querySelector("input");
    if (!r) return;
    node.classList.toggle("selected", r.checked);
  });
  // Ensure radios match selection state (covers pre-selection).
  Object.entries(state.selections).forEach(([pid, o]) => {
    const r = document.querySelector(`input[name="part_${pid}"][type="radio"]`);
    if (r && !r.checked) {
      const labels = document.querySelectorAll(`input[name="part_${pid}"]`);
      labels.forEach((x) => {
        const checked = x.closest(".offer") && x === x; // noop guard
      });
    }
  });
}

function renderOrderBar() {
  const sels = Object.values(state.selections);
  const bar = $("orderBar");
  if (sels.length === 0) { bar.hidden = true; return; }
  bar.hidden = false;


144	// ── Render: parts + offers ─────────────────────────────────
145	function renderParts(v) {
146	  const card = $("partsCard");
147	  card.hidden = false;
148	  const parts = v.parts?.items || [];
149	  const offers = v.basket?.offers || [];
150	  const suppliers = {};
151	  (v.basket?.suppliers || []).forEach((s) => { suppliers[s.business_id] = s.name; });
152	  const confId = state.confirmable.id;
153
154	  const list = $("partsList");
155	  list.innerHTML = "";
156
157	  // Drop selections for parts that are no longer estimated.
158	  const estimatedIds = new Set(parts.filter((p) => p.status === "estimated").map((p) => p.id));
159	  Object.keys(state.selections).forEach((pid) => { if (!estimatedIds.has(pid)) delete state.selections[pid]; });
160
161	  $("partsNote").textContent = `${parts.length} parts · ${parts.filter((p) => p.status === "ordered").length} ordered`;
162
163	  parts.forEach((part) => {
164	    const partOffers = offers.filter((o) => (o.job_part_ids || []).includes(part.id));
165	    const ordered = part.status === "ordered";
166	    const row = el("div", "part" + (ordered ? " done" : ""));
167
168	    const head = el("div", "part-head");
169	    head.appendChild(el("div", "part-name", esc(part.description)));

// ── Render: parts + offers ─────────────────────────────────
function renderParts(v) {
  const card = $("partsCard");
  card.hidden = false;
  const parts = v.parts?.items || [];
  const offers = v.basket?.offers || [];
  const suppliers = {};
  (v.basket?.suppliers || []).forEach((s) => { suppliers[s.business_id] = s.name; });
  const confId = state.confirmable.id;

  const list = $("partsList");
  list.innerHTML = "";

  // Drop selections for parts that are no longer estimated.
  const estimatedIds = new Set(parts.filter((p) => p.status === "estimated").map((p) => p.id));
  Object.keys(state.selections).forEach((pid) => { if (!estimatedIds.has(pid)) delete state.selections[pid]; });

  $("partsNote").textContent = `${parts.length} parts · ${parts.filter((p) => p.status === "ordered").length} ordered`;

  parts.forEach((part) => {
    const partOffers = offers.filter((o) => (o.job_part_ids || []).includes(part.id));
    const ordered = part.status === "ordered";
    const row = el("div", "part" + (ordered ? " done" : ""));

    const head = el("div", "part-head");
    head.appendChild(el("div", "part-name", esc(part.description)));


Now I'll restructure: move pre-selection before the render loop and replace the broken `syncSelectionUI`.


Edit

  // Drop selections for parts that are no longer estimated.
  const estimatedIds = new Set(parts.filter((p) => p.status === "estimated").map((p) => p.id));
  Object.keys(state.selections).forEach((pid) => { if (!estimatedIds.has(pid)) delete state.selections[pid]; });

  $("partsNote").textContent = `${parts.length} parts · ${parts.filter((p) => p.status === "ordered").length} ordered`;

  // Drop selections for parts that are no longer estimated.
  const estimatedIds = new Set(parts.filter((p) => p.status === "estimated").map((p) => p.id));
  Object.keys(state.selections).forEach((pid) => { if (!estimatedIds.has(pid)) delete state.selections[pid]; });

  // Pre-select the confirmable (recommended) offer for any unselected estimated part,
  // BEFORE rendering, so each radio's checked state reflects it.
  parts.filter((p) => p.status === "estimated").forEach((part) => {
    if (state.selections[part.id]) return;
    const rec = offers.find((o) => (o.job_part_ids || []).includes(part.id) && o.supplier_business_id === confId);
    if (rec) state.selections[part.id] = rec;
  });

  $("partsNote").textContent = `${parts.length} parts · ${parts.filter((p) => p.status === "ordered").length} ordered`;