/* Gravity CMS — Try-On workflow views: routine grid → dancer list → piece decisions. */

function ReadinessBadge({ code }) {
  const m = GLOGIC.STATUS_META[code];
  return <Badge tone={m.tone} dot>{m.label}</Badge>;
}

// ---------------------------------------------------------------- Home
// #74: one dense row per routine instead of a card each. Gravity imports ~50
// routines a season; the card grid was sized for the 2 in demo data and turned
// a scan for "which routine is behind" into ~25 phone screens of scrolling.
// The team lives on the section header (GLOGIC.groupRoutinesByTeam), so the
// row itself stays one line — repeating the team under every name inside its
// own team section would spend exactly the height this change is buying.
function RoutineRow({ state, routine, team, nav }) {
  const s = GLOGIC.routineStats(state, routine);
  const tone = GLOGIC.routineTone(s);
  // #77: under a PETITE SURGE header, "Petite Surge Tap" spends two of its
  // three words repeating the header. Conditional both ways — the prefix is a
  // naming convention, not a field, so a name that doesn't follow it (or that
  // IS the team name) stays whole.
  const label = GLOGIC.routineLabelInTeam(routine.name, team);
  // #78: routine names usually end with their style, so the strip above left
  // "Tap" sitting beside "Tap · 6 pieces". Blank when the label already says
  // it; the piece count carries the cell on its own.
  const style = GLOGIC.routineStyleForRow(routine.style, label);
  return (
    // A real <button>, not a clickable div: keyboard and screen readers reach
    // it for free, and #66 (which covers the rest of the app's clickable divs)
    // has one less screen to fix.
    <button
      type="button" className="rt-row"
      onClick={() => nav({ view: "routine", routineId: routine.id })}
      // The header supplies the team visually, but nothing ties the <h2> to
      // this button for a screen reader, so the accessible name keeps the
      // whole routine name. Sighted staff read "Tap"; a reader still hears
      // "Petite Surge Tap" and knows which routine it landed on.
      aria-label={label === routine.name ? undefined : routine.name}
    >
      <span className="rt-name">{label}</span>
      {/* Spelled out, not the mock's "6p": this cell sits one column left of
          "Dancers ready · 1/9", where a bare number reads as a dancer count. */}
      <span className="rt-type">
        <Icon name="shirt" size={12} /> {style ? `${style} · ` : ""}{routine.pieces.length} piece{routine.pieces.length === 1 ? "" : "s"}
      </span>
      <span className="rt-ready">
        <ProgressBar value={s.ready} total={s.total} tone={tone} height={5} />
        <span className="rt-ready-num">{s.ready}/{s.total}</span>
      </span>
      <span className="rt-flags">
        {s.openExchanges > 0 && <Badge tone="warn"><Icon name="repeat" size={12} /> {s.openExchanges}</Badge>}
        {s.openAlterations > 0 && <Badge tone="info"><Icon name="scissors" size={12} /> {s.openAlterations}</Badge>}
        {s.notStarted > 0 && <Badge tone="muted">{s.notStarted} to start</Badge>}
      </span>
      <Icon name="chevronRight" size={16} className="rt-chev" />
    </button>
  );
}

function TryOnHome({ state, nav }) {
  const groups = GLOGIC.groupRoutinesByTeam(state.routines);
  return (
    <div className="page">
      <div className="page-head">
        <div>
          <h1 className="page-title">Try-Ons</h1>
          <p className="page-sub">Work a routine top-to-bottom: dancer → assigned pieces → fit decision.</p>
        </div>
      </div>
      {groups.length === 0 ? (
        <Empty icon="shirt" title="No routines yet" sub="Routines come from the backend Routines tab." />
      ) : groups.map((g, gi) => (
        // A routine whose Team cell is blank still reaches this screen — it
        // groups last under a named section rather than vanishing.
        <section key={g.team || "__no_team"} className="rt-section">
          <h2 className="rt-team-head">
            {g.team || "No team set"}
            <span className="rt-team-count">{g.routines.length}</span>
          </h2>
          <Card className="rt-card">
            <div className="rt-rows">
              {/* Column legend once, on the first section only. Repeating it
                  per team spends ~34px a section — at Gravity's ~10 teams that
                  is most of a phone screen, which is the height this whole
                  layout exists to save. */}
              {gi === 0 && (
                <div className="rt-row rt-head" aria-hidden="true">
                  <span className="rt-name">Routine</span>
                  <span className="rt-type">Type</span>
                  <span className="rt-ready">Dancers ready</span>
                  <span className="rt-flags">Open issues</span>
                  <span />
                </div>
              )}
              {g.routines.map((r) => (
                <RoutineRow key={r.id} state={state} routine={r} team={g.team} nav={nav} />
              ))}
            </div>
          </Card>
        </section>
      ))}
    </div>
  );
}

// ------------------------------------------------------------ Routine
function RoutineDetail({ state, routineId, nav, goBack, backLabel, actions }) {
  const [swapOpen, setSwapOpen] = React.useState(false);
  const [addonOpen, setAddonOpen] = React.useState(false);
  const [reconcileOpen, setReconcileOpen] = React.useState(false);
  const routine = state.routines.find((r) => r.id === routineId);
  const s = GLOGIC.routineStats(state, routine);
  return (
    <div className="page">
      {/* #81: back to wherever this page was opened from (Manager, Try-Ons…); "All routines" only when there is no history. */}
      <button className="back" onClick={() => goBack({ view: "tryon" })}><Icon name="chevronLeft" size={16} /> {backLabel || "All routines"}</button>
      <div className="page-head">
        <div>
          <div className="routine-team">{routine.team} · {routine.style}</div>
          <h1 className="page-title">{routine.name}</h1>
          <p className="page-sub">{s.ready}/{s.total} dancers ready · {s.openExchanges} exchanges · {s.openAlterations} alterations open</p>
        </div>
        <div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
          <Button variant="ghost" icon="sparkles" onClick={() => setAddonOpen(true)}>Request add-on</Button>
          <Button variant="ghost" icon="swap" onClick={() => setSwapOpen(true)}>Swap pieces</Button>
          <Button variant="ghost" icon="package" onClick={() => setReconcileOpen(true)}>Units board</Button>
        </div>
      </div>
      <div className="dancer-list">
        {s.assigns.map((a) => {
          const st = GLOGIC.assignmentStatus(state, a);
          return (
            // #66: a real <button>, matching the .rt-row idiom #77 used for
            // the routine rows. Inner elements are spans, not divs — a button
            // may only contain phrasing content, and the divs were what made
            // this a clickable div in the first place.
            <button type="button" key={a.id} className="card dancer-row" onClick={() => nav({ view: "dancer", assignmentId: a.id })}>
              <span className="avatar"><Icon name="user" size={18} /></span>
              <span className="dancer-row-main">
                <span className="dancer-name">{a.dancerName}</span>
                <span className="dancer-sub">{st.decided}/{st.total} piece{st.total === 1 ? "" : "s"} decided
                  {st.issues.length > 0 && <span className="issue-inline"> · {st.issues.map((i) => DECISION_META[i.st.code].label).join(", ")}</span>}
                </span>
              </span>
              <ReadinessBadge code={st.code} />
              <Icon name="chevronRight" size={18} className="muted" />
            </button>
          );
        })}
        {/* Dropped dancers leave every count above but stay listed, grayed, at
            the bottom — gray-out, never delete (ADR-0007). Still tappable so
            their costume history stays reachable. */}
        {s.droppedAssigns.map((a) => (
          <button type="button" key={a.id} className="card dancer-row dropped-gray" onClick={() => nav({ view: "dancer", assignmentId: a.id })}>
            <span className="avatar"><Icon name="userX" size={18} /></span>
            <span className="dancer-row-main">
              <span className="dancer-name">{a.dancerName}</span>
              <span className="dancer-sub">Dropped — not counted in readiness; history kept</span>
            </span>
            <Badge tone="muted">Dropped</Badge>
            <Icon name="chevronRight" size={18} className="muted" />
          </button>
        ))}
      </div>
      {swapOpen && <SwapModal state={state} routine={routine} onClose={() => setSwapOpen(false)} actions={actions} />}
      {reconcileOpen && <ReconcileModal state={state} routine={routine} onClose={() => setReconcileOpen(false)} actions={actions} />}
      {addonOpen && (
        <AddonModal
          routineId={routine.id} routineLabel={routine.team + " · " + routine.name}
          onClose={() => setAddonOpen(false)} actions={actions}
        />
      )}
    </div>
  );
}

// --------------------------------------------------- Dancer try-on
function DancerTryOn({ state, assignmentId, nav, goBack, backLabel, actions, dropsDisabled }) {
  const [addonOpen, setAddonOpen] = React.useState(false);
  const a = state.assignments.find((x) => x.id === assignmentId);
  const routine = state.routines.find((r) => r.id === a.routineId);
  const st = GLOGIC.assignmentStatus(state, a);
  // #81: back is the browser's previous entry (Manager list, Welcome search,
  // the routine page — wherever this dancer was opened from). With no history
  // (fresh tab, direct link, or a refresh that lost the label) the routine
  // page is the fallback, Denise's pick, and the label says so.
  return (
    <div className="page">
      <button className="back" onClick={() => goBack({ view: "routine", routineId: routine.id })}><Icon name="chevronLeft" size={16} /> {backLabel || routine.name}</button>
      <div className="page-head">
        <div className="dancer-hero">
          <div className="avatar lg"><Icon name="user" size={24} /></div>
          <div>
            <h1 className="page-title">{a.dancerName}</h1>
            <p className="page-sub">{routine.team} · {routine.name} · {routine.style}</p>
          </div>
        </div>
        <div className="hero-right">
          <ReadinessBadge code={st.code} />
          <Button variant="ghost" size="sm" icon="sparkles" onClick={() => setAddonOpen(true)}>Request add-on</Button>
        </div>
      </div>

      <div className="piece-list">
        {a.pieces.map((p) => (
          <PieceCard key={p.id} state={state} assignment={a} piece={p} actions={actions} />
        ))}
      </div>

      <div className="tryon-footer">
        <div className="tryon-progress">
          <ProgressBar value={st.decided} total={st.total} tone={st.code === "ready" ? "good" : "accent"} />
          <span>{st.decided} of {st.total} decided</span>
        </div>
        {st.code === "ready" && <Badge tone="good" dot>Ready for bagging</Badge>}
        {st.code === "issue" && <Badge tone="warn" dot>{st.issues.length} follow-up{st.issues.length > 1 ? "s" : ""} in queue</Badge>}
      </div>

      <DancerClassesPanel state={state} dancerId={a.dancerId} dancerName={a.dancerName} actions={actions} disabled={dropsDisabled} />

      {addonOpen && (
        <AddonModal
          dancerId={a.dancerId} dancerName={a.dancerName}
          routineId={a.routineId} routineLabel={routine.team + " · " + routine.name}
          onClose={() => setAddonOpen(false)} actions={actions}
        />
      )}
    </div>
  );
}

// -------------------------------------------- classes & drops (issue #7)
// The drop entry point (ADR-0007): find the dancer, check "dropped" per class,
// or drop from all classes in one tap. Every drop opens a card in the Drops
// queue where keep/release and flagged in-flight work get decided.
function DancerClassesPanel({ state, dancerId, dancerName, actions, disabled }) {
  const [dropping, setDropping] = React.useState(null); // [classNames] pending confirm
  const [restoring, setRestoring] = React.useState(null); // className pending confirm
  const enrolled = state.drops.classesByDancer.get(dancerId);
  if (!enrolled || enrolled.size === 0) {
    // Don't disappear silently: pre-import (or for a hand-entered dancer)
    // there are no enrollments to check off, and that's why, not a bug.
    return (
      <Card style={{ padding: 17, marginTop: 18 }}>
        <div className="drop-section-title">Classes</div>
        <p className="modal-note" style={{ margin: 0 }}>
          No class enrollments imported for {dancerName} yet — per-class drops unlock once their roll sheet is imported (Import tab).
        </p>
      </Card>
    );
  }
  const dropped = state.drops.droppedByDancer.get(dancerId) || new Set();
  const classNames = Array.from(enrolled);
  const activeClasses = classNames.filter((c) => !dropped.has(c));

  return (
    <Card style={{ padding: 17, marginTop: 18 }}>
      <div className="drop-section-title">Classes · drops are per class, never a deletion</div>
      {disabled && <p className="modal-note">Drops are disabled until the Sheet has its “Drops” tab (see the banner above).</p>}
      {classNames.map((c) => {
        const isDropped = dropped.has(c);
        return (
          <div key={c} className={"class-check" + (isDropped ? " dropped-gray" : "")}>
            <Icon name={isDropped ? "userX" : "check"} size={14} className="muted" />
            <span style={{ flex: 1 }}>{c}</span>
            {isDropped ? (
              <>
                <Badge tone="muted">Dropped</Badge>
                <Button variant="ghost" size="sm" disabled={disabled} onClick={() => setRestoring(c)}>Restore</Button>
              </>
            ) : (
              <Button variant="ghost" size="sm" icon="userX" disabled={disabled} onClick={() => setDropping([c])}>Dropped</Button>
            )}
          </div>
        );
      })}
      {activeClasses.length > 1 && (
        <div style={{ marginTop: 10 }}>
          <Button variant="ghost" size="sm" icon="userX" disabled={disabled} onClick={() => setDropping(activeClasses)}>
            Drop from all classes
          </Button>
        </div>
      )}

      {dropping && (
        <ConfirmWithNoteModal
          title={dropping.length > 1 ? "Drop from all classes" : "Drop from class"}
          confirmLabel="Record drop" confirmIcon="userX" confirmVariant="warn"
          notePlaceholder="e.g. family moved, mid-season quit…"
          onClose={() => setDropping(null)}
          onConfirm={(note) => { actions.dropClasses(dancerId, dropping, note); setDropping(null); }}
        >
          <p className="modal-note">
            Records a drop event for <b>{dancerName}</b> — nothing is deleted, and costumes stay put.
            A drop card opens in the <b>Drops</b> queue, where you pick the affected routines and decide
            keep-vs-release for each costume piece.
          </p>
          <div className="resolve-summary">
            {dropping.map((c) => <div key={c}><span className="rk">Class</span>{c}</div>)}
          </div>
        </ConfirmWithNoteModal>
      )}
      {restoring && (
        <ConfirmWithNoteModal
          title="Restore enrollment?"
          confirmLabel="Restore" confirmIcon="check" confirmVariant="accent"
          notePlaceholder="e.g. dropped by mistake, family changed their mind…"
          onClose={() => setRestoring(null)}
          onConfirm={(note) => { actions.undropClass(dancerId, restoring, note); setRestoring(null); }}
        >
          <p className="modal-note">
            Marks {dancerName} active in “{restoring}” again (the drop stays in history as a counter-event).
            Costumes do <b>not</b> come back automatically — reassign released units through the normal spare flows.
          </p>
        </ConfirmWithNoteModal>
      )}
    </Card>
  );
}

// #71: DropModal and RestoreModal were the same modal twice — note state, the
// optional-note field, Cancel, and the wrapper were identical, and only the
// title, the confirm button and the explanatory body differed. One component
// now, with the copy passed in, because the two flows say genuinely different
// things and neither wording should be nudged toward the other.
//
// Both still take a free-text note: a drop and its undo are append-only
// events (ADR-0007) and the note is the only place the WHY is ever recorded.
function ConfirmWithNoteModal({ title, confirmLabel, confirmIcon, confirmVariant, notePlaceholder, onClose, onConfirm, children }) {
  const [note, setNote] = React.useState("");
  return (
    <Modal title={title} onClose={onClose}
      footer={<>
        <Button variant="ghost" onClick={onClose}>Cancel</Button>
        <Button variant={confirmVariant} icon={confirmIcon} onClick={() => onConfirm(note)}>{confirmLabel}</Button>
      </>}>
      {children}
      <Field label="Note (optional)">
        <input className="inp" value={note} placeholder={notePlaceholder} onChange={(e) => setNote(e.target.value)} />
      </Field>
    </Modal>
  );
}

// piece card with inline decision controls
// #75: the four full-width decision buttons become one segmented track, which
// is what takes the row from ~90px to ~54px on the screen staff live in during
// try-ons.
//
// Deliberately toggle buttons (aria-pressed) in a group, NOT role="radio".
// The ticket asks for "a radio group", but a radio group cannot express #38:
// tapping the lit segment un-decides the piece. That is toggle semantics, and
// role="radio" would promise assistive tech a selection that can never be
// switched off. Arrow keys still move focus across the track (roving
// tabindex) — they do not move the selection, because "selecting" here writes
// a row to the ledger and can open a form.
const DECISION_SEGMENTS = [
  { key: "fits", label: "Fits", icon: "check", tone: "good" },
  { key: "alter", label: "Alter", icon: "scissors", tone: "info" },
  { key: "exchange", label: "Exchange", icon: "repeat", tone: "warn" },
  { key: "missing", label: "Missing", icon: "flag", tone: "bad" },
];

function DecisionSegments({ active, onChoose, lockedBy, pieceName }) {
  const btns = React.useRef([]);
  function moveFocus(e, i) {
    const step = e.key === "ArrowRight" || e.key === "ArrowDown" ? 1
      : e.key === "ArrowLeft" || e.key === "ArrowUp" ? -1 : 0;
    if (!step) return;
    e.preventDefault();
    const next = (i + step + DECISION_SEGMENTS.length) % DECISION_SEGMENTS.length;
    if (btns.current[next]) btns.current[next].focus();
  }
  return (
    // Labelled per piece: a dancer has up to six of these, and a bare
    // "Fit decision" gives a screen-reader user six identical groups.
    <div className="seg" role="group" aria-label={"Fit decision — " + pieceName}>
      {DECISION_SEGMENTS.map((s, i) => {
        const on = active === s.key;
        return (
          <button
            key={s.key} type="button"
            ref={(el) => { btns.current[i] = el; }}
            className={"seg-btn seg-" + s.tone + (on ? " active" : "")}
            aria-pressed={on}
            // The track stays visible while an exchange/alteration form is
            // open (hiding it made the card jump), but the segments lock —
            // otherwise tapping Fits mid-form silently threw away the size
            // and notes already typed. Cancel is right there in the form.
            // All four, including the lit one: tapping it is #38's undo, which
            // would discard the open form just as silently as any other tap.
            disabled={!!lockedBy}
            title={lockedBy ? "Finish or cancel the " + lockedBy + " form first" : undefined}
            // Roving tabindex: one stop for the whole track, so tabbing down a
            // dancer's pieces doesn't cost four stops per piece.
            tabIndex={on || (!active && i === 0) ? 0 : -1}
            onKeyDown={(e) => moveFocus(e, i)}
            onClick={() => onChoose(s.key)}
          >
            <Icon name={s.icon} size={14} />
            <span>{s.label}</span>
          </button>
        );
      })}
    </div>
  );
}

function PieceCard({ state, assignment, piece, actions }) {
  const live = GLOGIC.pieceStatus(state, assignment, piece);
  const [form, setForm] = React.useState(null); // 'alter' | 'exchange' | null
  const [exReq, setExReq] = React.useState("");
  const [exReason, setExReason] = React.useState("Too small");
  const [exNotes, setExNotes] = React.useState("");
  const [alType, setAlType] = React.useState("Hem");
  const [alNotes, setAlNotes] = React.useState("");
  const [alError, setAlError] = React.useState(null);

  const decided = piece.decision;
  const openExchange = live.code === "exchange" && live.open;
  const openAlter = live.code === "alter" && live.open;

  // Which segment reads as lit, and whether a resolved pill still has to
  // speak. One tested rule (#75) rather than two copies of the same condition
  // in the JSX — "tap the lit segment to un-decide" has to match what the eye
  // sees, or a mis-tap undo turns into a fresh decision.
  const ctl = GLOGIC.pieceControlState(live);
  // #28: what the returned-from-alterations banner says. Null unless this
  // piece has a returned alteration, so the banner can't render on its own.
  // Who and when come from the alteration; where it landed is a units-ledger
  // question, so it comes from Reconcile with the rest of that vocabulary.
  const returned = GLOGIC.alterationReturnDetail(live.resolvedAl);
  const landing = live.resolvedAl
    ? Reconcile.describeReturnLanding({ unitId: live.resolvedAl.unitId, liveUnits: state.units })
    : null;
  function choose(decision) {
    if (ctl.active === decision) {
      // #38: tapping the already-recorded decision clears it back to
      // undecided. If it spawned still-open queue work, confirm first —
      // that work is flagged, never auto-closed (ADR-0007 rule).
      if (openExchange || openAlter) { setForm("clear"); return; }
      actions.decidePiece(assignment.id, piece.code, "cleared");
      setForm(null);
      return;
    }
    if (decision === "alter") { setForm("alter"); return; }
    if (decision === "exchange") { setForm("exchange"); return; }
    actions.decidePiece(assignment.id, piece.code, decision);
    setForm(null);
  }
  function saveExchange() {
    actions.decidePiece(assignment.id, piece.code, "exchange", { requestedSize: exReq || "—", reason: exReason, notes: exNotes });
    setForm(null);
  }
  function saveAlter() {
    // Notes are required on Other only (GLOGIC.alterationFormError) — the
    // refusal is inline text, never alert() (see CLAUDE.md).
    const err = GLOGIC.alterationFormError({ type: alType, notes: alNotes });
    if (err) { setAlError(err); return; }
    setAlError(null);
    actions.decidePiece(assignment.id, piece.code, "alter", { type: alType, notes: alNotes.trim() });
    setForm(null);
  }

  const meta = decided ? DECISION_META[live.code] || DECISION_META[decided] : null;
  return (
    <Card className={"piece-card" + (decided ? " decided tone-" + (meta ? meta.tone : "neutral") : "")}>
      <div className="piece-main">
        <div className="piece-id">
          {/* The resolved pill sits on the name line, not as a third item in
              .piece-main — there it added ~80px to the line's flex bases, so
              resolved pieces wrapped their track under the name earlier than
              their neighbours and stood taller for no reason. */}
          <div className="piece-name">
            {piece.name}
            {piece.grouped && <Pill style={{ marginLeft: 8 }}>grouped</Pill>}
            {/* #28: the pill's tone comes from pieceControlState, not from
                DECISION_META[live.code] — that is "fits" for BOTH resolved
                states, so a returned alteration wore a green pill above its
                blue banner. The card's left border stays green either way:
                the tint says the piece fits now, the pill and banner say how
                it got there. */}
            {ctl.resolvedLabel && <Badge tone={ctl.resolvedTone} dot style={{ marginLeft: 8 }}>{ctl.resolvedLabel}</Badge>}
          </div>
          {/* #73: buildAssignments leaves unitId null until a unit exists
              (useSheets.js — `unit ? unit.unitId : null`), and these separators
              used to render unconditionally, so an unreceived piece read
              "· Amazon · OS" with a leading orphan dot. Same bug class as #46,
              different site. Separators now belong to the part before them. */}
          <div className="piece-sub">
            {piece.unitId && (
              <React.Fragment>
                <span className="mono unit">{piece.unitId}</span>
                <span className="dot-sep">·</span>
              </React.Fragment>
            )}
            {piece.vendor && (
              <React.Fragment>
                {piece.vendor}
                <span className="dot-sep">·</span>
              </React.Fragment>
            )}
            <Icon name="ruler" size={12} /> <span className="mono">{piece.unitSize || piece.preselectedSize}</span>
          </div>
        </div>
        {/* #75 removed the status pill everywhere the control speaks for
            itself. It survives only on resolved states (rendered on the name
            line above), which light no segment — without it an
            altered-and-returned piece reads as untouched (its banner is #28). */}
        <DecisionSegments
          active={ctl.active} onChoose={choose} pieceName={piece.name}
          lockedBy={form === "exchange" ? "exchange" : form === "alter" ? "alteration" : null}
        />
      </div>

      {openExchange && (
        <div className="piece-detail">
          <Icon name="repeat" size={14} /> Exchange <span className="mono">{live.ex.originalSize}</span> → <span className="mono">{live.ex.requestedSize}</span> · {live.ex.reason}
          <span className="detail-status">{EXCHANGE_STATUS[live.ex.status].label}</span>
        </div>
      )}
      {openAlter && (
        <div className="piece-detail">
          <Icon name="scissors" size={14} /> {live.al.type}{live.al.notes ? " — " + live.al.notes : ""}
          <span className="detail-status">{live.al.status === "open" ? "In queue" : live.al.status === "in_progress" ? "With seamstress" : "Returned"}</span>
        </div>
      )}

      {!decided && (live.strandedEx || live.strandedAl) && (
        <div className="piece-detail">
          <Icon name="flag" size={14} /> Cleared to undecided — the open {live.strandedEx ? "exchange" : "alteration"} it started stays in its queue until resolved there.
        </div>
      )}

      {live.resolvedFromExchange && live.resolvedEx && (
        <div className="piece-detail resolved">
          <Icon name="checkCircle" size={14} />
          <span className="pd-text">Resolved {live.resolvedEx.resolvedVia === "reorder" ? "by re-order" : "from inventory"} · was <span className="mono">{live.resolvedEx.originalSize}</span>, now <span className="mono">{live.resolvedEx.needSize}</span></span>
          <span className="pd-unit mono">{live.resolvedEx.replacementUnit}</span>
        </div>
      )}

      {/* #28: the alteration half of the resolved banner above. Each part is
          conditional and owns the separator BEFORE it, so a return with no
          seamstress and no stamp reads "Returned from alterations" cleanly
          rather than trailing an orphan dot (#46/#73). The chip reads
          "TUESDAY BIN → HOME" because a delivery bin IS the trip home — it
          goes with the student at their next class, and the desk sweep that
          empties the bin is a presumption nobody records. */}
      {returned && (
        <div className="piece-detail returned">
          <Icon name="scissors" size={14} />
          <span className="pd-text">
            Returned from alterations
            {returned.seamstress && <React.Fragment> · {returned.seamstress}</React.Fragment>}
            {returned.returnedAt && <React.Fragment> · {fmtStamp(returned.returnedAt)}</React.Fragment>}
          </span>
          {landing && <span className="pd-unit mono">{landing.spot}{landing.goesHome ? " → HOME" : ""}</span>}
        </div>
      )}

      {form === "clear" && (
        <div className="inline-form">
          <p className="modal-note">
            Back to undecided? The open {openExchange ? "exchange" : "alteration"} this decision started <strong>stays in its queue</strong> — resolve it there, or re-decide here. Nothing is closed automatically.
          </p>
          <div className="inline-actions">
            <Button variant="ghost" size="sm" onClick={() => setForm(null)}>Keep decision</Button>
            <Button variant="warn" size="sm" icon="x" onClick={() => { actions.decidePiece(assignment.id, piece.code, "cleared"); setForm(null); }}>Clear to undecided</Button>
          </div>
        </div>
      )}
      {form === "exchange" && (
        <div className="inline-form">
          <div className="inline-grid">
            <Field label="Requested size"><input className="inp mono" value={exReq} placeholder={piece.preselectedSize} onChange={(e) => setExReq(e.target.value)} /></Field>
            <Field label="Reason">
              <select className="inp" value={exReason} onChange={(e) => setExReason(e.target.value)}>
                <option>Too small</option><option>Too big</option><option>Wrong style/color</option><option>Damaged</option>
              </select>
            </Field>
          </div>
          <Field label="Notes"><input className="inp" value={exNotes} placeholder="Optional" onChange={(e) => setExNotes(e.target.value)} /></Field>
          <div className="inline-actions">
            <Button variant="ghost" size="sm" onClick={() => setForm(null)}>Cancel</Button>
            <Button variant="warn" size="sm" icon="repeat" onClick={saveExchange}>Queue exchange</Button>
          </div>
        </div>
      )}
      {form === "alter" && (
        <div className="inline-form">
          <div className="inline-grid">
            <Field label="Alteration">
              <select className="inp" value={alType} onChange={(e) => { setAlType(e.target.value); setAlError(null); }}>
                {GLOGIC.ALTERATION_TYPES.map((t) => <option key={t}>{t}</option>)}
              </select>
            </Field>
            <Field label={alType === "Other" ? "Notes (required)" : "Notes"}>
              <input className="inp" value={alNotes} placeholder={alType === "Other" ? "What needs doing" : "Optional"}
                aria-invalid={alError ? "true" : undefined}
                onChange={(e) => { setAlNotes(e.target.value); if (alError) setAlError(null); }} />
            </Field>
          </div>
          {alError && <div role="alert" style={{ marginBottom: 8, color: "var(--bad-text)", fontSize: 13 }}>{alError}</div>}
          <div className="inline-actions">
            <Button variant="ghost" size="sm" onClick={() => { setForm(null); setAlError(null); }}>Cancel</Button>
            <Button variant="info" size="sm" icon="scissors" onClick={saveAlter}>Queue alteration</Button>
          </div>
        </div>
      )}

    </Card>
  );
}

// ----------------------------------------------- modals (add-on, swap)
// dancerId/dancerName are both null for a whole-routine request (the "Request
// add-on" button on RoutineDetail) — routineId is always required so createAddon
// can write a real FK instead of just denormalized display text (SHEETS_SPEC.md §10 finding 6).
function AddonModal({ dancerId = null, dancerName = null, routineId, routineLabel, onClose, actions }) {
  const [item, setItem] = React.useState("");
  const [category, setCategory] = React.useState("Custom add-on");
  const [notes, setNotes] = React.useState("");
  const [sent, setSent] = React.useState(false);
  function save() {
    if (sent) return; // #29: a double-click fired createAddon twice = duplicate rows
    setSent(true);
    actions.createAddon({ dancerId, dancerName, routineId, routineLabel, item: item || "Add-on", category, notes, requestedBy: "Instructor" });
    onClose();
  }
  return (
    <Modal title="Request add-on" onClose={onClose}
      footer={<><Button variant="ghost" onClick={onClose}>Cancel</Button><Button variant="accent" icon="sparkles" onClick={save}>Send to review</Button></>}>
      <p className="modal-note">Flag a hat, rhinestones, hairpiece or accessory to be reviewed and ordered.</p>
      {dancerName ? (
        <Field label="Dancer"><input className="inp" value={dancerName} readOnly /></Field>
      ) : (
        <p className="modal-note">This applies to the whole routine ({routineLabel}), not one dancer.</p>
      )}
      <Field label="Add-on item"><input className="inp" autoFocus placeholder="e.g. Rhinestones, hat, hair bow" value={item} onChange={(e) => setItem(e.target.value)} /></Field>
      <Field label="Category">
        <select className="inp" value={category} onChange={(e) => setCategory(e.target.value)}>
          <option>Custom add-on</option><option>Purchased accessory</option><option>Hairpiece</option>
        </select>
      </Field>
      <Field label="Notes"><input className="inp" placeholder="Optional" value={notes} onChange={(e) => setNotes(e.target.value)} /></Field>
    </Modal>
  );
}

// ------------------------------------------- units board (issue #10, §8)
// "Who is physically holding every unit of this routine, and does it match
// the plan?" — checked anytime, especially before bagging. Moves happen here
// too: one unit per move; chains are several moves in the order they
// happened at the rack. The board recomputes from live state on every
// render, so a move (or another device's move, ~15s poll) shows up in place.
function ReconcileModal({ state, routine, onClose, actions }) {
  // Only the unitId is kept in modal state — the unit itself is re-resolved
  // from live state at render AND confirm time, so a ~15s poll bringing
  // another device's move can never make us write a stale Units row
  // (newest-row-wins would silently revert their change).
  const [moving, setMoving] = React.useState(null); // unitId being moved
  const [target, setTarget] = React.useState("");
  const [shortfall, setShortfall] = React.useState(null); // offer-an-exchange step
  const [returning, setReturning] = React.useState(null); // unitId pending vendor return (#50)
  const [returnError, setReturnError] = React.useState(null);

  const board = Reconcile.buildRoutineReconciliation({
    routineId: routine.id,
    assignments: state.assignments,
    units: state.units,
    droppedAssignmentIds: state.drops.droppedAssignmentIds,
  });
  const routineDancers = board.dancers.filter((d) => !d.dropped);
  const movingUnit = moving ? state.units.find((u) => u.unitId === moving) : null;

  function startMove(unitId) {
    setTarget("");
    setMoving(unitId);
  }
  function confirmMove() {
    const unit = state.units.find((u) => u.unitId === moving);
    if (!unit) {
      console.error(`ReconcileModal: unit ${moving} vanished from the ledger — move not recorded`);
      setMoving(null);
      return;
    }
    // Compute who's left short BEFORE the move rewrites local state, then
    // OFFER the pre-filled exchange (§8) — mid-chain the "loser" usually
    // receives another unit on the next move, so never auto-queue it.
    const short = Reconcile.findShortfallAfterMove({ unit, assignments: state.assignments, units: state.units });
    actions.reassignUnit(unit, target);
    setMoving(null);
    if (short && short.dancerId !== target) setShortfall(short);
  }
  function queueShortfallExchange() {
    actions.decidePiece(shortfall.assignmentId, shortfall.pieceCode, "exchange", {
      requestedSize: shortfall.preselectedSize,
      reason: "Given to another dancer",
    });
    setShortfall(null);
  }
  function confirmReturn() {
    try {
      // The action re-resolves from live units and the builder refuses an
      // assigned unit — a race with another device surfaces here, loudly.
      actions.returnUnitToVendor(returning);
      setReturning(null);
      setReturnError(null);
    } catch (e) {
      setReturnError(e.message);
    }
  }

  const unitChip = (l) => l.unit
    ? <>
        <span className="mono">{l.unit.unitId} · {l.unit.size}</span>
        {l.unit.location && <span className="drop-line-sub"> · {l.unit.location}</span>}
      </>
    : <span style={{ color: "var(--bad-text)", fontWeight: 600 }}>no unit</span>;

  return (
    <Modal title={`Units board — ${routine.name}`} onClose={onClose}
      footer={<Button variant="accent" onClick={onClose}>Done</Button>}>
      {movingUnit ? (
        <div>
          <p className="modal-note">
            Move <span className="mono">{movingUnit.unitId}</span> ({movingUnit.pieceCode}, size <span className="mono">{movingUnit.size}</span>)
            {movingUnit.assignedDancerId ? <> from <b>{dancerName(state, movingUnit.assignedDancerId)}</b></> : " from spares"} to:
          </p>
          <Field label="New holder">
            <select className="inp" value={target} onChange={(e) => setTarget(e.target.value)}>
              <option value="">No one — back to spares</option>
              {routineDancers
                .filter((d) => d.dancerId !== movingUnit.assignedDancerId)
                .map((d) => {
                  const holding = Reconcile.describePieceHolding(d, movingUnit.pieceCode);
                  return (
                    <option key={d.dancerId} value={d.dancerId}>
                      {d.dancerName}{holding ? ` — ${holding}` : ""}
                    </option>
                  );
                })}
            </select>
          </Field>
          <div className="inline-actions" style={{ marginTop: 12 }}>
            <Button variant="ghost" size="sm" onClick={() => setMoving(null)}>Cancel</Button>
            <Button variant="accent" size="sm" icon="swap" onClick={confirmMove}>Move unit</Button>
          </div>
        </div>
      ) : shortfall ? (
        <div>
          <p className="modal-note">
            <b>{shortfall.dancerName}</b> now has no {shortfall.pieceName} ({shortfall.pieceCode}, planned size{" "}
            <span className="mono">{shortfall.preselectedSize}</span>). Queue an exchange for them, or skip if
            another unit is coming their way in this chain — the board keeps flagging the gap either way.
          </p>
          <div className="inline-actions" style={{ marginTop: 12 }}>
            <Button variant="ghost" size="sm" onClick={() => setShortfall(null)}>Skip — unit on its way</Button>
            <Button variant="warn" size="sm" icon="repeat" onClick={queueShortfallExchange}>Queue exchange</Button>
          </div>
        </div>
      ) : returning ? (
        <div>
          <p className="modal-note">
            Ship <span className="mono">{returning}</span> back to the vendor? This is final for the season:
            the unit leaves inventory and no picker will offer it again. The ledger keeps its history.
          </p>
          {returnError && <div style={{ marginBottom: 8, color: "var(--bad-text)", fontSize: 13 }}>{returnError}</div>}
          <div className="inline-actions" style={{ marginTop: 12 }}>
            <Button variant="ghost" size="sm" onClick={() => { setReturning(null); setReturnError(null); }}>Cancel</Button>
            <Button variant="warn" size="sm" icon="package" onClick={confirmReturn}>Return to vendor</Button>
          </div>
        </div>
      ) : (
        <div>
          <p className="modal-note">
            {board.issues === 0
              ? "Everything matches the plan — clear to bag."
              : `${board.issues} thing${board.issues > 1 ? "s" : ""} to sort before bagging.`}
          </p>
          {board.dancers.map((d) => (
            <div key={d.assignmentId} className={"drop-section" + (d.dropped ? " dropped-gray" : "")}>
              <div className="drop-section-title">{d.dancerName}{d.dropped ? " · dropped (see drop card)" : ""}</div>
              {d.lines.map((l) => (
                <div key={l.pieceCode} className="drop-line">
                  <div className="drop-line-main">
                    {l.pieceName} <span className="drop-line-sub">plan {l.preselectedSize}</span> — {unitChip(l)}
                    {l.flags.includes("size-differs") && <span className="drop-line-sub" style={{ color: "var(--warn-text)", fontWeight: 700 }}> ⚠ size differs</span>}
                  </div>
                  {l.unit && !d.dropped && (
                    <Button variant="ghost" size="sm" onClick={() => startMove(l.unit.unitId)}>Move…</Button>
                  )}
                </div>
              ))}
              {d.extras.map((x) => (
                <div key={x.unitId} className="drop-line">
                  <div className="drop-line-main">
                    <span style={{ color: "var(--warn-text)", fontWeight: 700 }}>⚠ extra:</span>{" "}
                    <span className="mono">{x.unitId}</span> · {x.pieceCode} · {x.size}
                    {x.location && <span className="drop-line-sub"> · {x.location}</span>} — not on their plan
                  </div>
                  {!d.dropped && <Button variant="ghost" size="sm" onClick={() => startMove(x.unitId)}>Move…</Button>}
                </div>
              ))}
            </div>
          ))}
          {board.spares.length > 0 && (
            <div className="drop-section">
              <div className="drop-section-title">Spares (available)</div>
              {board.spares.map((sp) => (
                <div key={sp.unitId} className="drop-line">
                  <div className="drop-line-main">
                    <span className="mono">{sp.unitId}</span> · {sp.pieceCode} · {sp.size}
                    {sp.location && <span className="drop-line-sub"> · {sp.location}</span>}
                  </div>
                  <span className="inline-actions">
                    <Button variant="ghost" size="sm" onClick={() => startMove(sp.unitId)}>Assign…</Button>
                    <Button variant="ghost" size="sm" onClick={() => { setReturnError(null); setReturning(sp.unitId); }}>Return…</Button>
                  </span>
                </div>
              ))}
            </div>
          )}
          {board.strays.length > 0 && (
            <div className="drop-section">
              <div className="drop-section-title">⚠ Held outside this routine's dancers</div>
              {board.strays.map((st) => (
                <div key={st.unitId} className="drop-line">
                  <div className="drop-line-main">
                    <span className="mono">{st.unitId}</span> · {st.pieceCode} · {st.size}
                    {st.location && <span className="drop-line-sub"> · {st.location}</span>} — with {dancerName(state, st.heldBy)}
                  </div>
                  <Button variant="ghost" size="sm" onClick={() => startMove(st.unitId)}>Move…</Button>
                </div>
              ))}
            </div>
          )}
        </div>
      )}
    </Modal>
  );
}

function SwapModal({ state, routine, onClose, actions }) {
  const assigns = state.assignments.filter((a) => a.routineId === routine.id);
  const [a1, setA1] = React.useState(assigns[0] ? assigns[0].id : "");
  const [a2, setA2] = React.useState(assigns[1] ? assigns[1].id : "");
  const [code, setCode] = React.useState(routine.pieces[0].code);
  const valid = a1 && a2 && a1 !== a2;
  function doSwap() {
    actions.swapPieces(routine.id, a1, a2, code);
    onClose();
  }
  return (
    <Modal title="Swap pieces within routine" onClose={onClose}
      footer={<><Button variant="ghost" onClick={onClose}>Cancel</Button><Button variant="accent" icon="swap" disabled={!valid} onClick={doSwap}>Swap units</Button></>}>
      <p className="modal-note">On-floor swap: trade the physical units for one piece between two dancers. Each garment keeps its own tag and size, so if a size no longer matches a dancer's planned size, the Units board flags it.</p>
      <Field label="Piece">
        <select className="inp" value={code} onChange={(e) => setCode(e.target.value)}>
          {routine.pieces.map((p) => <option key={p.code} value={p.code}>{p.name}</option>)}
        </select>
      </Field>
      <div className="inline-grid">
        <Field label="Dancer A">
          <select className="inp" value={a1} onChange={(e) => setA1(e.target.value)}>
            {assigns.map((a) => <option key={a.id} value={a.id}>{a.dancerName}</option>)}
          </select>
        </Field>
        <Field label="Dancer B">
          <select className="inp" value={a2} onChange={(e) => setA2(e.target.value)}>
            {assigns.map((a) => <option key={a.id} value={a.id}>{a.dancerName}</option>)}
          </select>
        </Field>
      </div>
    </Modal>
  );
}

// #71: DropModal came off this list with the modal merge — nothing outside
// this file rendered it, and its replacement is used only here too.
Object.assign(window, { TryOnHome, RoutineDetail, DancerTryOn, DancerClassesPanel, ReconcileModal });
