/* Gravity CMS — Manager dashboard: readiness, backlogs, "not ready" list. */

function Metric({ icon, label, value, tone = "accent", sub }) {
  return (
    <Card className="metric">
      <div className={"metric-icon tone-" + tone}><Icon name={icon} size={18} /></div>
      <div className="metric-body">
        <div className="metric-value">{value}</div>
        <div className="metric-label">{label}</div>
      </div>
      {sub && <div className="metric-sub">{sub}</div>}
    </Card>
  );
}

// #81 narrow fix: which Not ready groups are open, at MODULE scope — it
// survives the manager -> dancer -> back round trip (the component unmounts),
// but dies with the page, so refresh still opens a clean collapsed list
// (#80 grill call: no expand memory across refresh).
let nrgOpenMemory = new Set();

// #80: what a group row's chips say. Counts partition DANCERS by worst state
// (tested in logic.js), so a row's chips always sum to its "N not ready".
const NR_CHIP_META = {
  backordered: { tone: "bad", label: (n) => `${n} backordered` },
  issue: { tone: "warn", label: (n) => `${n} issue${n === 1 ? "" : "s"}` },
  in_progress: { tone: "info", label: (n) => `${n} in progress` },
  not_started: { tone: "muted", label: (n) => `${n} not started` },
};

function ManagerDashboard({ state, nav }) {
  // #80: one fan-out for metrics AND groups — the selector wraps
  // studioReadiness, so nothing here computes readiness twice (#69 rule).
  const r = GLOGIC.studioReadinessByRoutine(state);
  const openEx = GLOGIC.allOpenExchanges(state);
  const back = GLOGIC.backordered(state);
  const openAl = GLOGIC.openAlterations(state);
  const addons = GLOGIC.pendingAddons(state);

  // #80: collapsed by default, expand the routine you're chasing — the #55
  // Rollout idiom, deliberately NOT remembered across refresh (grill call,
  // Aug 25, 2026: expanding is in-the-moment triage; #79 restores the view).
  const [openIds, setOpenIds] = React.useState(() => new Set(nrgOpenMemory));
  function toggleGroup(routineId) {
    setOpenIds((prev) => {
      const next = new Set(prev);
      if (next.has(routineId)) next.delete(routineId); else next.add(routineId);
      nrgOpenMemory = next;
      return next;
    });
  }

  return (
    <div className="page">
      <div className="page-head">
        <div>
          <h1 className="page-title">Manager view</h1>
          {/* #67: the sidebar badge is a bare number and every other badge in
              that rail means "work waiting in this queue". Saying the count in
              words here is where the misread gets corrected, and it lands on a
              phone where a tooltip never would. */}
          <p className="page-sub">
            {state.season.name} · {r.notReady > 0
              ? `${r.notReady} of ${r.total} assignments not ready yet.`
              : `all ${r.total} assignments ready.`}
          </p>
        </div>
      </div>

      <div className="metric-grid">
        <Metric icon="checkCircle" tone="good" label="Ready for bagging" value={r.ready} sub={`of ${r.total}`} />
        <Metric icon="alert" tone="warn" label="Not ready" value={r.notReady} sub="needs follow-up" />
        <Metric icon="repeat" tone="warn" label="Open exchanges" value={openEx.length} sub={`${back.length} backordered`} />
        <Metric icon="scissors" tone="info" label="Open alterations" value={openAl.length} />
        <Metric icon="sparkles" tone="accent" label="Add-ons pending" value={addons.length} />
      </div>

      <div className="dash-cols">
        <div className="dash-main">
          <div className="section-head">
            <h2 className="section-title">Not ready <span className="muted-count">{r.notReady}</span></h2>
            <span className="section-hint">Worst routine first, then oldest open issue · dancers highest-severity first inside</span>
          </div>
          {r.groups.length === 0 ? (
            <Empty title="Everyone's ready" sub="No unresolved try-on issues across the season." />
          ) : (
            <div className="nr-list">
              {r.groups.map((g) => {
                const open = openIds.has(g.routineId);
                return (
                  <div key={g.routineId}>
                    <button type="button" className="card nrg-head" onClick={() => toggleGroup(g.routineId)} aria-expanded={open}>
                      <Icon name={open ? "chevronDown" : "chevronRight"} size={16} className="muted" />
                      <span className="nrg-name">{g.name}{g.team && <span className="nrg-team"> · {g.team}</span>}</span>
                      {/* Chips render in severity order; below 640px CSS keeps
                          only the first (worst) one — grill call, Aug 25, 2026:
                          manager view is an iPad/laptop surface. */}
                      <span className="nrg-chips">
                        {g.chips.map((c) => {
                          const m = NR_CHIP_META[c.key];
                          return <Badge key={c.key} tone={m.tone}>{m.label(c.count)}</Badge>;
                        })}
                      </span>
                      <span className="nrg-count">{g.notReady} of {g.total} not ready</span>
                    </button>
                    {open && (
                      <div className="nr-list nrg-body">
                        {g.entries.map((entry) => {
                          const a = entry.assignment;
                          const routine = state.routines.find((x) => x.id === a.routineId);
                          const s = entry.status;
                          return (
                            // #66: real button, same reason as the try-on dancer rows.
                            <button type="button" key={a.id} className="card nr-row" onClick={() => nav({ view: "dancer", assignmentId: a.id })}>
                              <span className="avatar"><Icon name="user" size={16} /></span>
                              <span className="nr-main">
                                <span className="dancer-name">{a.dancerName}</span>
                                <span className="dancer-sub">{routine.team} · {routine.name}</span>
                              </span>
                              <span className="nr-flags">
                                {s.code === "not_started" && <Badge tone="muted">Not started</Badge>}
                                {s.code === "in_progress" && <Badge tone="info">{s.decided}/{s.total} decided</Badge>}
                                {s.issues.map((i, idx) => {
                                  const m = DECISION_META[i.st.code];
                                  const back = i.st.ex && i.st.ex.status === "backordered";
                                  return <Badge key={idx} tone={back ? "bad" : m.tone}>
                                    <Icon name={m.icon} size={11} /> {i.piece.name}{back ? " · backordered" : ""}
                                  </Badge>;
                                })}
                              </span>
                              <Icon name="chevronRight" size={18} className="muted" />
                            </button>
                          );
                        })}
                      </div>
                    )}
                  </div>
                );
              })}
            </div>
          )}
        </div>

        <div className="dash-side">
          <Card className="side-card">
            <h3 className="side-title">Readiness by routine</h3>
            <div className="rr-list">
              {state.routines.map((rt) => {
                const s = GLOGIC.routineStats(state, rt);
                const tone = GLOGIC.routineTone(s);
                return (
                  <button type="button" key={rt.id} className="rr-row" onClick={() => nav({ view: "routine", routineId: rt.id })}>
                    <span className="rr-label"><span>{rt.name}</span><span className="mono">{s.ready}/{s.total}</span></span>
                    <ProgressBar value={s.ready} total={s.total} tone={tone} />
                  </button>
                );
              })}
            </div>
          </Card>

          <Card className="side-card">
            <h3 className="side-title">Season timeline</h3>
            <div className="timeline">
              {state.goalDates.map((g, i) => (
                <div key={i} className={"tl-row" + (g.active ? " active" : g.done ? " done" : "")}>
                  <span className="tl-dot" />
                  <div className="tl-body">
                    <div className="tl-label">{g.label}</div>
                    <div className="tl-date">{g.date}</div>
                  </div>
                  {g.done && <Icon name="check" size={14} className="tl-check" />}
                  {g.active && <Badge tone="accent">Now</Badge>}
                </div>
              ))}
            </div>
          </Card>
        </div>
      </div>
    </div>
  );
}

// =================================================== DSP roll-sheet import
// Issue #6 (ADR-0006): imports MERGE. Any number of per-class roll sheets in
// one run — each file decoded in a worker with a timeout (a corrupted .xlsx
// hangs XLSX.read, so parsing never runs on this thread), derived via
// RollSheet, merged against the sheet's CURRENT rows by RosterMerge (pure,
// tested): dancers add-only, each file replaces exactly its own class's
// enrollment, hand-filled Team/Season survive. The preview shows the diff
// and warnings; only confirm writes (one replace per tab).

const importCellStyle = { padding: "4px 10px", borderTop: "1px solid var(--border)", fontSize: 12.5, textAlign: "left" };

function ImportWarnings({ warnings }) {
  if (!warnings || warnings.length === 0) return null;
  return (
    <div className="card" style={{ padding: "12px 16px", marginBottom: 14, borderLeft: "3px solid var(--warn, #9a6714)" }}>
      <div style={{ fontWeight: 700, fontSize: 13, marginBottom: 6 }}>
        <Icon name="alert" size={13} /> Check before confirming
      </div>
      {warnings.map((w, i) => (
        <div key={i} style={{ fontSize: 13, marginTop: 4 }}>{w.message}</div>
      ))}
    </div>
  );
}

function ImportScreen({ state, replaceTab, reload, fetchTabs }) {
  const [preview, setPreview] = React.useState(null); // { merge, rejected }
  const [reading, setReading] = React.useState(false);
  const [busy, setBusy] = React.useState(false);
  const [done, setDone] = React.useState(null);
  const [error, setError] = React.useState(null);

  async function onFiles(e) {
    const files = Array.from(e.target.files || []);
    e.target.value = ""; // so re-picking the same files re-fires onChange
    if (files.length === 0) return;
    setError(null); setDone(null); setPreview(null); setReading(true);
    try {
      // Fresh rows, not page-load state: the merge's "current" must be what
      // the sheet holds right now (another device may have imported today).
      const raw = await fetchTabs(["Dancers", "Classes"]);
      const derived = [];
      const rejected = [];
      for (const file of files) {
        try {
          const grid = await XlsxDecode.decodeGridInWorker(await file.arrayBuffer());
          const d = RollSheet.deriveRollSheet(grid);
          if (d.dancers.length === 0) {
            throw new Error("No students found — is it a DSP roll-sheet (\"Download to Excel\") export?");
          }
          derived.push({ fileName: file.name, ...d });
        } catch (err) {
          // Per-file rejection: one bad file never blocks the others.
          rejected.push({ fileName: file.name, error: err.message || String(err) });
        }
      }
      if (derived.length === 0) {
        setError("None of the selected files could be read as roll sheets: " +
          rejected.map((r) => `${r.fileName} (${r.error})`).join("; "));
        return;
      }
      const merge = RosterMerge.mergeRoster({
        currentDancersGrid: raw.Dancers,
        currentClassesGrid: raw.Classes,
        files: derived,
      });
      setPreview({ merge, rejected });
    } catch (err) {
      setError(err.message || String(err));
    } finally {
      setReading(false);
    }
  }

  async function confirmMerge() {
    setBusy(true); setError(null);
    // Two sequential replaces aren't atomic: if Classes fails after Dancers
    // succeeded, say so explicitly — re-running the import is the recovery
    // (safe now by construction: merges are add-only for dancers).
    let dancersWritten = false;
    try {
      await replaceTab("Dancers", preview.merge.dancers);
      dancersWritten = true;
      await replaceTab("Classes", preview.merge.classes);
      await reload(); // awaited so "reloaded" is never claimed early
      const d = preview.merge.diff;
      setDone({ newDancers: d.newDancers.length, classCount: d.perClass.length });
      setPreview(null);
    } catch (err) {
      const msg = err.message || String(err);
      setError(
        dancersWritten
          ? `Dancers was written, but the next step failed: ${msg} — re-run the same import (merging again is safe) so both tabs match.`
          : msg
      );
    } finally {
      setBusy(false);
    }
  }

  const diff = preview && preview.merge.diff;

  return (
    <div className="page">
      <div className="page-head">
        <div>
          <h1 className="page-title">DSP roster import</h1>
          <p className="page-sub">
            Pick the Roll Sheet exports ("Download to Excel" in DSP) — one file per class, as many
            as you have. Imports only ever add dancers; each file updates just its own class.
            You'll see exactly what changes before anything is written.
          </p>
        </div>
      </div>

      <label className="btn btn-md" style={{ background: "var(--accent)", color: "#fff", cursor: "pointer" }}>
        <Icon name="upload" size={15} /> {reading ? "Reading files…" : "Choose roll-sheet files…"}
        <input type="file" accept=".xlsx,.xls" multiple style={{ display: "none" }} onChange={onFiles} disabled={reading} />
      </label>

      {error && (
        <div className="card" style={{ marginTop: 16, padding: "12px 16px", color: "var(--bad-text)" }}>
          {error}
        </div>
      )}

      {done && (
        <div className="card" style={{ marginTop: 16, padding: "12px 16px" }}>
          <strong>Imported.</strong> {done.classCount} {done.classCount === 1 ? "class" : "classes"} updated,{" "}
          {done.newDancers} new {done.newDancers === 1 ? "dancer" : "dancers"} added; roster reloaded.
        </div>
      )}

      {preview && (
        <div style={{ marginTop: 18 }}>
          {preview.rejected.length > 0 && (
            <div className="card" style={{ padding: "12px 16px", marginBottom: 14, color: "var(--bad-text)" }}>
              <div style={{ fontWeight: 700, fontSize: 13, marginBottom: 6 }}>
                {preview.rejected.length} {preview.rejected.length === 1 ? "file" : "files"} couldn't be read (the rest can still import)
              </div>
              {preview.rejected.map((r) => (
                <div key={r.fileName} style={{ fontSize: 13, marginTop: 4 }}>
                  <span className="mono">{r.fileName}</span> — {r.error}
                </div>
              ))}
            </div>
          )}

          <ImportWarnings warnings={preview.merge.warnings} />

          <div className="card" style={{ padding: "14px 16px", marginBottom: 14 }}>
            <div style={{ fontWeight: 700 }}>What this import will change</div>
            <div style={{ fontSize: 13, marginTop: 8 }}>
              {diff.newDancers.length > 0
                ? <><strong>{diff.newDancers.length} new {diff.newDancers.length === 1 ? "dancer" : "dancers"}</strong> join the roster.</>
                : <>No new dancers.</>}{" "}
              Dancers already on the roster are never changed or removed by an import.
            </div>
            {diff.perClass.map((pc) => (
              <div key={pc.className} style={{ fontSize: 13, marginTop: 8, paddingTop: 8, borderTop: "1px solid var(--border)" }}>
                <strong>{pc.className}</strong>{" "}
                <span className="muted mono" style={{ fontSize: 11.5 }}>({pc.fileNames.join(", ")})</span>
                <div style={{ marginTop: 2 }}>
                  {pc.added.length} joining · {pc.kept} staying
                  {pc.removed.length > 0 && (
                    <> · {pc.removed.length} no longer in this class's file:{" "}
                      {pc.removed.map((d) => `${d.FirstName} ${d.LastName}`.trim()).join(", ")}
                      {" "}(their enrollment row here goes away — they stay on the roster and in their other classes)
                    </>
                  )}
                </div>
              </div>
            ))}
            {diff.untouchedClassNames.length > 0 && (
              <div className="muted" style={{ fontSize: 12.5, marginTop: 8 }}>
                Not in this run, left exactly as-is: {diff.untouchedClassNames.join(", ")}
              </div>
            )}
            <div style={{ marginTop: 12, display: "flex", gap: 10 }}>
              <Button variant="accent" icon="check" disabled={busy} onClick={confirmMerge}>
                {busy ? "Writing…" : "Confirm import"}
              </Button>
              <Button variant="ghost" disabled={busy} onClick={() => setPreview(null)}>Cancel</Button>
            </div>
          </div>

          {diff.newDancers.length > 0 && (
            <div className="card" style={{ padding: "10px 6px", marginBottom: 14, overflowX: "auto" }}>
              <div className="muted" style={{ fontSize: 12.5, padding: "2px 10px 6px" }}>New dancers</div>
              <table style={{ borderCollapse: "collapse", width: "100%" }}>
                <thead>
                  <tr>
                    {["DancerID", "FirstName", "LastName"].map((h) => (
                      <th key={h} style={{ ...importCellStyle, borderTop: "none", color: "var(--text-3)" }}>{h}</th>
                    ))}
                  </tr>
                </thead>
                <tbody>
                  {diff.newDancers.map((d) => (
                    <tr key={d.DancerID}>
                      <td className="mono" style={importCellStyle}>{d.DancerID}</td>
                      <td style={importCellStyle}>{d.FirstName}</td>
                      <td style={importCellStyle}>{d.LastName}</td>
                    </tr>
                  ))}
                </tbody>
              </table>
            </div>
          )}

          <div className="card" style={{ padding: "10px 6px", overflowX: "auto" }}>
            <div className="muted" style={{ fontSize: 12.5, padding: "2px 10px 6px" }}>
              Exact Classes rows after this import ({preview.merge.classes.length})
            </div>
            <table style={{ borderCollapse: "collapse", width: "100%" }}>
              <thead>
                <tr>
                  {["DancerID", "ClassName", "Team", "Season"].map((h) => (
                    <th key={h} style={{ ...importCellStyle, borderTop: "none", color: "var(--text-3)" }}>{h}</th>
                  ))}
                </tr>
              </thead>
              <tbody>
                {preview.merge.classes.map((c, i) => (
                  <tr key={`${c.DancerID}|${c.ClassName}|${i}`}>
                    <td className="mono" style={importCellStyle}>{c.DancerID}</td>
                    <td style={importCellStyle}>{c.ClassName}</td>
                    <td style={importCellStyle}>{c.Team || "—"}</td>
                    <td style={importCellStyle}>{c.Season || "—"}</td>
                  </tr>
                ))}
              </tbody>
            </table>
          </div>
        </div>
      )}

      <AddDancerForm classRows={state.classRows} fetchTabs={fetchTabs} replaceTab={replaceTab} reload={reload} />
    </div>
  );
}

// =================================================== add a dancer (late adds)
// ADR-0006's second path onto the roster: add-only, ID minted with the
// import's own rule (RollSheet.deriveDancerId) so a later re-import resolves
// to the same dancer. Same preview→confirm shape as the import above.
// `fetchTabs` is still a prop, but only for onPreview: the write is built
// against the grids as they are AT PREVIEW TIME, never a mount-time snapshot
// (#10's stale-snapshot rule). The class PICKER is the part that moved to
// state in #64.
function AddDancerForm({ classRows, fetchTabs, replaceTab, reload }) {
  const [first, setFirst] = React.useState("");
  const [last, setLast] = React.useState("");
  const [dob, setDob] = React.useState("");
  const [selectedClass, setSelectedClass] = React.useState("");
  const [plan, setPlan] = React.useState(null); // addDancerToRoster result awaiting confirm
  const [busy, setBusy] = React.useState(false);
  const [error, setError] = React.useState(null);
  const [done, setDone] = React.useState(null);

  // #64: class options come from assembled state, NOT a fetch of this form's
  // own. The old comment here said state never loads Classes; that stopped
  // being true at #7. Its fetch meant "No classes yet" could show while 31
  // classes were in flight, and its silent catch could leave that up forever.
  // Reading state deletes the race, the failure mode, and the round trip.
  const knownClasses = React.useMemo(() => Roster.knownClassNames(classRows), [classRows]);

  async function onPreview() {
    setError(null); setDone(null); setPlan(null); setBusy(true);
    try {
      // One class per add (Denise, July 13): a dancer joining two classes is
      // two runs of this form — the stable ID links them to one Dancers row.
      const classNames = [selectedClass];
      const raw = await fetchTabs(["Dancers", "Classes"]);
      const result = RosterMerge.addDancerToRoster({
        currentDancersGrid: raw.Dancers,
        currentClassesGrid: raw.Classes,
        firstName: first,
        lastName: last,
        dob,
        classNames,
      });
      setPlan({ ...result, classNames });
    } catch (err) {
      setError(err.message || String(err));
    } finally {
      setBusy(false);
    }
  }

  async function onConfirm() {
    setBusy(true); setError(null);
    let dancersWritten = false;
    try {
      await replaceTab("Dancers", plan.dancers);
      dancersWritten = true;
      await replaceTab("Classes", plan.classes);
      await reload();
      setDone({ name: `${first.trim()} ${last.trim()}`.trim(), dancerId: plan.dancerId });
      setPlan(null); setFirst(""); setLast(""); setDob(""); setSelectedClass("");
    } catch (err) {
      const msg = err.message || String(err);
      setError(
        dancersWritten
          ? `Dancers was written, but the next step failed: ${msg} — press Preview and confirm again (adding twice is safe).`
          : msg
      );
    } finally {
      setBusy(false);
    }
  }

  const fieldStyle = { padding: "7px 10px", border: "1px solid var(--border)", borderRadius: 8, fontSize: 13.5, background: "var(--bg, transparent)", color: "inherit" };

  return (
    <div className="card" style={{ marginTop: 24, padding: "14px 16px" }}>
      <div style={{ fontWeight: 700 }}>Add a dancer</div>
      <div className="muted" style={{ fontSize: 12.5, marginTop: 2 }}>
        For late adds between DSP exports. Adding is all this does — dancers are never edited or
        removed here, and a later import links up with the same dancer automatically.
      </div>

      <div style={{ display: "flex", gap: 10, marginTop: 12, flexWrap: "wrap" }}>
        <input style={fieldStyle} placeholder="First name" value={first} onChange={(e) => setFirst(e.target.value)} />
        <input style={fieldStyle} placeholder="Last name" value={last} onChange={(e) => setLast(e.target.value)} />
        <input style={fieldStyle} type="date" title="Birthdate" value={dob} onChange={(e) => setDob(e.target.value)} />
      </div>

      <div style={{ marginTop: 10 }}>
        {/* Classes come ONLY from what's already been imported (Denise,
            July 13): a dancer is never added ahead of their class, so there's
            no free-text path — and no typo'd class can ever be minted here. */}
        {knownClasses.length === 0 ? (
          <div className="muted" style={{ fontSize: 12.5 }}>
            No classes yet — import a roll sheet above first; its class will show up here automatically.
          </div>
        ) : (
          <select
            style={{ ...fieldStyle, minWidth: 260, cursor: "pointer" }}
            value={selectedClass}
            onChange={(e) => setSelectedClass(e.target.value)}
          >
            <option value="">Choose a class…</option>
            {knownClasses.map((name) => (
              <option key={name} value={name}>{name}</option>
            ))}
          </select>
        )}
      </div>

      <div style={{ marginTop: 12 }}>
        <Button variant="ghost" icon="user" disabled={busy || !selectedClass} onClick={onPreview}>
          {busy && !plan ? "Checking…" : "Preview"}
        </Button>
      </div>

      {error && <div style={{ marginTop: 10, fontSize: 13, color: "var(--bad-text)" }}>{error}</div>}
      {done && (
        <div style={{ marginTop: 10, fontSize: 13 }}>
          <strong>Added.</strong> {done.name} (<span className="mono">{done.dancerId}</span>) is on the roster; data reloaded.
        </div>
      )}

      {plan && (
        <div style={{ marginTop: 12, paddingTop: 10, borderTop: "1px solid var(--border)" }}>
          <ImportWarnings warnings={plan.warnings} />
          <div style={{ fontSize: 13 }}>
            {plan.alreadyExisted ? (
              <>
                <span className="mono">{plan.dancerId}</span> is <strong>already on the roster</strong> —
                confirming only adds the missing class enrollments below.
              </>
            ) : (
              <>
                Will add <strong>{first.trim()} {last.trim()}</strong> as{" "}
                <span className="mono">{plan.dancerId}</span>
              </>
            )}
            {" "}· classes: {plan.classNames.join(", ")}
          </div>
          <div style={{ marginTop: 10, display: "flex", gap: 10 }}>
            <Button variant="accent" icon="check" disabled={busy} onClick={onConfirm}>
              {busy ? "Writing…" : "Confirm add"}
            </Button>
            <Button variant="ghost" disabled={busy} onClick={() => setPlan(null)}>Cancel</Button>
          </div>
        </div>
      )}
    </div>
  );
}

// Roster view (issue #15): the read-only reference screen. "By class" is the
// post-import verification Denise used to do by opening the backend Sheet
// (class → active count + names, eyeballable against DSP file-by-file);
// "by dancer" is the season-long "which classes is she in?" lookup. All
// grouping/drop logic lives in the tested Roster module — this renders it.
// Deliberately no Team column anywhere (decided July 16: zero consumers).
function RosterView({ state }) {
  const [grouping, setGrouping] = React.useState("byClass");
  const [classQuery, setClassQuery] = React.useState("");
  const roster = Roster.buildRoster({
    dancers: state.dancers,
    classRows: state.classRows,
    drops: state.drops,
  });
  const hasClasses = roster.byClass.length > 0;
  const shownClasses = Roster.filterByClass(roster.byClass, classQuery);

  const toggle = (
    <div className="tabs" style={{ margin: "12px 0 18px" }}>
      {[["byClass", "By class"], ["byDancer", "By dancer"]].map(([key, label]) => (
        <button key={key} className={"tab" + (grouping === key ? " active" : "")} onClick={() => setGrouping(key)}>
          {label}
        </button>
      ))}
    </div>
  );

  return (
    <div className="page">
      <div className="page-head">
        <div>
          <h1 className="page-title">Roster</h1>
          <p className="page-sub">
            Read-only reference of everyone imported and their classes. Dropped
            enrollments stay listed, grayed — same rules as everywhere else.
          </p>
        </div>
      </div>
      {!hasClasses ? (
        <Empty
          icon="users"
          title="No classes yet"
          sub="Run a roll-sheet import (Import tab) and every class will show up here with its dancers, ready to verify."
        />
      ) : (
        <React.Fragment>
          {toggle}
          {grouping === "byClass" && (
            <React.Fragment>
              <input
                className="inp"
                placeholder="Search classes…"
                value={classQuery}
                onChange={(e) => setClassQuery(e.target.value)}
                style={{ maxWidth: 340, marginBottom: 14 }}
              />
              {shownClasses.length === 0 ? (
                <Empty
                  icon="users"
                  title="No classes match"
                  sub={`Nothing named like “${classQuery.trim()}” — clear the search to see all ${roster.byClass.length} classes.`}
                />
              ) : (
                shownClasses.map((cls) => (
                  <Card key={cls.className} className="card-pad" style={{ marginBottom: 12 }}>
                    <div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline" }}>
                      <strong>{cls.className}</strong>
                      <span className="muted">{cls.activeCount} dancer{cls.activeCount === 1 ? "" : "s"}</span>
                    </div>
                    <div style={{ marginTop: 8 }}>
                      {cls.dancers.map((d) => (
                        <div key={d.id} className={d.dropped ? "dropped-gray" : ""} style={{ padding: "2px 0" }}>
                          {d.name}{d.dropped ? " (dropped)" : ""}
                        </div>
                      ))}
                    </div>
                  </Card>
                ))
              )}
            </React.Fragment>
          )}
          {grouping === "byDancer" &&
            roster.byDancer.map((d) => (
              <Card key={d.id} className={"card-pad" + (d.fullyDropped ? " dropped-gray" : "")} style={{ marginBottom: 8 }}>
                <div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline" }}>
                  <strong>{d.name}{d.fullyDropped ? " (dropped)" : ""}</strong>
                  <span className="muted" style={{ fontSize: 12 }}>{d.id}</span>
                </div>
                <div className="muted" style={{ marginTop: 4 }}>
                  {d.classes.length === 0
                    ? "No classes on file yet"
                    : d.classes.map((c, i) => (
                        <span key={c.className}>
                          {i > 0 && " · "}
                          <span className={c.dropped ? "dropped-gray" : ""}>
                            {c.className}{c.dropped ? " (dropped)" : ""}
                          </span>
                        </span>
                      ))}
                </div>
              </Card>
            ))}
        </React.Fragment>
      )}
    </div>
  );
}

// Receiving screen (issue #13, ADR-0008): every ON ORDER / BACKORDERED unit
// grouped the way boxes pack (routine × piece × size); check-in is the ONLY
// safe way to record an arrival — the Units ledger silently ignores edits to
// superseded rows. Selection and refusal logic live in the tested Reconcile
// builders; whatever they throw surfaces verbatim under the group.
// #52 (ADR-0010): late boxes are arrivals too — the landing spot is suggested,
// never decided. Storage pre-fills the class's real bin; a rolled-out routine
// gets a hint to use a delivery bin instead (staff pick which day's).
// #69: the suggestion arrives as a prop. It scans every unit, and it used to
// run once per GROUP ROW — a routine with 3 pieces across 2 sizes paid for six
// identical scans. ReceivingScreen now computes one per ROUTINE.
function ReceivingGroupRow({ group, state, actions, suggestion }) {
  const [bin, setBin] = React.useState(suggestion.bin); // "" when delivery — staff pick the day's bin
  const [count, setCount] = React.useState(group.unitIds.length);
  const [error, setError] = React.useState(null);

  const routine = state.routines.find((r) => r.id === group.routineId);
  const piece = routine && routine.pieces.find((p) => p.code === group.pieceCode);
  const idRange = group.unitIds.length <= 2
    ? group.unitIds.join(", ")
    : `${group.unitIds[0]} … ${group.unitIds[group.unitIds.length - 1]}`;

  function run(action) {
    setError(null);
    try {
      action();
    } catch (e) {
      setError(e.message);
    }
  }

  return (
    <Card className="card-pad" style={{ marginBottom: 12 }}>
      <div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 12, flexWrap: "wrap" }}>
        <div>
          <strong>{routine ? routine.name : group.routineId}</strong>
          {" · "}{piece ? piece.name : group.pieceCode}{" · "}{group.size}
        </div>
        <span className="muted" style={{ display: "inline-flex", alignItems: "center", gap: 6, flexWrap: "wrap" }}>
          {group.unitIds.length} unit{group.unitIds.length === 1 ? "" : "s"}
          {" "}({group.assignedCount} assigned · {group.spareCount} spare{group.spareCount === 1 ? "" : "s"})
          {group.backorderedCount > 0 && <Badge tone="warn">{group.backorderedCount} backordered</Badge>}
        </span>
      </div>
      <div className="muted" style={{ fontSize: 12, marginTop: 4 }}>{idRange}</div>
      <div style={{ display: "flex", gap: 8, marginTop: 10, alignItems: "center", flexWrap: "wrap" }}>
        <input
          type="number" min="1" max={group.unitIds.length} value={count}
          onChange={(e) => setCount(Number(e.target.value))}
          style={{ width: 64 }} aria-label="How many arrived"
        />
        <input
          type="text" placeholder="Bin (e.g. A3)" value={bin}
          onChange={(e) => setBin(e.target.value)}
          style={{ width: 130 }} aria-label="Bin"
        />
        <button className="btn btn-md" style={{ background: "var(--accent)", color: "#fff" }}
          onClick={() => run(() => { actions.checkInUnits({ ...group, count, bin }); setBin(""); })}>
          Check in
        </button>
        <button className="btn btn-md"
          onClick={() => run(() => actions.markBackordered({ ...group, count }))}>
          Mark backordered
        </button>
      </div>
      {suggestion.kind === "delivery" && (
        <div className="muted" style={{ fontSize: 12, marginTop: 6 }}>
          This routine has rolled out — use a delivery bin (MONDAY–THURSDAY or WEEKEND BIN) so the desk sweeps it to the dancer.
        </div>
      )}
      {error && <div style={{ marginTop: 8, color: "var(--bad-text)", fontSize: 13 }}>{error}</div>}
    </Card>
  );
}

function ReceivingScreen({ state, actions }) {
  // #69: one landing-spot suggestion per ROUTINE, not per group row. Memoized
  // with the groups so a re-render that brought no new rows (#68) re-scans
  // nothing at all.
  const { groups, suggestions } = React.useMemo(() => {
    const gs = Reconcile.buildReceivingGroups(state.units);
    const byRoutine = new Map();
    gs.forEach((g) => {
      if (byRoutine.has(g.routineId)) return;
      byRoutine.set(g.routineId, Reconcile.suggestLandingSpot({ routineId: g.routineId, liveUnits: state.units }));
    });
    return { groups: gs, suggestions: byRoutine };
  }, [state]);
  return (
    <div className="page">
      <div className="page-head">
        <div>
          <h1 className="page-title">Receiving</h1>
          <p className="page-sub">
            Everything ordered but not yet arrived, grouped the way boxes pack.
            Check a box in with its bin — tags go on as each garment comes out.
            Partial arrivals check in the FIRST units of the group, so the tags
            you use match what's recorded.
          </p>
        </div>
      </div>
      {groups.length === 0 ? (
        <Empty
          icon="package"
          title="Nothing on order right now"
          sub="Units appear here when an order's PASTE Units rows land in the backend Sheet with Location ON ORDER."
        />
      ) : (
        groups.map((g) => (
          <ReceivingGroupRow
            key={`${g.routineId}|${g.pieceCode}|${g.size}|${g.unitIds.length}`}
            group={g} state={state} actions={actions}
            suggestion={suggestions.get(g.routineId)}
          />
        ))
      )}
    </div>
  );
}

// ---------------------------------------------------------------- Rollout (#51, ADR-0010)
// Receiving's mirror: the roll-call of every dancer with anything still
// in-studio. Handing out appends WITH DANCER rows; open work flags, never blocks.
function RolloutDancerCard({ dancer, state, actions, onReceipt, currentRoutineId, issueDetails }) {
  const allUnitIds = dancer.routines.flatMap((r) => r.units.map((u) => u.unitId));
  // Selection is stored INVERTED — the staff's deliberate un-ticks — so the
  // default (everything goes home) needs no state at all: a unit arriving
  // mid-15s-poll renders ticked, and un-ticks survive re-renders without any
  // remount-on-key tricks (a count-based key silently reset them; #51 review).
  const [unticked, setUnticked] = React.useState(() => new Set());
  const [error, setError] = React.useState(null);
  function toggle(unitId) {
    setUnticked((prev) => {
      const next = new Set(prev);
      if (next.has(unitId)) next.delete(unitId); else next.add(unitId);
      return next;
    });
  }
  const tickedIds = allUnitIds.filter((id) => !unticked.has(id));
  function sendHome() {
    setError(null);
    try {
      // tickedIds derives from CURRENT props, so a unit another device already
      // handed out can't ride a stale checkbox; the builder re-checks against
      // live units and refuses loudly either way.
      const { records, undoRecords } = actions.rolloutUnits({ dancerId: dancer.dancerId, unitIds: tickedIds });
      // #55: the screen is the receipt — banner exactly what went home.
      if (onReceipt) onReceipt({ text: `${dancer.dancerName} sent home — ${records.length} unit${records.length === 1 ? "" : "s"}: ${records.map((r) => r.UnitID).join(", ")}`, undo: undoRecords });
    } catch (e) {
      setError(e.message);
    }
  }
  // #55 review find: the builder's total sees every non-dropped enrollment —
  // the routines listed on the card only cover classes she still holds units in.
  const openIssues = dancer.openIssues || 0;
  // #55 close-out (Denise): tap the badge to see WHAT the open items are —
  // read from the same tested logic the count came from, across all her
  // enrollments, so the list can never disagree with the number.
  const [showIssues, setShowIssues] = React.useState(false);
  const issueLines = [];
  (state.assignments || []).forEach((a) => {
    if (a.dancerId !== dancer.dancerId) return;
    ((issueDetails && issueDetails[a.id]) || []).forEach(({ piece, st }) => {
      const kind = st.code === "exchange" ? `exchange (${st.ex.status})`
        : st.code === "alter" ? `alteration (${String(st.al.status).replace(/_/g, " ")})`
        : st.code === "missing" ? "missing" : "open";
      issueLines.push(`${routineName(state, a.routineId)} · ${piece.name} — ${kind}`);
    });
  });
  // #55 close-out (Denise): standing in a class, THAT class's pieces lead —
  // the rest of her bag follows dimmed, labeled for what it is. Whole-bag
  // behavior unchanged; this is purely reading order.
  const hereGroups = currentRoutineId ? dancer.routines.filter((r) => r.routineId === currentRoutineId) : dancer.routines;
  const otherGroups = currentRoutineId ? dancer.routines.filter((r) => r.routineId !== currentRoutineId) : [];
  const renderGroup = (r) => (
    <div key={r.assignmentId} className={"drop-section" + (r.dropped && !dancer.fullyDropped ? " dropped-gray" : "")}>
      <div className="drop-section-title">{routineName(state, r.routineId)}{r.dropped ? " · dropped" : ""}</div>
      {r.units.map((u) => (
        <label key={u.unitId} className="drop-line" style={{ cursor: "pointer" }}>
          <span className="drop-line-main" style={{ display: "flex", alignItems: "center", gap: 8 }}>
            <input type="checkbox" checked={!unticked.has(u.unitId)} onChange={() => toggle(u.unitId)} />
            <span className="mono">{u.unitId}</span> · {u.pieceCode} · {u.size}
            {u.location && <span className="drop-line-sub"> · {u.location}</span>}
          </span>
        </label>
      ))}
    </div>
  );
  return (
    <Card className={"card-pad" + (dancer.fullyDropped ? " dropped-gray" : "")} style={{ marginBottom: 12 }}>
      <div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 12, flexWrap: "wrap" }}>
        <strong>{dancer.dancerName}{dancer.fullyDropped ? " · dropped (see drop card)" : ""}</strong>
        {openIssues > 0 && (
          <button onClick={() => setShowIssues(!showIssues)} style={{ background: "none", border: 0, padding: 0, cursor: "pointer", font: "inherit" }} aria-expanded={showIssues}>
            <Badge tone="warn">{openIssues} open item{openIssues === 1 ? "" : "s"} — tap to see · never a blocker</Badge>
          </button>
        )}
      </div>
      {showIssues && issueLines.length > 0 && (
        <div style={{ margin: "6px 0 2px" }}>
          {issueLines.map((line, i) => (
            <div key={i} className="drop-line-sub" style={{ padding: "2px 0" }}>{line}</div>
          ))}
        </div>
      )}
      {/* #67: when this class's pieces are all home already, hereGroups is
          empty and the card used to open straight onto the dimmed "other
          classes" block — reading as though that WERE this class's list.
          One line anchors it. Only when there is something else below;
          a card with nothing at all never renders. */}
      {hereGroups.length === 0 && otherGroups.length > 0 && currentRoutineId && (
        <div style={{ marginTop: 8, fontSize: 13, color: "var(--good-text)", fontWeight: 600 }}>
          All {routineName(state, currentRoutineId)} pieces are home
        </div>
      )}
      {hereGroups.map(renderGroup)}
      {otherGroups.length > 0 && (
        <div style={{ opacity: 0.65 }}>
          <div className="drop-line-sub" style={{ marginTop: 8, fontWeight: 600 }}>Also going home in this bag (other classes):</div>
          {otherGroups.map(renderGroup)}
        </div>
      )}
      {/* #55: the bag-ready line — answers "bag it nice now, or wait?" at a
          glance. Advisory only, never a gate on the Send home button. */}
      {dancer.bagReady && (dancer.bagReady.allHere ? (
        <div style={{ marginTop: 8, fontSize: 13, color: "var(--good-text)", fontWeight: 600 }}>
          All {allUnitIds.length} piece{allUnitIds.length === 1 ? "" : "s"} here — bag it
        </div>
      ) : (
        <div style={{ marginTop: 8, fontSize: 13, color: "var(--warn-text)", fontWeight: 600 }}>
          {dancer.bagReady.awayUnits.length > 0
            ? <>Missing {dancer.bagReady.awayUnits.length}: {dancer.bagReady.awayUnits.map((u) => `${u.pieceCode} (${String(u.location || "").toLowerCase()})`).join(" · ")}</>
            : <>Open work flagged above — the bag isn't settled yet</>}
        </div>
      ))}
      {dancer.sentHome && dancer.sentHome.length > 0 && (
        <div className="drop-line-sub" style={{ marginTop: 4 }}>
          Already home: {dancer.sentHome.map((u) => `${u.pieceCode}${u.sentAt ? " " + fmtStamp(u.sentAt) : ""}`).join(" · ")}
        </div>
      )}
      <div style={{ display: "flex", gap: 8, marginTop: 10, alignItems: "center", flexWrap: "wrap" }}>
        <Button variant="accent" size="sm" icon="bag" onClick={sendHome}>
          Send home ({tickedIds.length})
        </Button>
      </div>
      {error && <div style={{ marginTop: 8, color: "var(--bad-text)", fontSize: 13 }}>{error}</div>}
    </Card>
  );
}

// #55: the class's other half — what already left, and when. The ledger's
// stamped WITH DANCER row is the receipt; collapsed by default so the
// roll-call stays about who still owes pickup.
function SentHomeList({ entries }) {
  const [open, setOpen] = React.useState(false);
  if (entries.length === 0) return null;
  return (
    <div style={{ marginTop: 4, marginBottom: 12 }}>
      <Button variant="ghost" size="sm" icon={open ? "chevronDown" : "chevronRight"} onClick={() => setOpen(!open)}>
        Sent home ({entries.length})
      </Button>
      {open && (
        <Card className="card-pad" style={{ marginTop: 8 }}>
          {entries.map((d) => (
            <div key={d.dancerId} className="drop-section">
              <div className="drop-section-title">{d.dancerName}</div>
              {d.units.map((u) => (
                <div key={u.unitId} className="drop-line">
                  <span className="drop-line-main">
                    <span className="mono">{u.unitId}</span> · {u.pieceCode} · {u.size}
                    <span className="drop-line-sub">{u.sentAt ? ` · sent home ${fmtStamp(u.sentAt)}` : " · sent home"}</span>
                  </span>
                </div>
              ))}
            </div>
          ))}
        </Card>
      )}
    </div>
  );
}

// #55: one class, one section — collapsed by default, the header carries the
// story so the crew expands only the class they're bagging. The SAME
// everything-she's-owed dancer card renders in every class she's in, so
// whichever class catches her first sends the whole bag home.
function RolloutClassSection({ name, section, open, onToggle, state, actions, onReceipt, issueDetails }) {
  // #55 close-out (Denise): dancers collapse too — a class of 18 reads as a
  // roster of rows, and 1-of-18 is a scan, not a scroll. The row chip tells
  // the bag-ready story before anything expands.
  const [openDancers, setOpenDancers] = React.useState(() => new Set());
  function toggleDancer(dancerId) {
    setOpenDancers((prev) => {
      const next = new Set(prev);
      if (next.has(dancerId)) next.delete(dancerId); else next.add(dancerId);
      return next;
    });
  }
  function chip(d) {
    if ((d.openIssues || 0) > 0) return <Badge tone="warn">{d.openIssues} open item{d.openIssues === 1 ? "" : "s"}</Badge>;
    if (d.bagReady && !d.bagReady.allHere) return <Badge tone="warn">Missing {d.bagReady.awayUnits.length}</Badge>;
    return <Badge tone="good">All here</Badge>;
  }
  return (
    <div style={{ marginBottom: 12 }}>
      <Card>
        <button
          onClick={onToggle}
          style={{ display: "flex", alignItems: "center", gap: 8, width: "100%", background: "none", border: 0, cursor: "pointer", font: "inherit", textAlign: "left", padding: "12px 16px" }}
        >
          <Icon name={open ? "chevronDown" : "chevronRight"} size={16} className="muted" />
          <strong style={{ flex: 1 }}>{name}</strong>
          <span className="muted" style={{ fontSize: 13, whiteSpace: "nowrap" }}>
            {section.stillHere.length} to hand out · {section.sentHome.length} sent home
          </span>
        </button>
      </Card>
      {open && (
        <div style={{ marginTop: 8 }}>
          {section.stillHere.map((d) => {
            const pieces = d.routines.reduce((n, r) => n + r.units.length, 0);
            const isOpen = openDancers.has(d.dancerId);
            return (
              <div key={d.dancerId} style={{ marginBottom: isOpen ? 0 : 8 }}>
                {/* #55 (Denise): rows tinted accent-soft so they read as the
                    clicked class's dancers, distinct from class headers and
                    the white expanded card. */}
                <Card className={d.fullyDropped ? "dropped-gray" : ""} onClick={() => toggleDancer(d.dancerId)} style={{ cursor: "pointer", background: "var(--accent-soft)" }}>
                  <div style={{ display: "flex", alignItems: "center", gap: 8, padding: "10px 16px" }}>
                    <Icon name={isOpen ? "chevronDown" : "chevronRight"} size={14} className="muted" />
                    <strong style={{ flex: 1 }}>{d.dancerName}</strong>
                    <span className="muted" style={{ fontSize: 12.5, whiteSpace: "nowrap" }}>
                      {pieces} piece{pieces === 1 ? "" : "s"}{d.sentHome && d.sentHome.length > 0 ? ` · ${d.sentHome.length} home` : ""}
                    </span>
                    {chip(d)}
                  </div>
                </Card>
                {isOpen && (
                  <div style={{ marginTop: 8 }}>
                    <RolloutDancerCard dancer={d} state={state} actions={actions} onReceipt={onReceipt} currentRoutineId={section.routineId} issueDetails={issueDetails} />
                  </div>
                )}
              </div>
            );
          })}
          <SentHomeList entries={section.sentHome} />
        </div>
      )}
    </div>
  );
}

// #52: the desk sweep. Counts come from the SAME tested seam the sweep uses
// (buildBinSweepCounts), so the card can never promise a sweep the builder
// refuses: misroutes (unassigned units in a delivery bin) are surfaced as
// their own line and only sweepable items get a Sweep button.
function BinSweepCard({ state, actions, onReceipt }) {
  const [error, setError] = React.useState(null);
  const counts = Reconcile.buildBinSweepCounts(state.units);
  if (counts.length === 0) return null;
  function sweep(bin) {
    setError(null);
    try {
      const { records, undoRecords } = actions.sweepBin(bin);
      // #55: same receipt rule as hand-out — name exactly what was picked up,
      // and WHO took it home (review find: the kid was missing from the story).
      if (onReceipt) onReceipt({ text: `${bin} swept — ${records.length} item${records.length === 1 ? "" : "s"} picked up: ${records.map((r) => `${r.UnitID} (${dancerName(state, r.AssignedDancerID)})`).join(", ")}`, undo: undoRecords });
    } catch (e) { setError(e.message); }
  }
  return (
    <Card className="card-pad" style={{ marginBottom: 12 }}>
      <div className="drop-section-title">Delivery bins — sweep after each class day</div>
      {counts.map(({ bin, sweepable, misroutes }) => (
        <div key={bin} className="drop-line">
          <div className="drop-line-main">
            <span className="mono">{bin}</span>
            {sweepable > 0 && <> · {sweepable} item{sweepable === 1 ? "" : "s"} waiting</>}
            {misroutes > 0 && (
              <span style={{ color: "var(--warn-text)", fontWeight: 600 }}>
                {" "}· ⚠ {misroutes} unassigned — sweeps skip these; assign or move them on the Units board
              </span>
            )}
          </div>
          {sweepable > 0 && <Button variant="ghost" size="sm" icon="bag" onClick={() => sweep(bin)}>Sweep — picked up</Button>}
        </div>
      ))}
      {error && <div style={{ marginTop: 8, color: "var(--bad-text)", fontSize: 13 }}>{error}</div>}
    </Card>
  );
}

function RolloutScreen({ state, actions }) {
  // #69: the find-a-dancer box is component state, so EVERY KEYSTROKE used to
  // re-run all of this — assignmentStatus over every assignment, then the whole
  // roll-call rebuild. Memoized on the state object, which #68 leaves untouched
  // on a poll that brought nothing new, so typing is free while a unit arriving
  // mid-poll still lands on the next assemble.
  //
  // #69 ticket said "lift the filter input into its own component" — this gets
  // the same result without restructuring JSX on a screen behind the passcode
  // gate, where a silent layout regression can't be agent-verified. useMemo is
  // already this file's idiom (knownClasses, line ~406).
  const { issueDetails, ordered } = React.useMemo(() => {
    // Open-work flags are CONSUMED from the tested queue logic, never re-derived
    // (same rule as drops semantics) — the roster builder just carries counts.
    const counts = {};
    const details = {};
    state.assignments.forEach((a) => {
      const s = GLOGIC.assignmentStatus(state, a);
      counts[a.id] = s.issues.length;
      details[a.id] = s.issues;
    });
    // #55: sections per class (see buildRolloutByClass) — a dancer appears
    // under every class she's in, with one everything-she's-owed card.
    const sections = Reconcile.buildRolloutByClass({
      assignments: state.assignments,
      units: state.units,
      droppedAssignmentIds: (state.drops && state.drops.droppedAssignmentIds) || new Set(),
      openIssueCountByAssignmentId: counts,
    });
    return {
      issueDetails: details,
      ordered: sections.slice().sort((a, b) => routineName(state, a.routineId).localeCompare(routineName(state, b.routineId))),
    };
  }, [state]);
  // All collapsed by default (Denise, #55 grill): expand the class you're
  // bagging; nothing forces the others shut.
  const [openIds, setOpenIds] = React.useState(() => new Set());
  const [receipt, setReceipt] = React.useState(null);
  // #55 close-out (Denise): the desk-straggler case — type a few letters,
  // every class narrows to matching kids and opens up.
  const [filter, setFilter] = React.useState("");
  function toggle(routineId) {
    setOpenIds((prev) => {
      const next = new Set(prev);
      if (next.has(routineId)) next.delete(routineId); else next.add(routineId);
      return next;
    });
  }
  function undoReceipt() {
    const records = actions.undoUnitsWrite(receipt.undo);
    setReceipt({ text: `Undone — ${records.length} unit${records.length === 1 ? "" : "s"} back where they were`, undo: null });
  }
  const q = filter.trim().toLowerCase();
  const visible = (q
    ? ordered.map((s) => ({
        ...s,
        stillHere: s.stillHere.filter((d) => d.dancerName.toLowerCase().includes(q)),
        sentHome: s.sentHome.filter((d) => d.dancerName.toLowerCase().includes(q)),
      }))
    : ordered
  ).filter((s) => s.stillHere.length > 0 || s.sentHome.length > 0);
  const binCount = Reconcile.countDeliveryBinUnits(state.units);
  return (
    <div className="page">
      <div className="page-head">
        <div>
          <h1 className="page-title">Rollout</h1>
          <p className="page-sub">
            Grouped by class — expand the one you're bagging. A dancer shows under every
            class she's in; whichever class catches her first sends the whole bag home.
            Open work is flagged, never a blocker.{binCount > 0 ? ` ${binCount} item${binCount === 1 ? "" : "s"} waiting in delivery bins.` : ""}
          </p>
        </div>
        <input
          className="inp"
          placeholder="Find a dancer…"
          value={filter}
          onChange={(e) => setFilter(e.target.value)}
          style={{ maxWidth: 220 }}
        />
      </div>
      {receipt && (
        <div style={{ display: "flex", alignItems: "center", gap: 8, background: "var(--good-soft)", color: "var(--good-text)", borderRadius: 10, padding: "9px 12px", marginBottom: 12, fontSize: 13.5, fontWeight: 600 }}>
          <Icon name="checkCircle" size={15} />
          <span style={{ flex: 1 }}>{receipt.text}</span>
          {receipt.undo && <Button variant="ghost" size="sm" onClick={undoReceipt}>Undo</Button>}
          <button onClick={() => setReceipt(null)} aria-label="Dismiss" style={{ background: "none", border: 0, cursor: "pointer", color: "inherit", font: "inherit", fontWeight: 700 }}>✕</button>
        </div>
      )}
      <BinSweepCard state={state} actions={actions} onReceipt={setReceipt} />
      {visible.length === 0 ? (
        q ? (
          <Empty icon="bag" title="No dancer matches" sub={`Nobody on the roll-call matches "${filter.trim()}".`} />
        ) : (
          <Empty
            icon="bag"
            title="Every costume is home"
            sub="No dancer has anything left in-studio. New arrivals will bring dancers back to this list."
          />
        )
      ) : (
        visible.map((s) => (
          <RolloutClassSection
            key={s.routineId}
            name={routineName(state, s.routineId)}
            section={s}
            open={q ? true : openIds.has(s.routineId)}
            onToggle={() => toggle(s.routineId)}
            state={state}
            actions={actions}
            onReceipt={setReceipt}
            issueDetails={issueDetails}
          />
        ))
      )}
    </div>
  );
}

Object.assign(window, { ManagerDashboard, ImportScreen, AddDancerForm, RosterView, ReceivingScreen, RolloutScreen });
