// screens-inventory.jsx — single-card inventory, Ops side
//
// Every row is one SKU: a real card, in a condition, from a known collection
// buy. Cost comes from the buy; ask comes from market x condition. The two
// numbers that matter are margin (is the shelf worth more than it cost) and
// unpriced count (how much of the shelf we can't value yet).

function InvSearchBox({ value, onChange, placeholder }) {
  return (
    <div className="inv-search">
      <Icon name="grid" size={16} color="var(--ink-3)" />
      <input value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder}
             aria-label="Search inventory" />
      {value && <button onClick={() => onChange('')} aria-label="Clear search">×</button>}
    </div>
  );
}

function InvRarityTag({ rarity }) {
  const r = CARD_RARITY[rarity] || { short: '—', tier: 0 };
  const cls = r.tier >= 5 ? 'badge-gold' : r.tier === 4 ? 'badge-accent' : 'badge-good';
  return <span className={'badge ' + cls} title={rarity}>{r.short}</span>;
}

/* ───────────── costing a whole collection in one pass ─────────────
   The per-row form below is right for a card bought loose over the counter.
   It is the wrong shape for PCS season, when 200 cards arrive as one lot with
   one number on it — typing a unit cost 200 times is how rows stay uncosted.

   This applies the allocation method the inventory already uses: every uncosted
   unit takes basis in proportion to its condition-adjusted ask, at the lot's
   own buy rate. The preview states the rate and what will be skipped BEFORE it
   runs, because it writes to many rows at once and one of them being wrong is
   harder to notice than one row being wrong. */
function LotAllocate({ roll, onSaved, picked = [], onClearPick }) {
  const lots = (typeof OPS_PIPELINE !== 'undefined' ? OPS_PIPELINE : [])
    .filter(p => p.stage === 'bought' && p.market > 0);
  const [lotId, setLotId] = React.useState(lots.length ? lots[0].id : '');
  const [err, setErr] = React.useState(null);
  const [done, setDone] = React.useState(null);

  const lot = lots.find(l => l.id === lotId);
  // Dry run: the same split the write will make, computed for the preview so
  // the two cannot disagree about what is about to happen.
  const preview = React.useMemo(() => {
    if (!lot) return null;
    const rate = lot.offer / lot.market;
    const pool = INVENTORY.filter(r => uncostedQty(r) > 0
      && (picked.length === 0 || picked.includes(r.sku)));
    let allocated = 0, willCost = 0;
    const skip = [];
    pool.forEach(r => {
      const ask = skuAsk(r);
      if (ask == null) { skip.push(r.sku); return; }
      willCost += 1; allocated += ask * rate * uncostedQty(r);
    });
    return { rate, willCost, skip, allocated, pool: pool.length };
  }, [lot, roll.uncostedUnits, roll.uncosted, picked.join(',')]);

  if (!lots.length || !preview) return null;

  function run() {
    try {
      const res = allocateLotCost(lotId, picked.length ? { skus: picked } : {});
      setErr(null);
      setDone(res);
      if (onClearPick) onClearPick();
      onSaved();
    } catch (e) {
      setErr(String((e && e.message) || e));
    }
  }

  return (
    <div className="inv-alloc">
      <div style={{ flex: 1, minWidth: 240 }}>
        <div className="kicker" style={{ marginBottom: 6 }}>
          {picked.length > 0
            ? picked.length + ' row' + (picked.length === 1 ? '' : 's') + ' selected'
            : roll.uncostedUnits + ' scanned unit' + (roll.uncostedUnits === 1 ? '' : 's') + ' with no cost basis'}
        </div>
        {done ? (
          <p className="muted" style={{ fontSize: 12.5, lineHeight: 1.5, margin: 0 }}>
            Allocated <b>{money(done.allocated)}</b> across {done.costed} SKU{done.costed === 1 ? '' : 's'} at{' '}
            <b>{(done.rate * 100).toFixed(0)}%</b> of market, from {done.lot.who}.
            {done.skipped.length > 0 && <> {done.skipped.length} skipped for having no market price — they stay uncosted.</>}
          </p>
        ) : (
          <p className="muted" style={{ fontSize: 12.5, lineHeight: 1.5, margin: 0 }}>
            Allocating {lot.who}'s buy — {money0(lot.offer)} against {money0(lot.market)} of market —
            costs {preview.willCost}{picked.length > 0 ? ' selected' : ''} SKU{preview.willCost === 1 ? '' : 's'} at{' '}
            <b>{(preview.rate * 100).toFixed(0)}% of each card's ask</b>, about{' '}
            <b>{money(preview.allocated)}</b> in total.
            {preview.skip.length > 0 && (
              <> {preview.skip.length} <b>cannot be allocated</b> — no market price, and the method
              cannot assign cost to an item of unknown value. Those stay uncosted rather than
              being written down as free.</>
            )}
          </p>
        )}
      </div>
      {!done && (
        <div className="inv-cost-row" style={{ flexShrink: 0 }}>
          <label className="inv-cost-field">
            <span className="kicker">Collection</span>
            <select value={lotId} onChange={(e) => setLotId(e.target.value)} aria-label="Collection to allocate">
              {lots.map(l => <option key={l.id} value={l.id}>{l.who} · {l.id}</option>)}
            </select>
          </label>
          <button className="btn btn-soft" style={{ padding: '11px 18px', fontSize: 13.5 }}
                  disabled={preview.willCost === 0} onClick={run}>
            Allocate pro-rata
          </button>
        </div>
      )}
      {err && <p className="lens-warn" style={{ margin: 0, width: '100%' }}>{err}</p>}
    </div>
  );
}

/* ───────────── capturing the product photograph ─────────────
   The browser can do three of the four things SPEC-008 asks for: resize, strip
   EXIF, and name the file. It cannot commit it — this is a static site with no
   backend, and the photo has to end up in `website/photos/` to deploy.

   So the step ends by handing over a correctly-named file and then TELLING THE
   TRUTH about where it is. A path recorded at capture time would clear the Ops
   flag while the site still had no image; instead the path sits as pending
   until a HEAD request against the site answers for it. Captured is not
   deployed, and a flag that clears on intent rather than on fact is worse than
   no flag at all. */
function PhotoCapture({ row, need, onSaved }) {
  const [busy, setBusy] = React.useState(null);
  const [err, setErr] = React.useState(null);
  const faces = need.faces === 2 ? ['front', 'back'] : ['front'];
  const live = skuPhotos(row);
  const pending = skuPendingPhotos(row);

  async function take(face, file) {
    if (!file) return;
    setBusy(face); setErr(null);
    try {
      const shot = await preparePhoto(file, { sku: row.sku, face });
      // Hand the file over. Nothing else can put it in the repo.
      const url = URL.createObjectURL(shot.blob);
      const a = document.createElement('a');
      a.href = url; a.download = shot.filename.split('/').pop();
      document.body.appendChild(a); a.click(); a.remove();
      setTimeout(() => URL.revokeObjectURL(url), 2000);
      await confirmPhoto(row.sku, shot.filename);
      onSaved();
    } catch (e) {
      setErr(String((e && e.message) || e));
    } finally {
      setBusy(null);
    }
  }

  async function recheck(path) {
    setBusy(path);
    try { await confirmPhoto(row.sku, path); onSaved(); }
    finally { setBusy(null); }
  }

  return (
    <div className="inv-photo">
      <div className="kicker" style={{ marginBottom: 8 }}>
        {need.label}{need.fed ? ' — this one is fed' : ''} · {need.note}
      </div>
      <div className="inv-photo-faces">
        {faces.map(face => {
          const path = photoFilename(row.sku, face);
          const isLive = live.includes(path);
          const isPending = pending.includes(path);
          return (
            <div key={face} className={'inv-photo-face' + (isLive ? ' live' : isPending ? ' pending' : '')}>
              <div className="kicker">{face}</div>
              {isLive ? (
                <div className="ops-num" style={{ color: 'var(--good)' }}>On the site</div>
              ) : isPending ? (
                <>
                  <div className="ops-num" style={{ color: 'var(--gold)' }}>Saved, not deployed</div>
                  <button className="btn btn-soft" style={{ padding: '7px 12px', fontSize: 12 }}
                          disabled={busy === path} onClick={() => recheck(path)}>
                    {busy === path ? 'Checking…' : 'Re-check'}
                  </button>
                </>
              ) : (
                <label className="btn btn-soft inv-photo-pick" style={{ padding: '8px 13px', fontSize: 12.5 }}>
                  {busy === face ? 'Preparing…' : 'Capture ' + face}
                  <input type="file" accept="image/*" capture="environment"
                         aria-label={'Capture ' + face + ' of ' + row.sku}
                         onChange={(e) => { take(face, e.target.files && e.target.files[0]); e.target.value = ''; }} />
                </label>
              )}
              <code className="mono">{path}</code>
            </div>
          );
        })}
      </div>
      <p className="muted" style={{ fontSize: 12, lineHeight: 1.5, margin: '9px 0 0' }}>
        Resized to {PHOTO_LONG_EDGE}px on the long edge and re-encoded, which is what strips the
        EXIF — <b>phone photos carry GPS by default</b>, and that should not ship with the site.
        The file downloads; commit it to <span className="mono">website/photos/</span> and it goes
        live on the next deploy. Until the site actually answers for it, this SKU still counts as
        unphotographed.
      </p>
      {err && <p className="lens-warn" style={{ marginTop: 8 }}>{err}</p>}
    </div>
  );
}

/* ───────────── recording what a scanned unit cost ─────────────
   Scanned units sit out of margin until this runs. It is deliberately a small,
   local form rather than a modal: the number is usually known at the moment
   someone is looking at the row, and making them navigate away to enter it is
   how rows stay uncosted forever.

   The preview is the point. On a row that already holds bought units the cost
   becomes a weighted average across the whole SKU, and that is surprising
   enough that it should be shown before it is committed rather than explained
   afterwards. */
function CostEntry({ row, onSaved }) {
  const [val, setVal] = React.useState('');
  const [lot, setLot] = React.useState(row.sourceLot || '');
  const [err, setErr] = React.useState(null);

  const pending = uncostedQty(row);
  const prior = costedQty(row);
  const each = Number(val);
  const valid = val.trim() !== '' && Number.isFinite(each) && each >= 0;
  const blended = valid ? (row.unitCost * prior + each * pending) / row.qty : null;

  function save() {
    try {
      setScannedCost(row.sku, each, { sourceLot: lot || null });
      setErr(null);
      onSaved();
    } catch (e) {
      setErr(String((e && e.message) || e));
    }
  }

  return (
    <div className="inv-cost">
      <div className="kicker" style={{ marginBottom: 8 }}>
        Record what {pending === 1 ? 'the scanned unit' : 'the ' + pending + ' scanned units'} cost
      </div>
      <div className="inv-cost-row">
        <label className="inv-cost-field">
          <span className="kicker">Paid, each</span>
          <div className="inv-cost-input">
            <span>$</span>
            <input type="number" min="0" step="0.01" value={val} inputMode="decimal"
                   onChange={(e) => setVal(e.target.value)} placeholder="0.00"
                   aria-label={'Unit cost for ' + row.sku} />
          </div>
        </label>
        <label className="inv-cost-field">
          <span className="kicker">From collection</span>
          <select value={lot} onChange={(e) => setLot(e.target.value)} aria-label="Source collection">
            <option value="">No lot — bought loose</option>
            {OPS_PIPELINE.filter(p => p.stage === 'bought').map(p => (
              <option key={p.id} value={p.id}>{p.who} · {p.id}</option>
            ))}
          </select>
        </label>
        <button className="btn btn-accent" disabled={!valid} style={{ opacity: valid ? 1 : 0.45, padding: '11px 18px', fontSize: 13.5 }}
                onClick={save}>
          Record cost
        </button>
      </div>
      <p className="muted" style={{ fontSize: 12, lineHeight: 1.5, margin: '9px 0 0' }}>
        {valid && prior > 0 ? (
          <>
            This SKU already holds {prior} unit{prior === 1 ? '' : 's'} at {money(row.unitCost)}. Recording{' '}
            {money(each)} for {pending === 1 ? 'the other one' : 'the other ' + pending} makes the row a
            weighted average of <b>{money(blended)}</b> across all {row.qty} — one SKU carries one unit
            cost, so the two histories blend rather than split the row.
          </>
        ) : valid ? (
          <>Records {money(each)} each for {pending} unit{pending === 1 ? '' : 's'}, moving {pending === 1 ? 'it' : 'them'} into margin.</>
        ) : (
          <><b>$0.00 is a real answer</b> — a giveaway or a throw-in genuinely cost nothing, and recorded
          zero counts in margin. Leaving the field blank is not the same thing: unrecorded stays out.</>
        )}
      </p>
      {err && <p className="lens-warn" style={{ marginTop: 8 }}>{err}</p>}
    </div>
  );
}

function InventoryScreen({ openScan }) {
  const [q, setQ] = React.useState('');
  const [setFilter, setSetFilter] = React.useState('all');
  const [sort, setSort] = React.useState('value');
  const [open, setOpen] = React.useState(null);   // expanded sku
  // SPEC-009 sells on marketplaces AND direct, so the shelf's net depends on
  // where it goes. Default to TCGplayer — that is where the volume is.
  const [chan, setChan] = React.useState(DEFAULT_CHANNEL);
  // INVENTORY is a module-level array, so a cost recorded in a row mutates data
  // React is not watching. Bumping a counter is what tells it to look again —
  // and it has to be in the rows memo's deps or the table keeps the stale copy.
  const [rev, setRev] = React.useState(0);
  const bumpRev = React.useCallback(() => setRev(n => n + 1), []);
  // Which SKUs a lot allocation should cover. Empty means "everything
  // allocatable" — see LotAllocate; a lot that is wholly on the shelf is the
  // common case and should not require ticking every box.
  const [selected, setSelected] = React.useState(() => new Set());

  const roll = inventoryRoll(chan);

  const rows = React.useMemo(() => {
    const needle = q.trim().toLowerCase();
    let list = INVENTORY.map(r => {
      const card = cardById(r.cardId);
      return { row: r, card, set: cardSet(card.setCode), ask: skuAsk(r), value: skuValue(r), cost: skuCost(r) };
    }).filter(x => {
      if (setFilter !== 'all' && x.card.setCode !== setFilter) return false;
      if (!needle) return true;
      const hay = [x.card.name, x.card.number, x.set.name, x.set.code, x.row.sku, x.card.rarity, x.card.nickname || '']
        .join(' ').toLowerCase();
      return hay.includes(needle);
    });
    const key = {
      value: (x) => -(x.value == null ? -1 : x.value),
      margin: (x) => -((x.value == null ? 0 : x.value) - x.cost),
      name: (x) => x.card.name,
      set: (x) => x.set.released,
    }[sort];
    return list.sort((a, b) => {
      const ka = key(a), kb = key(b);
      return typeof ka === 'string' ? ka.localeCompare(kb) : ka - kb;
    });
  }, [q, setFilter, sort, rev]);

  const shown = {
    units: rows.reduce((s, x) => s + x.row.qty, 0),
    cost: rows.reduce((s, x) => s + x.cost, 0),
    value: rows.reduce((s, x) => s + (x.value || 0), 0),
    // Same rule as the row and the rollup: the footer's margin only counts
    // value that has a cost behind it, or scanned-in units would read as pure
    // profit at the bottom of the table.
    costedValue: rows.reduce((s, x) => s + (x.ask == null ? 0 : x.ask * costedQty(x.row)), 0),
    uncostedUnits: rows.reduce((s, x) => s + uncostedQty(x.row), 0),
  };
  const filtered = setFilter !== 'all' || q.trim() !== '';
  // Only rows awaiting a cost can be allocated, and only the ones currently
  // shown — selecting something the filter has hidden would allocate to a row
  // nobody can see.
  const allocatable = rows.filter(x => uncostedQty(x.row) > 0).map(x => x.row.sku);
  const anyUncosted = allocatable.length > 0;
  // A filter change can strip a selected row off the screen; drop it rather
  // than keep a hidden row armed.
  const picked = [...selected].filter(s => allocatable.includes(s));

  return (
    <div className="fade-up">
      <header className="ops-hd">
        <div>
          <div className="kicker" style={{ marginBottom: 7 }}>Ops · Inventory</div>
          <h1 style={{ fontFamily: 'var(--ff-display)', fontWeight: 800, fontSize: 34, letterSpacing: '-0.03em', lineHeight: 1, margin: 0 }}>
            Singles on the shelf
          </h1>
          <p className="muted" style={{ fontSize: 13.5, margin: '9px 0 0' }}>
            {roll.skus} SKUs · {roll.distinctCards} distinct cards · {roll.units} units · plus {roll.bulkCount.toLocaleString('en-US')} in bulk
            {roll.uncostedUnits > 0 && (
              <> · <span style={{ color: 'var(--gold)' }}>
                {roll.uncostedUnits} scanned in with no cost recorded, held out of margin
              </span></>
            )}
          </p>
        </div>
        <div style={{ display: 'flex', alignItems: 'center', gap: 10, flexShrink: 0 }}>
          {roll.unpriced > 0 && (
            <span className="badge badge-low" style={{ fontSize: 11.5 }}>{roll.unpriced} unpriced</span>
          )}
          {/* Same class of problem, so it sits beside the unpriced count rather
              than somewhere else: a SKU with no photograph cannot be sold as it
              stands either. SPEC-008. */}
          {roll.unphotographed > 0 && (
            <span className="badge badge-low" style={{ fontSize: 11.5 }}
                  title="SKUs needing a photograph before they can be listed">
              {roll.unphotographed} no photo
            </span>
          )}
          {/* The buy counter's version of this screen: a card you can't read is
              a card you can't price. Lens turns it into a row above. */}
          {openScan && (
            <button className="btn btn-soft" style={{ padding: '10px 15px', fontSize: 13.5 }} onClick={openScan}>
              <Icon name="scan" size={17} sw={2.2} /> Scan a card
            </button>
          )}
        </div>
      </header>

      <div className="ops-stats">
        <OpsStat label="Cost basis" value={money0(roll.cost)} sub={roll.units + ' units acquired'} />
        <OpsStat label="Ask value" value={money0(roll.value)} sub={roll.unpriced > 0 ? 'excludes ' + roll.unpricedUnits + ' unpriced units' : 'all units priced'} tone="good" />
        <OpsStat label="Margin, net of fees" value={money0(roll.netMargin)}
                 sub={Math.round(roll.netRoc) + '% ROC · ' + (roll.takePct * 100).toFixed(1) + '% blended take'}
                 tone={roll.netMargin > 0 ? 'good' : 'accent'} />
        <OpsStat label="Bulk" value={roll.bulkCount.toLocaleString('en-US')} sub={money0(roll.bulkValue) + ' · not indexed per card'} tone="gold" />
      </div>

      {roll.uncostedUnits > 0 && (
        <LotAllocate roll={roll} onSaved={bumpRev} picked={picked}
                     onClearPick={() => setSelected(new Set())} />
      )}

      <section className="card hairline ops-panel">
        <header className="ops-panel-hd">
          <div>
            <div className="kicker" style={{ marginBottom: 5 }}>Card index</div>
            <h2 style={{ fontFamily: 'var(--ff-display)', fontWeight: 800, fontSize: 19, letterSpacing: '-0.02em', margin: 0 }}>
              {filtered ? rows.length + ' of ' + INVENTORY.length + ' SKUs' : 'All SKUs'}
            </h2>
          </div>
          <InvSearchBox value={q} onChange={setQ} placeholder="Card, number, set, SKU…" />
        </header>

        <div className="inv-controls">
          <div className="rail" style={{ padding: 0, gap: 8 }}>
            <button className={'chip' + (setFilter === 'all' ? ' on' : '')} onClick={() => setSetFilter('all')}>All sets</button>
            {CARD_SETS.filter(s => INVENTORY.some(r => cardById(r.cardId).setCode === s.code)).map(s => (
              <button key={s.code} className={'chip' + (setFilter === s.code ? ' on' : '')} onClick={() => setSetFilter(s.code)}>
                {s.name}
              </button>
            ))}
          </div>
          <label className="inv-sort">
            <span className="kicker">Channel</span>
            <select value={chan} onChange={(e) => setChan(e.target.value)} title={channel(chan).note}>
              {Object.values(CHANNELS).map(c => (
                <option key={c.id} value={c.id}>{c.label}</option>
              ))}
            </select>
          </label>
          <label className="inv-sort">
            <span className="kicker">Sort</span>
            <select value={sort} onChange={(e) => setSort(e.target.value)}>
              <option value="value">Ask value</option>
              <option value="margin">Margin</option>
              <option value="name">Card name</option>
              <option value="set">Set date</option>
            </select>
          </label>
        </div>

        <div className="ops-tablewrap">
          <table className="ops-table inv-table">
            <thead>
              <tr>
                {/* The select column only exists while there is something to
                    select. A permanent column of dead checkboxes on a table
                    where most rows are already costed is noise. */}
                {anyUncosted && (
                  <th className="inv-pick">
                    <input type="checkbox" aria-label="Select all rows awaiting a cost"
                           checked={allocatable.length > 0 && allocatable.every(s => selected.has(s))}
                           ref={el => { if (el) el.indeterminate = selected.size > 0 && selected.size < allocatable.length; }}
                           onChange={(e) => setSelected(e.target.checked ? new Set(allocatable) : new Set())} />
                  </th>
                )}
                <th>Card</th><th>Set</th><th>No.</th><th>Rarity</th><th>Cond</th>
                <th className="num">Qty</th><th className="num">Unit cost</th><th className="num">Ask ea</th>
                <th className="num">Value</th><th className="num">Margin</th><th>Location</th>
              </tr>
            </thead>
            <tbody>
              {rows.map(({ row, card, set, ask, value, cost }) => {
                // Units imported by a scan have no cost basis, and the table
                // must not imply one. A $0.00 unit cost reads as "free", and a
                // margin computed against it reads as pure profit — both are
                // the flattering answer to a question nobody has answered yet.
                const noBasis = uncostedQty(row);
                const allUncosted = costedQty(row) === 0;
                // Margin compares like with like: only the units that cost
                // something. On a row holding one bought copy and one scanned
                // copy, the scanned copy's ask is value, not gain.
                const margin = value == null || allUncosted ? null : ask * costedQty(row) - cost;
                const gap = photoGap(row, { channelId: chan });
                const isOpen = open === row.sku;
                return (
                  <React.Fragment key={row.sku}>
                    <tr onClick={() => setOpen(isOpen ? null : row.sku)} className={'inv-row' + (isOpen ? ' open' : '')}>
                      {anyUncosted && (
                        // stopPropagation, or ticking a box also expands the
                        // row — two different intents on the same click.
                        <td className="inv-pick" onClick={(e) => e.stopPropagation()}>
                          {noBasis > 0 ? (
                            <input type="checkbox" checked={selected.has(row.sku)}
                                   aria-label={'Select ' + row.sku + ' for allocation'}
                                   onChange={() => setSelected(prev => {
                                     const next = new Set(prev);
                                     if (next.has(row.sku)) next.delete(row.sku); else next.add(row.sku);
                                     return next;
                                   })} />
                          ) : null}
                        </td>
                      )}
                      <td style={{ fontWeight: 700 }}>
                        {card.name}
                        {card.variant && <span className="muted" style={{ fontWeight: 400 }}> · {card.variant}</span>}
                      </td>
                      <td className="muted">{set.name}</td>
                      <td className="mono">{card.number}/{set.printed}</td>
                      <td><InvRarityTag rarity={card.rarity} /></td>
                      <td className="mono">{row.condition}</td>
                      <td className="num mono" style={{ fontWeight: 700 }}>
                        {row.qty}
                        {/* "+1" only makes sense as "1 OF these has no basis".
                            On a row where every unit was scanned in, the unit
                            cost cell already says "not set" and a +N beside the
                            qty just reads as one more unit. */}
                        {noBasis > 0 && (
                          <span className="muted" title={noBasis + ' of ' + row.qty + ' scanned in, no cost recorded'}>
                            {allUncosted ? ' ⚑' : ' +' + noBasis + '⚑'}
                          </span>
                        )}
                      </td>
                      <td className="num mono">
                        {allUncosted
                          ? <span className="muted" title="Scanned in — nobody has recorded what was paid">not set</span>
                          : money(row.unitCost)}
                      </td>
                      <td className="num mono">{ask == null ? <span className="muted">—</span> : money(ask)}</td>
                      <td className="num mono" style={{ fontWeight: 700 }}>{value == null ? <span className="muted">unpriced</span> : money0(value)}</td>
                      <td className="num mono" style={{ fontWeight: 700, color: margin == null ? 'var(--ink-3)' : margin >= 0 ? 'var(--good)' : 'var(--accent)' }}>
                        {margin == null ? '—' : money0(margin)}
                      </td>
                      <td className="mono muted">{row.location}</td>
                    </tr>
                    {isOpen && (
                      <tr className="inv-detail">
                        <td colSpan={anyUncosted ? 12 : 11}>
                          <div className="inv-detail-grid">
                            <div>
                              <div className="kicker">SKU</div>
                              <div className="ops-num">{row.sku}</div>
                            </div>
                            <div>
                              <div className="kicker">Card id</div>
                              <div className="ops-num">{card.id}</div>
                            </div>
                            <div>
                              <div className="kicker">Condition</div>
                              <div className="ops-num">{condition(row.condition).label} · ×{condition(row.condition).mult}</div>
                            </div>
                            <div>
                              <div className="kicker">Acquired</div>
                              <div className="ops-num">{row.acquired}</div>
                            </div>
                            <div>
                              <div className="kicker">From collection</div>
                              <div className="ops-num">
                                {(OPS_PIPELINE.find(p => p.id === row.sourceLot) || {}).who
                                  || (row.origin === 'scan' ? 'Scanned in — no lot' : '—')}
                              </div>
                            </div>
                            {noBasis > 0 && (
                              <div>
                                <div className="kicker">Cost basis</div>
                                <div className="ops-num" style={{ color: 'var(--gold)' }}>
                                  {noBasis} of {row.qty} unrecorded
                                </div>
                              </div>
                            )}
                            {/* Which capture this SKU needs, and whether it has
                                it. The band comes from SPEC-008's ladder, so
                                the row states the rule rather than leaving
                                someone to look it up. */}
                            <div>
                              <div className="kicker">Photo</div>
                              <div className="ops-num" style={{ color: gap.blocking ? 'var(--gold)' : undefined }}
                                   title={gap.need.note}>
                                {gap.need.faces === 0
                                  ? gap.need.label
                                  : gap.have >= gap.need.faces
                                    ? gap.have + ' on file'
                                    : gap.need.label + (gap.have ? ' · ' + gap.have + ' of ' + gap.need.faces : ' · none')}
                              </div>
                            </div>
                            <div>
                              <div className="kicker">Set released</div>
                              <div className="ops-num">{set.released}</div>
                            </div>
                          </div>
                          <p className="muted inv-prov">
                            <b>Market:</b>{' '}
                            {card.market
                              ? <>{money(card.market.rawNM)} NM as of {card.market.asOf} — {card.market.basis}</>
                              : <>no price captured. Ask is unset; value excluded from the roll-up.</>}
                            <br />
                            <b>Identity verified:</b> {card.src}
                            {row.costedOn && <><br /><b>Cost recorded:</b> {row.costedOn}</>}
                          </p>
                          {noBasis > 0 && <CostEntry row={row} onSaved={bumpRev} />}
                          {gap.need.faces > 0 && <PhotoCapture row={row} need={gap.need} onSaved={bumpRev} />}
                        </td>
                      </tr>
                    )}
                  </React.Fragment>
                );
              })}
              {rows.length === 0 && (
                <tr><td colSpan={anyUncosted ? 12 : 11} className="muted" style={{ textAlign: 'center', padding: '26px 0' }}>
                  Nothing matches “{q}”.
                </td></tr>
              )}
            </tbody>
            {filtered && rows.length > 0 && (
              <tfoot>
                <tr>
                  <td colSpan={anyUncosted ? 6 : 5} className="kicker">Shown</td>
                  <td className="num mono" style={{ fontWeight: 700 }}>{shown.units}</td>
                  <td className="num mono">{money0(shown.cost)}</td>
                  <td />
                  <td className="num mono" style={{ fontWeight: 700 }}>{money0(shown.value)}</td>
                  <td className="num mono" style={{ fontWeight: 700, color: 'var(--good)' }}>{money0(shown.costedValue - shown.cost)}</td>
                  <td />
                </tr>
              </tfoot>
            )}
          </table>
        </div>
      </section>

      <div className="ops-grid" style={{ marginTop: 16 }}>
        <section className="card hairline ops-panel">
          <header className="ops-panel-hd">
            <div>
              <div className="kicker" style={{ marginBottom: 5 }}>Not indexed per card — on purpose</div>
              <h2 style={{ fontFamily: 'var(--ff-display)', fontWeight: 800, fontSize: 19, letterSpacing: '-0.02em', margin: 0 }}>Bulk</h2>
            </div>
          </header>
          <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
            {BULK_LOTS.map(b => (
              <div key={b.id} style={{ display: 'flex', alignItems: 'center', gap: 12, justifyContent: 'space-between' }}>
                <div style={{ minWidth: 0 }}>
                  <div style={{ fontWeight: 700, fontSize: 13.5 }}>{b.label}</div>
                  <div className="muted" style={{ fontSize: 11.5, marginTop: 2 }}>
                    {b.location} · from {(OPS_PIPELINE.find(p => p.id === b.sourceLot) || {}).who || '—'}
                  </div>
                </div>
                <div style={{ textAlign: 'right', flexShrink: 0 }}>
                  <div className="ops-num">{b.count.toLocaleString('en-US')} ct</div>
                  <div className="muted mono" style={{ fontSize: 11 }}>{money0(b.count * b.perCard)} @ {money(b.perCard)}</div>
                </div>
              </div>
            ))}
          </div>
          <p className="muted" style={{ fontSize: 11.5, lineHeight: 1.45, margin: '14px 0 0' }}>
            A 3,000-count box of commons is close to $0 against the hours it takes to sleeve.
            Giving each one a SKU would be the most expensive mistake in this system.
          </p>
        </section>

        <section className="card hairline ops-panel">
          <header className="ops-panel-hd">
            <div>
              <div className="kicker" style={{ marginBottom: 5 }}>Data provenance</div>
              <h2 style={{ fontFamily: 'var(--ff-display)', fontWeight: 800, fontSize: 19, letterSpacing: '-0.02em', margin: 0 }}>Where this comes from</h2>
            </div>
            <span className="badge badge-low">Hand-verified</span>
          </header>
          <p className="muted" style={{ fontSize: 12.5, lineHeight: 1.55, margin: 0 }}>
            Every card above is a real card — set, collector number and rarity checked against
            price guides and marketplaces, with the source recorded per row (expand a row to see it).
            Card artwork is deliberately not reproduced: names and numbers are factual catalog data,
            the images are not ours to host.
          </p>
          <p className="muted" style={{ fontSize: 12.5, lineHeight: 1.55, margin: '12px 0 0' }}>
            Prices are a July 2026 snapshot and will drift. The catalog is built to load from
            <span className="mono"> api.pokemontcg.io</span> plus a TCGplayer price feed —
            the adapter is written, but those hosts are blocked from this build environment,
            so the {CARD_CATALOG.length} rows here were entered by hand.
          </p>
        </section>
      </div>

      <div style={{ height: 28 }} />
    </div>
  );
}

// InvSearchBox / InvRarityTag are shared with the customer-facing singles
// storefront — same search affordance, same rarity shorthand, both sides.
Object.assign(window, { InventoryScreen, InvSearchBox, InvRarityTag });
