/* Gravity CMS — Exchange queue, Alterations queue, Add-on requests. */

function AgeTag({ opened, today, overdue = 12 }) {
  const d = ageInDays(opened, today);
  const tone = d >= overdue ? "bad" : d >= Math.max(2, overdue - 5) ? "warn" : "muted";
  return <Badge tone={tone}><Icon name="clock" size={11} /> {d}d</Badge>;
}

// =================================================== Exchange queue
function ExchangeQueue({ state, actions, overdueDays = 12 }) {
  const [filter, setFilter] = React.useState("active");
  const [oldestFirst, setOldestFirst] = React.useState(true);
  // #82: Brittney places replacement orders vendor by vendor — grouping is a
  // view toggle, off by default so the queue opens the way staff know it.
  const [byVendor, setByVendor] = React.useState(false);
  const [resolving, setResolving] = React.useState(null);

  // #71: the tab buckets come from the SAME GLOGIC predicates the nav badge
  // uses, so a change to what "open" means can't leave the badge and the
  // screen disagreeing. DropQueue has always done it this way.
  const active = GLOGIC.allOpenExchanges(state);
  const backordered = GLOGIC.backordered(state);
  const history = state.exchanges.filter((e) => e.status === "resolved");

  let rows = filter === "active" ? active.slice()
    : filter === "backordered" ? backordered.slice()
    : filter === "history" ? history.slice()
    : state.exchanges.slice();
  rows.sort((a, b) => oldestFirst ? a.opened.localeCompare(b.opened) : b.opened.localeCompare(a.opened));

  const counts = {
    active: active.length,
    backordered: backordered.length,
    history: history.length,
    all: state.exchanges.length,
  };

  return (
    <div className="page">
      <div className="page-head">
        <div>
          <h1 className="page-title">Exchanges</h1>
          <p className="page-sub">Re-order queue with status tracking. Color = where each request stands.</p>
        </div>
        <div className="legend">
          <span className="legend-item"><i className="swatch sw-new" /> New</span>
          <span className="legend-item"><i className="swatch sw-ordered" /> Ordered</span>
          <span className="legend-item"><i className="swatch sw-back" /> Backordered</span>
          <span className="legend-item"><i className="swatch sw-resolved" /> Resolved</span>
        </div>
      </div>

      <div className="toolbar">
        <div className="tabs">
          {[["active", "Active"], ["backordered", "Backordered"], ["history", "History"], ["all", "All"]].map(([k, l]) => (
            <button key={k} className={"tab" + (filter === k ? " active" : "")} onClick={() => setFilter(k)}>
              {l} <span className="tab-count">{counts[k]}</span>
            </button>
          ))}
        </div>
        <div className="toolbar-actions">
          <Button variant="ghost" size="sm" icon="clock" onClick={() => setOldestFirst((v) => !v)}>
            {oldestFirst ? "Oldest first" : "Newest first"}
          </Button>
          <Button variant={byVendor ? "accent" : "ghost"} size="sm" icon="package"
            title="Group by vendor — the order in which replacements get placed"
            onClick={() => setByVendor((v) => !v)}>
            By vendor
          </Button>
        </div>
      </div>

      {rows.length === 0 ? (
        <Empty title="Nothing here" sub="No exchange requests match this filter." />
      ) : (
        <div className="ex-table">
          {/* #82: one shape for both views — the flat list is a single
              headerless group, so the row markup exists exactly once. */}
          {(byVendor ? GLOGIC.groupExchangesByVendor(state, rows) : [{ vendor: null, rows }]).map((g) => (
          <React.Fragment key={g.vendor == null ? "flat" : g.vendor || "no-vendor"}>
          {g.vendor != null && (
            <div className="ex-vendor-head">
              {g.vendor || "No vendor"} <span className="ex-vendor-count">{g.rows.length}</span>
            </div>
          )}
          {g.rows.map((e) => (
            <div key={e.id} className={"ex-row ex-" + e.status}>
              <div className="ex-status"><span className={"status-bar sb-" + e.status} /></div>
              <div className="ex-cell ex-who">
                <div className="q-dancer">{e.dancerName}</div>
                <div className="q-route">{e.routineLabel}</div>
              </div>
              <div className="ex-cell">
                <div className="q-piece">{e.pieceName}</div>
                <div className="mono q-unit">{e.unitId}</div>
              </div>
              <div className="ex-cell ex-size">
                <span className="mono">{e.originalSize}</span>
                <Icon name="chevronRight" size={13} className="muted" />
                <span className="mono strong">{e.requestedSize}</span>
              </div>
              <div className="ex-cell ex-reason">
                <div>{e.reason}</div>
                {e.notes && <div className="ex-notes">{e.notes}</div>}
              </div>
              <div className="ex-cell ex-age">
                {e.status === "resolved"
                  ? <Badge tone="muted"><Icon name="check" size={11} /> {fmtDate(e.resolvedDate)}</Badge>
                  : <><AgeTag opened={e.opened} today={state.today} overdue={overdueDays} /><div className="ex-opened">opened {fmtDate(e.opened)}</div></>}
              </div>
              <div className="ex-cell ex-actions">
                {e.status === "resolved" ? (
                  <span className="resolved-from">
                    <span className={"via-tag via-" + (e.resolvedVia || "inventory")}>
                      {e.resolvedVia === "reorder" ? "Re-ordered" : "From inventory"}
                    </span>
                    <span className="mono resolved-unit">→ {e.replacementUnit}</span>
                  </span>
                ) : (
                  <>
                    <select className="mini-select" value={e.status} onChange={(ev) => actions.setExchangeStatus(e.id, ev.target.value)}>
                      <option value="new">New</option>
                      <option value="ordered">Ordered</option>
                      <option value="backordered">Backordered</option>
                    </select>
                    <Button variant="accent" size="sm" icon="package" onClick={() => setResolving(e)}>Resolve</Button>
                  </>
                )}
              </div>
            </div>
          ))}
          </React.Fragment>
          ))}
        </div>
      )}

      {resolving && <ResolveModal state={state} exchange={resolving} onClose={() => setResolving(null)} actions={actions} />}
    </div>
  );
}

// #52 (ADR-0010): the landing-spot control shared by the two modal re-entry
// points (alteration-Returned, exchange resolve); Receiving keeps its inline
// bin field, fed by the same suggester. The suggestion is advisory — it
// pre-selects, staff override freely; the app suggests WHETHER
// delivery-or-storage but never guesses a class's day, so a delivery
// suggestion still asks which bin.
function LandingSpotPicker({ suggestion, value, onChange }) {
  const [storageMode, setStorageMode] = React.useState(suggestion.kind === "storage");
  return (
    <>
      <Field label="Landing spot" hint={suggestion.kind === "delivery"
        ? "This routine has rolled out — a delivery bin gets it to the dancer's next class; the desk sweeps the bin after class."
        : "Not rolled out yet — back to storage with its class."}>
        <select className="inp" value={storageMode ? "__storage" : (value || "")}
          onChange={(e) => {
            if (e.target.value === "__storage") {
              setStorageMode(true);
              onChange(suggestion.bin); // "" when the suggestion was delivery
            } else {
              setStorageMode(false);
              onChange(e.target.value);
            }
          }}>
          {!storageMode && !value && <option value="">Pick a delivery bin…</option>}
          {Reconcile.DELIVERY_BINS.map((b) => <option key={b} value={b}>{b}</option>)}
          <option value="__storage">Storage bin…</option>
        </select>
      </Field>
      {storageMode && (
        <Field label="Storage bin">
          <input className="inp" value={value} onChange={(e) => onChange(e.target.value)} placeholder="e.g. Bin A3" />
        </Field>
      )}
    </>
  );
}

// #52: marking an alteration Returned asks where the garment lands — the #42
// fix. The route write runs FIRST (it can refuse on a stale unit); only then
// does the status write follow, so a refusal leaves the card untouched.
function ArrivalRouteModal({ state, alteration, onClose, actions }) {
  const suggestion = Reconcile.suggestLandingSpot({ routineId: alteration.routineId, liveUnits: state.units });
  const [bin, setBin] = React.useState(suggestion.bin); // "" when delivery — staff pick the day's bin
  const [error, setError] = React.useState(null);
  function confirm() {
    setError(null);
    try {
      actions.routeArrival({ unitId: alteration.unitId, bin });
      actions.setAlteration(alteration.id, {
        status: "returned", returned: true,
        sent: !alteration.sent ? state.today : alteration.sent,
      });
      onClose();
    } catch (e) {
      setError(e.message);
    }
  }
  return (
    <Modal title="Back from the seamstress — where does it land?" onClose={onClose}
      footer={<>
        <Button variant="ghost" onClick={onClose}>Cancel</Button>
        <Button variant="accent" icon="checkCircle" disabled={!String(bin || "").trim()} onClick={confirm}>Mark returned &amp; route</Button>
      </>}>
      <p className="modal-note">
        <span className="mono">{alteration.unitId}</span> ({alteration.pieceName}) for <b>{alteration.dancerName}</b> is
        back at the desk. Give it a landing spot so it never sits in limbo.
      </p>
      <LandingSpotPicker suggestion={suggestion} value={bin} onChange={setBin} />
      {error && <div style={{ marginTop: 8, color: "var(--bad-text)", fontSize: 13 }}>{error}</div>}
    </Modal>
  );
}

function ResolveModal({ state, exchange, onClose, actions }) {
  // Matching on size alone could hand a dancer a spare from a totally different
  // routine/piece that happens to share a size label (SHEETS_SPEC.md §10 finding 2).
  const matches = state.spares.filter(
    (s) => s.routineId === exchange.routineId && s.pieceCode === exchange.pieceCode && s.size === exchange.needSize
  );
  const [unit, setUnit] = React.useState(matches[0] ? matches[0].unitId : "");
  const [manual, setManual] = React.useState("");
  // #52: the replacement in hand is an arrival — it gets a landing spot too.
  const suggestion = Reconcile.suggestLandingSpot({ routineId: exchange.routineId, liveUnits: state.units });
  const [bin, setBin] = React.useState(suggestion.bin); // "" when delivery — staff pick the day's bin
  const chosen = unit === "__manual" ? manual : unit;
  const via = unit === "__manual" ? "reorder" : "inventory";
  // #91: the resolve checks the Sheet first; a refusal stays in the modal
  // with the picks intact, and the spare list refreshes behind it.
  const [error, setError] = React.useState(null);
  const [checking, setChecking] = React.useState(false);
  // A refused spare drops out of `matches` on the refresh; move the picker off
  // it so a second tap can't resend the same refused unit.
  const matchKey = matches.map((m) => m.unitId).join(",");
  React.useEffect(() => {
    if (unit && unit !== "__manual" && !matches.some((m) => m.unitId === unit)) setUnit(matches[0] ? matches[0].unitId : "");
  }, [matchKey]);
  async function resolve() {
    setError(null);
    setChecking(true);
    try {
      await actions.resolveExchange(exchange.id, chosen || "—", via, bin);
      onClose();
    } catch (e) {
      // Deliberate refusals read as written; anything else is a bug, kept
      // out of the modal's words and sent to the console (#39).
      if (!e.refusal) console.error("Resolve failed:", e);
      setError(e.refusal ? e.message : "Something went wrong and this wasn't saved. Try again, then tell Denise.");
      setChecking(false);
    }
  }
  // While checking, the resolve can still write: no closing the modal under it.
  const close = checking ? () => {} : onClose;
  return (
    <Modal title="Resolve from inventory" onClose={close}
      footer={<><Button variant="ghost" disabled={checking} onClick={close}>Cancel</Button><Button variant="accent" icon="checkCircle" disabled={checking || !chosen || !String(bin || "").trim()} onClick={resolve}>{checking ? "Checking…" : <>Assign &amp; resolve</>}</Button></>}>
      <div className="resolve-summary">
        <div><span className="rk">Dancer</span>{exchange.dancerName}</div>
        <div><span className="rk">Piece</span>{exchange.pieceName}</div>
        <div><span className="rk">Need size</span><span className="mono strong">{exchange.needSize}</span></div>
      </div>
      <Field label="Replacement unit" hint="Assigning marks the original resolved and frees the old unit back to spares — the landing spot below says where the replacement goes.">
        <select className="inp mono" value={unit} onChange={(e) => setUnit(e.target.value)}>
          {matches.length === 0 && <option value="">No matching spare in stock</option>}
          {matches.map((m) => <option key={m.unitId} value={m.unitId}>{[m.unitId, m.piece, m.location].filter(Boolean).join(" · ")}</option>)}
          <option value="__manual">Enter unit manually…</option>
        </select>
      </Field>
      {unit === "__manual" && <Field label="Unit ID"><input className="inp mono" autoFocus value={manual} onChange={(e) => setManual(e.target.value)} placeholder="e.g. VOLT-LEO-12" /></Field>}
      <LandingSpotPicker suggestion={suggestion} value={bin} onChange={setBin} />
      {error && <div role="alert" style={{ marginTop: 8, color: "var(--bad-text)", fontSize: 13 }}>{error}</div>}
    </Modal>
  );
}

// Gravity's current seamstresses (Denise, July 12, 2026). Kept UI-side only —
// the Sheet's Seamstress column stores plain text, so editing this list is a
// one-line change and old rows with other names still display via "Other".
const SEAMSTRESSES = ["Brenda", "Diana"];

// Brenda/Diana one tap away; "Other…" opens a text box (e.g. the outsourced
// production vendor). Commits on blur/Enter — one Sheets row per edit, and
// an unchanged value writes nothing.
function SeamstressField({ value, onCommit }) {
  const isListed = !value || SEAMSTRESSES.includes(value);
  const [typingOther, setTypingOther] = React.useState(false);
  if (isListed && !typingOther) {
    return (
      <select className="mini-select" value={value || ""}
        onChange={(e) => {
          if (e.target.value === "__other") { setTypingOther(true); return; }
          onCommit(e.target.value || null);
        }}>
        <option value="">Unassigned</option>
        {SEAMSTRESSES.map((s) => <option key={s} value={s}>{s}</option>)}
        <option value="__other">Other…</option>
      </select>
    );
  }
  return (
    <input className="mini-select" style={{ cursor: "text", width: 120 }}
      autoFocus={typingOther}
      defaultValue={isListed ? "" : value} placeholder="Name / vendor"
      onBlur={(e) => {
        const v = e.target.value.trim();
        if (v !== (value || "")) onCommit(v || null);
        if (!v) setTypingOther(false);
      }}
      onKeyDown={(e) => { if (e.key === "Enter") e.target.blur(); }} />
  );
}

// =================================================== Alterations queue
function AlterationsQueue({ state, actions }) {
  const [filter, setFilter] = React.useState("open");
  const [routing, setRouting] = React.useState(null); // alteration pending Returned + landing spot (#52)
  // #71: same predicate as the nav badge (see ExchangeQueue above).
  const open = GLOGIC.openAlterations(state);
  let rows = filter === "open" ? open.slice() : state.alterations.slice();
  rows.sort((a, b) => (a.status === "returned") - (b.status === "returned"));
  const openCount = open.length;

  return (
    <div className="page">
      <div className="page-head">
        <div>
          <h1 className="page-title">Alterations</h1>
          <p className="page-sub">Open work queue. Returned items drop off automatically.</p>
        </div>
      </div>
      <div className="toolbar">
        <div className="tabs">
          <button className={"tab" + (filter === "open" ? " active" : "")} onClick={() => setFilter("open")}>Open work <span className="tab-count">{openCount}</span></button>
          <button className={"tab" + (filter === "all" ? " active" : "")} onClick={() => setFilter("all")}>All <span className="tab-count">{state.alterations.length}</span></button>
        </div>
      </div>
      {rows.length === 0 ? <Empty icon="scissors" title="No open alterations" sub="The seamstress queue is clear." /> : (
        <div className="al-table">
          {rows.map((a) => (
            <div key={a.id} className={"al-row al-" + a.status}>
              <div className="al-cell al-who">
                <div className="q-dancer">{a.dancerName}</div>
                <div className="q-route">{a.routineLabel}</div>
              </div>
              <div className="al-cell">
                <div className="q-piece">{a.pieceName}</div>
                <div className="mono q-unit">{a.unitId}</div>
              </div>
              <div className="al-cell al-type">
                <div><Badge tone="info">{a.type}</Badge></div>
                {a.notes && <div className="ex-notes">{a.notes}</div>}
              </div>
              <div className="al-cell">
                <span className="cell-k">Seamstress</span>
                <SeamstressField value={a.seamstress || ""} onCommit={(v) => actions.setAlteration(a.id, { seamstress: v })} />
              </div>
              <div className="al-cell">
                <span className="cell-k">Sent</span>
                <span className="al-sent">{fmtDate(a.sent)}</span>
              </div>
              <div className="al-cell al-actions">
                <select className="mini-select" value={a.status} onChange={(e) => {
                  // #52 (the #42 fix): Returned needs a landing spot first —
                  // the modal writes both, so nothing drops off into limbo.
                  // An alteration with no live unit has nothing to route.
                  if (e.target.value === "returned" && state.units.some((u) => u.unitId === a.unitId)) {
                    setRouting(a);
                    return;
                  }
                  actions.setAlteration(a.id, { status: e.target.value, returned: e.target.value === "returned", sent: e.target.value !== "open" && !a.sent ? state.today : a.sent });
                }}>
                  <option value="open">Open</option>
                  <option value="in_progress">With seamstress</option>
                  <option value="returned">Returned</option>
                </select>
              </div>
            </div>
          ))}
        </div>
      )}

      {routing && <ArrivalRouteModal state={state} alteration={routing} actions={actions} onClose={() => setRouting(null)} />}
    </div>
  );
}

// =================================================== Add-on requests
// #53 (folds #31): marking an add-on Ordered mints its ON ORDER unit, so the
// package exists on the board from order day — Receiving badges it, check-in
// bins it, rollout hands it out. The suggested id continues the routine+piece
// tag sequence (ADR-0008); the mint runs FIRST so a refusal (duplicate id)
// leaves the add-on card untouched. "Skip" covers non-garment orders that
// never get a tag — the status still moves, nothing is blocked.
function MintAddonUnitModal({ state, addon, onClose, actions }) {
  const routine = state.routines.find((r) => r.id === addon.routineId);
  const pieces = routine ? routine.pieces : [];
  // #83: how many the real-world order is for — display only, minting still
  // tracks ONE unit (a whole-routine add-on arrives as a spare, #32 rule).
  const qtyNeeded = addon.dancerId ? null : GLOGIC.addonQtyNeeded(state, addon);
  const [pieceChoice, setPieceChoice] = React.useState(pieces[0] ? pieces[0].code : "__other");
  const [otherCode, setOtherCode] = React.useState("");
  const pieceCode = pieceChoice === "__other" ? otherCode.trim().toUpperCase() : pieceChoice;
  // One suggestion path for initializer, piece changes, and the Other blur —
  // and the blank-code guard lives in exactly one place (review #53).
  const suggestFor = (code) =>
    code ? Reconcile.suggestNextUnitId({ routineId: addon.routineId, pieceCode: code, liveUnits: state.units }) : "";
  const [unitId, setUnitId] = React.useState(() => suggestFor(pieces[0] ? pieces[0].code : ""));
  const [size, setSize] = React.useState("");
  const [error, setError] = React.useState(null);
  function pickPiece(choice) {
    setPieceChoice(choice);
    setUnitId(suggestFor(choice === "__other" ? "" : choice));
  }
  function confirm() {
    setError(null);
    try {
      // The add-on could have vanished in a poll (another device acted) —
      // refuse BEFORE minting, or the unit lands with the card still 'reviewed'.
      if (!state.addons.some((x) => x.id === addon.id)) {
        throw new Error("This add-on is no longer in the queue — another device may have handled it. Nothing was written.");
      }
      actions.mintAddonUnit({ unitId, routineId: addon.routineId, pieceCode, size, dancerId: addon.dancerId, addonId: addon.id });
      actions.setAddonStatus(addon.id, "ordered");
      onClose();
    } catch (e) {
      setError(e.message);
    }
  }
  function skip() {
    setError(null);
    try {
      actions.setAddonStatus(addon.id, "ordered");
      onClose();
    } catch (e) {
      setError(e.message);
    }
  }
  return (
    <Modal title="Ordered — give the new unit its tag" onClose={onClose}
      footer={<>
        <Button variant="ghost" onClick={onClose}>Cancel</Button>
        <Button variant="ghost" onClick={skip}>Skip — no unit to track</Button>
        <Button variant="accent" icon="check" disabled={!unitId.trim() || !pieceCode || !size.trim()} onClick={confirm}>Mint unit &amp; mark ordered</Button>
      </>}>
      <p className="modal-note">
        <b>{addon.item}</b> for {addon.dancerName || "the whole routine"} ({addon.routineLabel}
        {qtyNeeded > 0 ? ` · ${qtyNeeded} ${qtyNeeded === 1 ? "dancer" : "dancers"}` : ""}). Minting puts it
        ON ORDER so Receiving expects the package{addon.dancerName ? "" : " — it arrives as a spare"}.
      </p>
      <Field label="Piece">
        <select className="inp" value={pieceChoice} onChange={(e) => pickPiece(e.target.value)}>
          {pieces.map((p) => <option key={p.code} value={p.code}>{p.name} ({p.code})</option>)}
          <option value="__other">Other piece code…</option>
        </select>
      </Field>
      {pieceChoice === "__other" && (
        <Field label="Piece code"><input className="inp mono" value={otherCode}
          onChange={(e) => setOtherCode(e.target.value)}
          onBlur={() => { if (!unitId.trim()) setUnitId(suggestFor(otherCode.trim().toUpperCase())); }}
          placeholder="e.g. GLV" /></Field>
      )}
      <Field label="Unit ID" hint="Suggested: the next tag in this routine+piece sequence. Correct it if the printed tags say otherwise.">
        <input className="inp mono" value={unitId} onChange={(e) => setUnitId(e.target.value)} placeholder="e.g. PST-GLV-01" />
      </Field>
      <Field label="Size"><input className="inp mono" value={size} onChange={(e) => setSize(e.target.value)} placeholder="vendor scale, or OS" /></Field>
      {error && <div style={{ marginTop: 8, color: "var(--bad-text)", fontSize: 13 }}>{error}</div>}
    </Modal>
  );
}

function AddonsQueue({ state, actions }) {
  const next = { requested: "reviewed", reviewed: "ordered" };
  const label = { requested: "Mark reviewed", reviewed: "Mark ordered" };
  const tone = { requested: "warn", reviewed: "info", ordered: "good", cancelled: "muted" };
  const [ordering, setOrdering] = React.useState(null); // add-on pending its minted unit (#53)
  // #65: the fourth queue finally gets the tabs the other three have. Counts
  // come from the same GLOGIC predicates as the nav badge — the screen used to
  // render every add-on ever created, so the badge read 0 while dozens of
  // ordered cards sat on the board.
  const [filter, setFilter] = React.useState("pending");
  // #30: a mistaken request gets cancelled in-app, never hand-deleted from
  // the Sheet. Cancelled leaves Pending and the badge but keeps its own tab.
  const [cancelling, setCancelling] = React.useState(null);
  // Refusals from the card buttons (rare poll races) — inline text, the same
  // idiom as every other refusal site; alert()/prompt() can throw in
  // embedded webviews (the getStaffLabel lesson).
  const [actionError, setActionError] = React.useState(null);
  const pending = GLOGIC.pendingAddons(state);
  const ordered = GLOGIC.orderedAddons(state);
  const cancelled = GLOGIC.cancelledAddons(state);
  const counts = { pending: pending.length, ordered: ordered.length, cancelled: cancelled.length, all: state.addons.length };
  const rows = filter === "pending" ? pending : filter === "ordered" ? ordered : filter === "cancelled" ? cancelled : state.addons;
  return (
    <div className="page">
      <div className="page-head">
        <div>
          <h1 className="page-title">Add-on requests</h1>
          <p className="page-sub">Teacher-flagged extras waiting to be reviewed and ordered.</p>
        </div>
      </div>
      {actionError && <div style={{ marginBottom: 10, color: "var(--bad-text)", fontSize: 13 }}>{actionError}</div>}
      <div className="toolbar">
        <div className="tabs">
          {[["pending", "Pending"], ["ordered", "Ordered"], ["cancelled", "Cancelled"], ["all", "All"]].map(([k, l]) => (
            <button key={k} className={"tab" + (filter === k ? " active" : "")} onClick={() => setFilter(k)}>
              {l} <span className="tab-count">{counts[k]}</span>
            </button>
          ))}
        </div>
      </div>
      {rows.length === 0 ? (
        <Empty icon="sparkles"
          title={filter === "ordered" ? "Nothing ordered yet" : filter === "cancelled" ? "Nothing cancelled" : filter === "all" ? "No add-on requests" : "No add-ons waiting"}
          sub={filter === "ordered"
            ? "Add-ons show up here once they're marked ordered."
            : filter === "cancelled"
            ? "Cancelled requests keep their history here instead of vanishing."
            : filter === "all"
            ? "Request one from a dancer's page or a routine — it lands here."
            : "Every request has been ordered or handled. New ones land here."} />
      ) : (
      <div className="addon-grid">
        {rows.map((a) => {
          // #83 (review pass): computed once per card, gated on dancerId —
          // the same field addonQtyNeeded branches on, so the gate and the
          // count can never drift apart.
          const qty = a.dancerId ? null : GLOGIC.addonQtyNeeded(state, a);
          const hasRoster = state.assignments.some((x) => x.routineId === a.routineId);
          return (
          <Card key={a.id} className="addon-card">
            <div className="addon-top">
              <div className="addon-icon"><Icon name="sparkles" size={18} /></div>
              <Badge tone={tone[a.status]} dot>{a.status[0].toUpperCase() + a.status.slice(1)}</Badge>
            </div>
            <div className="addon-item">{a.item}</div>
            <div className="addon-cat">{a.category}</div>
            <div className="addon-meta">
              {a.dancerName && <div><span className="rk">Dancer</span>{a.dancerName}</div>}
              <div><span className="rk">Routine</span>{a.routineLabel}</div>
              {/* #83: whole-routine add-ons get ordered one per dancer, and the
                  meeting demo showed no quantity anywhere. Hidden only while the
                  routine has no roster rows at all (import pending) — but a
                  rostered routine whose dancers ALL dropped shows 0 plainly,
                  because "none needed anymore" is exactly the ordering info. */}
              {qty != null && hasRoster &&
                <div><span className="rk">Qty</span>{qty > 0 ? `${qty} · one per dancer` : "0 · every dancer dropped"}</div>}
              {a.notes && <div><span className="rk">Notes</span>{a.notes}</div>}
              <div><span className="rk">By</span>{a.requestedBy} · {fmtDate(a.date)}</div>
            </div>
            {next[a.status] && (
              <Button variant="accent" size="sm" icon="check"
                onClick={() => {
                  if (next[a.status] === "ordered") { setOrdering(a); return; }
                  setActionError(null);
                  try { actions.setAddonStatus(a.id, next[a.status]); }
                  catch (e) { setActionError(e.message); }
                }}
                style={{ marginTop: 12, width: "100%" }}>
                {label[a.status]}
              </Button>
            )}
            {a.status !== "cancelled" && (
              <Button variant="ghost" size="sm" icon="x"
                onClick={() => setCancelling(a)}
                style={{ marginTop: next[a.status] ? 6 : 12, width: "100%" }}>
                Cancel request
              </Button>
            )}
          </Card>
          );
        })}
      </div>
      )}

      {ordering && <MintAddonUnitModal state={state} addon={ordering} actions={actions} onClose={() => setOrdering(null)} />}
      {cancelling && <CancelAddonModal state={state} addon={cancelling} actions={actions} onClose={() => setCancelling(null)} />}
    </div>
  );
}

// #30: the cancel confirm. Same append-only rules as everything else: the
// row supersedes the request, and if a unit was already minted for it, the
// unit is NAMED and flagged, never touched — closing it out is the Units
// board's job (ADR-0007's never-auto-close rule, same as cleared decisions).
function CancelAddonModal({ state, addon, actions, onClose }) {
  const [note, setNote] = React.useState("");
  const [error, setError] = React.useState(null);
  // Named via the mint's Notes marker. Pre-#30 mints carry the bare note and
  // can't be linked — the ordered fallback below stays honest about that.
  const mintedUnit = Reconcile.findAddonUnit({ addonId: addon.id, liveUnits: state.units });
  function confirm() {
    setError(null);
    try {
      actions.cancelAddon(addon.id, note);
      onClose();
    } catch (e) {
      setError(e.message);
    }
  }
  return (
    <Modal title="Cancel this add-on request?" onClose={onClose}
      footer={<>
        <Button variant="ghost" onClick={onClose}>Keep request</Button>
        <Button variant="danger" icon="x" onClick={confirm}>Cancel request</Button>
      </>}>
      <p className="modal-note">
        <b>{addon.item}</b> for {addon.dancerName || "the whole routine"} ({addon.routineLabel}).
        This writes a cancelled row on the ledger — the history stays on the Cancelled tab.
      </p>
      {addon.status === "ordered" && (
        <p className="modal-note" style={{ color: "var(--warn-text)" }}>
          {mintedUnit
            ? <>This order already minted <b className="mono">{mintedUnit.unitId}</b> ({mintedUnit.location || "no location"}). Cancelling flags the request only — settle that unit on the Units board (or refuse the box in Receiving).</>
            : <>This add-on was already marked ordered. If a unit was minted for it, cancel doesn't touch it — check Receiving for the expected package.</>}
        </p>
      )}
      <Field label="Why (optional)">
        <input className="inp" value={note} placeholder="e.g. duplicate request, teacher withdrew it"
          onChange={(e) => setNote(e.target.value)} />
      </Field>
      {error && <div style={{ marginTop: 8, color: "var(--bad-text)", fontSize: 13 }}>{error}</div>}
    </Modal>
  );
}

// =================================================== Drops queue (issue #7)
// The fourth queue (ADR-0007): one card per drop action, headed by the drop.
// Staff picks the affected routines (never derived from Team), then decides
// keep/release per unit; in-flight work is flagged, never auto-closed. A card
// is resolved when every line is decided.
function DropQueue({ state, actions, dropsTabMissing }) {
  const [filter, setFilter] = React.useState("open");
  const cards = state.drops.cards;
  const openCards = GLOGIC.openDropCards(state); // same predicate as the nav badge
  const resolvedCards = cards.filter((c) => c.active && c.status === "resolved");
  const counts = { open: openCards.length, resolved: resolvedCards.length, all: cards.length };
  const rows = filter === "open" ? openCards : filter === "resolved" ? resolvedCards : cards;

  return (
    <div className="page">
      <div className="page-head">
        <div>
          <h1 className="page-title">Drops</h1>
          <p className="page-sub">Per-class drop events, decided on cards. A card stays open until every costume and flagged item is decided.</p>
        </div>
      </div>
      <div className="toolbar">
        <div className="tabs">
          {[["open", "Open"], ["resolved", "Resolved"], ["all", "All"]].map(([k, l]) => (
            <button key={k} className={"tab" + (filter === k ? " active" : "")} onClick={() => setFilter(k)}>
              {l} <span className="tab-count">{counts[k]}</span>
            </button>
          ))}
        </div>
      </div>
      {rows.length === 0 ? (
        <Empty icon="userX" title="No drop cards"
          sub={dropsTabMissing
            ? "Drops are disabled until the Sheet has its “Drops” tab (see the banner above)."
            : "Drop a dancer from a class on their try-on page — the card lands here."} />
      ) : (
        <div className="ex-table">
          {rows.map((c) => <DropCard key={c.id} state={state} card={c} actions={actions} />)}
        </div>
      )}
    </div>
  );
}

function DropCard({ state, card, actions }) {
  const [editingPick, setEditingPick] = React.useState(!card.routinesPicked);
  const [sel, setSel] = React.useState(() => new Set(card.routineIds));
  const [releasing, setReleasing] = React.useState(null); // unit line pending confirm

  // The pick list = routines this dancer is actually assigned in. Team is
  // never consulted (ADR-0007: a mistyped Team must not free the wrong
  // kid's costume) — staff confirms from this list by hand.
  const routineOptions = [];
  const seen = new Set();
  state.assignments.forEach((a) => {
    if (a.dancerId !== card.dancerId || seen.has(a.routineId)) return;
    seen.add(a.routineId);
    const r = state.routines.find((x) => x.id === a.routineId);
    if (r) routineOptions.push(r);
  });

  const droppedClasses = card.classLines.filter((l) => l.dropped);
  const restoredClasses = card.classLines.filter((l) => !l.dropped);

  function toggleSel(id) {
    setSel((prev) => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id); else next.add(id);
      return next;
    });
  }
  function savePick() {
    actions.pickDropRoutines(card, routineOptions.map((r) => r.id).filter((id) => sel.has(id)));
    setEditingPick(false);
  }

  return (
    <Card className={"drop-card" + (card.active ? "" : " dropped-gray")}>
      <div className="drop-head">
        <div>
          <div className="drop-title"><Icon name="userX" size={16} /> {card.dancerName}</div>
          <div className="drop-sub">
            Dropped {droppedClasses.map((l) => l.className).join(", ") || "—"} · {fmtDate(card.date)} · by {card.updatedBy}
            {restoredClasses.length > 0 && <> · restored: {restoredClasses.map((l) => l.className).join(", ")}</>}
          </div>
        </div>
        {card.active
          ? (card.status === "resolved"
            ? <Badge tone="good" dot>Resolved</Badge>
            : <Badge tone="warn" dot>{card.undecided > 0 ? `${card.undecided} to decide` : "Pick routines"}</Badge>)
          : <Badge tone="muted">Walked back</Badge>}
      </div>
      {card.note && <div className="drop-note">“{card.note}”</div>}

      {droppedClasses.length > 0 && card.active && (
        <div className="drop-section">
          <div className="drop-section-title">Classes on this card</div>
          {card.classLines.map((l) => (
            <div key={l.className} className={"drop-line" + (l.dropped ? "" : " dropped-gray")}>
              <div className="drop-line-main">{l.className}{!l.dropped && " (restored)"}</div>
              {l.dropped && (
                <Button variant="ghost" size="sm" onClick={() => actions.undropClass(card.dancerId, l.className)}>Restore</Button>
              )}
            </div>
          ))}
        </div>
      )}

      <div className="drop-section">
        <div className="drop-section-title">Affected routines — picked by staff, never guessed from Team</div>
        {editingPick ? (
          <div className="drop-pick">
            {routineOptions.length === 0 && <p className="modal-note">This dancer has no costumed routines — save the empty pick to resolve the card.</p>}
            {routineOptions.map((r) => (
              <label key={r.id}>
                <input type="checkbox" checked={sel.has(r.id)} onChange={() => toggleSel(r.id)} />
                {r.team} · {r.name}
              </label>
            ))}
            <div style={{ marginTop: 8 }}>
              <Button variant="accent" size="sm" icon="check" onClick={savePick}>Save affected routines</Button>
            </div>
          </div>
        ) : (
          <div className="drop-line">
            <div className="drop-line-main">
              {card.routineIds.length === 0
                ? "None — no costumed routines affected"
                : card.routineIds.map((id) => {
                    const r = state.routines.find((x) => x.id === id);
                    return r ? `${r.team} · ${r.name}` : id;
                  }).join(" · ")}
            </div>
            <Button variant="ghost" size="sm" onClick={() => { setSel(new Set(card.routineIds)); setEditingPick(true); }}>Edit</Button>
          </div>
        )}
      </div>

      {card.unitLines.length > 0 && (
        <div className="drop-section">
          <div className="drop-section-title">Costume units — keep (family paid) or release (back to spares)</div>
          {card.unitLines.map((l) => (
            <div key={l.unitId} className="drop-line">
              <div className="drop-line-main">
                <span className="mono">{l.unitId}</span> · {l.pieceCode} · <span className="mono">{l.size}</span>
                <div className="drop-line-sub">{l.routineLabel}</div>
              </div>
              <div className="drop-line-actions">
                {l.decision === null && <>
                  <Button variant="ghost" size="sm" icon="bag" onClick={() => actions.decideDropUnit(card, l.unitId, "keep")}>Keep</Button>
                  <Button variant="accent" size="sm" icon="package" onClick={() => setReleasing(l)}>Release</Button>
                </>}
                {l.decision === "keep" && <>
                  <Badge tone="info">Kept by family</Badge>
                  <Button variant="ghost" size="sm" onClick={() => actions.decideDropUnit(card, l.unitId, "undecided")}>Undo</Button>
                </>}
                {l.decision === "release" && <Badge tone="good">Released to spares</Badge>}
              </div>
            </div>
          ))}
        </div>
      )}

      {card.flagLines.length > 0 && (
        <div className="drop-section">
          <div className="drop-section-title">In-flight work flagged by this drop — never closed automatically</div>
          {card.flagLines.map((l) => (
            <div key={l.kind + l.refId} className="drop-line">
              <Icon name={l.kind === "exchange" ? "repeat" : l.kind === "alteration" ? "scissors" : "sparkles"} size={14} className="muted" />
              <div className="drop-line-main">
                {l.label} <span className="drop-line-sub">({l.kind} · {l.status})</span>
                <div className="drop-line-sub">{l.routineLabel}</div>
              </div>
              <div className="drop-line-actions">
                {l.handled
                  ? <><Badge tone="good">Handled</Badge><Button variant="ghost" size="sm" onClick={() => actions.setDropFlagHandled(card, l, false)}>Unmark</Button></>
                  : <Button variant="ghost" size="sm" icon="check" onClick={() => actions.setDropFlagHandled(card, l, true)}>Mark handled</Button>}
              </div>
            </div>
          ))}
        </div>
      )}

      {releasing && (
        <Modal title="Release unit to spares?" onClose={() => setReleasing(null)}
          footer={<>
            <Button variant="ghost" onClick={() => setReleasing(null)}>Cancel</Button>
            <Button variant="accent" icon="package" onClick={() => { actions.decideDropUnit(card, releasing.unitId, "release"); setReleasing(null); }}>Release to spares</Button>
          </>}>
          <p className="modal-note">
            <span className="mono">{releasing.unitId}</span> ({releasing.pieceCode}, size <span className="mono">{releasing.size}</span>) goes
            back to available inventory and can be assigned to another dancer right away — releasing can't be undone from this card.
            If the family paid enough to keep it, choose <b>Keep</b> instead.
          </p>
        </Modal>
      )}
    </Card>
  );
}

Object.assign(window, { ExchangeQueue, AlterationsQueue, AddonsQueue, DropQueue });
