// admin.jsx — Workspace admin: People page, Project Access panel, Invite modal,
//             and Teams admin panel.

// Display-name helper used by undo toasts. The members array carries
// only `{ id, role }` so we look the user up in PEOPLE for a friendly
// name. Falls back to "the member" when the user isn't loaded yet.
function _displayNameOf(member, people) {
  const list = Array.isArray(people) && people.length ? people
              : (typeof PEOPLE !== "undefined" ? PEOPLE : []);
  const p = (list || []).find(x => x && x.id === (member && member.id));
  return (p && p.name) || "the member";
}

// ─────────────────────────────────────────────────────────────
// AdminPeoplePage — top-level admin view. Hosts two sections:
//   • People  — the existing roster (default)
//   • Teams   — bundles of users that can be added to a project
//                in one click (see TeamsAdminPanel below).
// The section toggle lives in the header so the page chrome
// (seat cards, tabs, table) is only rendered for People.
// ─────────────────────────────────────────────────────────────
function AdminPeoplePage(props) {
  const [section, setSection] = React.useState("people");
  return (
    <div className="admin-page">
      <div className="admin-header">
        <div className="admin-title-row" style={{ alignItems: "flex-end" }}>
          <div style={{ flex: 1 }}>
            <div className="admin-title">{section === "teams" ? "Teams" : "People & permissions"}</div>
            <div className="admin-sub">
              {section === "teams"
                ? "Bundle users into named teams so you can add a whole team to a project in one click"
                : `${WORKSPACE.name} workspace — manage who has access, what they can do, and which projects they see`}
            </div>
          </div>
          <div
            role="tablist"
            aria-label="Admin section"
            style={{
              display: "inline-flex",
              background: "var(--surface-2, #f1f4f9)",
              border: "1px solid var(--border)",
              borderRadius: 999,
              padding: 3,
              gap: 2,
            }}>
            {[["people", "People"], ["teams", "Teams"]].map(([id, label]) => {
              const on = section === id;
              return (
                <button
                  key={id}
                  role="tab"
                  aria-selected={on}
                  onClick={() => setSection(id)}
                  style={{
                    display: "inline-flex", alignItems: "center", gap: 6,
                    border: "none",
                    background: on ? "#fff" : "transparent",
                    color: on ? "var(--brand, #0073ea)" : "var(--ink-muted)",
                    padding: "6px 14px",
                    font: "inherit", fontSize: 13, fontWeight: 600,
                    borderRadius: 999, cursor: "pointer",
                    boxShadow: on ? "0 1px 3px rgba(15,23,41,.08)" : "none",
                  }}>
                  <Icons.Users size={13}/> {label}
                </button>
              );
            })}
          </div>
        </div>
      </div>
      {section === "teams"
        ? <TeamsAdminPanel people={props.people} onToast={props.onToast}/>
        : <AdminPeopleBody {...props}/>}
    </div>
  );
}

// ─────────────────────────────────────────────────────────────
// People admin body (the original AdminPeoplePage content,
// minus the outer .admin-page wrapper which AdminPeoplePage
// now provides so the section toggle can sit above either
// section).
// ─────────────────────────────────────────────────────────────
function AdminPeopleBody({ onOpenProjectAccess, onOpenInvite, people, setPeople, access, setAccess, onToast }) {
  const [tab, setTab] = React.useState("all");          // all | admins | members | guests | invited | deactivated
  const [q, setQ] = React.useState("");
  const [selected, setSelected] = React.useState(new Set());
  // ── New row-level admin actions (password reset + module access) ──
  const [pwdTarget, setPwdTarget]       = React.useState(null); // person | null
  const [accessTarget, setAccessTarget] = React.useState(null); // person | null

  const counts = {
    all: people.length,
    admins: people.filter(p => p.wsRole === "owner" || p.wsRole === "admin").length,
    members: people.filter(p => p.wsRole === "member" && p.status === "active").length,
    guests: people.filter(p => p.wsRole === "guest").length,
    invited: people.filter(p => p.status === "invited").length,
    deactivated: people.filter(p => p.status === "deactivated").length,
  };

  const filtered = people.filter(p => {
    if (tab === "admins" && !(p.wsRole === "owner" || p.wsRole === "admin")) return false;
    if (tab === "members" && !(p.wsRole === "member" && p.status === "active")) return false;
    if (tab === "guests" && p.wsRole !== "guest") return false;
    if (tab === "invited" && p.status !== "invited") return false;
    if (tab === "deactivated" && p.status !== "deactivated") return false;
    if (q.trim()) {
      const s = q.trim().toLowerCase();
      if (!p.name.toLowerCase().includes(s) && !p.email.toLowerCase().includes(s) && !(p.team||"").toLowerCase().includes(s)) return false;
    }
    return true;
  });

  const projectCount = (personId) => {
    return Object.values(access).filter(a => a.members.some(m => m.id === personId)).length;
  };

  function toggleSel(id) {
    setSelected(s => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
  }
  function toggleAll() {
    if (selected.size === filtered.length) setSelected(new Set());
    else setSelected(new Set(filtered.map(p => p.id)));
  }
  function changeRole(id, role) {
    const prev = people;
    setPeople(ps => ps.map(p => p.id === id ? { ...p, wsRole: role } : p));
    if (window.api) {
      api.users.patch(id, { ws_role: role })
        .then(() => onToast && onToast("Role updated"))
        .catch(e => {
          setPeople(prev);
          onToast && onToast("Couldn't change role: " + (e.message || "network error"), 4000);
        });
    }
  }
  function removePerson(id) {
    const prev = people;
    setPeople(ps => ps.map(p => p.id === id ? { ...p, status: "deactivated" } : p));
    setSelected(new Set());
    if (window.api) {
      api.users.remove(id)
        .then(() => onToast && onToast("Member deactivated"))
        .catch(e => {
          setPeople(prev);
          onToast && onToast("Couldn't deactivate: " + (e.message || "network error"), 4000);
        });
    }
  }

  // Permanent (hard) delete — second stage, only offered for users
  // already in the "deactivated" state. Pre-flight call returns the
  // task counts so we can show a precise refusal when the user is
  // still attached to work, instead of the user clicking through a
  // confirm and then hitting a 409. The actual DELETE re-runs the
  // same guard server-side so a stale UI can't slip past it.
  async function permanentlyDeletePerson(person) {
    if (!person || !window.api) return;
    if (person.status !== "deactivated") {
      // UI safety — the menu shouldn't expose this for active users,
      // but defend anyway in case someone wires it elsewhere.
      onToast && onToast("Deactivate first, then permanently delete.", 4000);
      return;
    }
    let check;
    try {
      check = await api.users.deleteCheck(person.id);
    } catch (e) {
      onToast && onToast("Couldn't check delete safety: " + ((e && e.message) || "network error"), 4500);
      return;
    }
    if (!check || !check.can_delete) {
      // User has tasks. Tell the owner exactly how many and where so
      // they know what to clean up first.
      const a = (check && check.assigned_total) || 0;
      const c = (check && check.created_total) || 0;
      alert(
        `Cannot permanently delete ${person.name}.\n\n` +
        `They are currently:\n` +
        `  • assigned to ${a} task${a === 1 ? "" : "s"}\n` +
        `  • the author of ${c} task${c === 1 ? "" : "s"}\n\n` +
        `Reassign or delete those tasks first, then try again.`
      );
      return;
    }
    // Confirm by retyping the user's first name — high-friction
    // prompt because this is irreversible. (window.prompt is more
    // friction than a single OK on window.confirm, deliberately.)
    const expected = String(person.name || "").trim().split(/\s+/)[0] || person.name;
    const typed = window.prompt(
      `Permanently delete ${person.name}?\n\n` +
      `This removes the row from the database. Their workspace and project memberships, ` +
      `favorites, personal notes, and DM history will be cleaned up automatically.\n\n` +
      `Type the user's first name (${expected}) to confirm:`,
      ""
    );
    if (typed === null) return; // cancelled
    if (typed.trim().toLowerCase() !== expected.toLowerCase()) {
      onToast && onToast("Name didn't match — delete cancelled.", 4000);
      return;
    }
    try {
      await api.users.hardDelete(person.id);
      // Drop the row from the local list. No need to call the
      // bootstrap reload — the deactivated tab will lose this entry
      // and the All counter will tick down by one.
      setPeople(ps => ps.filter(p => p.id !== person.id));
      onToast && onToast(`${person.name} permanently deleted.`, 4000);
    } catch (e) {
      const msg = (e && e.body && (e.body.message || e.body.error)) || (e && e.message) || "network error";
      alert("Couldn't permanently delete: " + msg);
    }
  }

  const seatPct = Math.round((WORKSPACE.seatsUsed / WORKSPACE.seatsTotal) * 100);

  return (
    <React.Fragment>
      <div
        className="admin-tabs"
        style={{
          margin: 0, padding: "0 28px",
          background: "var(--surface, #fff)",
          borderTop: "1px solid var(--border)",
        }}>
        {[
          ["all", "All"],
          ["admins", "Admins"],
          ["members", "Members"],
          ["guests", "Guests"],
          ["invited", "Invited"],
          ["deactivated", "Deactivated"],
        ].map(([id, label]) => (
          <button key={id} className={`admin-tab ${tab === id ? "is-active" : ""}`} onClick={() => setTab(id)}>
            {label} <span className="admin-tab-count">{counts[id]}</span>
          </button>
        ))}
      </div>

      <div className="admin-seats">
        <div className="seat-card">
          <div className="seat-card-label"><Icons.Users size={12}/> Seats used</div>
          <div className="seat-card-value">{WORKSPACE.seatsUsed} / {WORKSPACE.seatsTotal}</div>
          <div className="seat-bar"><div className="seat-bar-fill" style={{ width: `${seatPct}%` }}/></div>
          <div className="seat-card-note">{WORKSPACE.seatsTotal - WORKSPACE.seatsUsed} seats available · {WORKSPACE.plan} plan</div>
        </div>
        <div className="seat-card">
          <div className="seat-card-label"><Icons.Check size={12}/> Active members</div>
          <div className="seat-card-value">{people.filter(p => p.status === "active").length}</div>
          <div className="seat-card-note">Across {Object.keys(access).length} projects</div>
        </div>
        <div className="seat-card">
          <div className="seat-card-label"><Icons.Bell size={12}/> Pending invites</div>
          <div className="seat-card-value">{counts.invited}</div>
          <div className="seat-card-note">{counts.invited > 0 ? "Awaiting acceptance" : "No open invites"}</div>
        </div>
        <div className="seat-card">
          <div className="seat-card-label"><Icons.Calendar size={12}/> Next billing</div>
          <div className="seat-card-value" style={{ fontSize: 18 }}>{WORKSPACE.nextBill}</div>
          <div className="seat-card-note">{WORKSPACE.billingCycle[0].toUpperCase() + WORKSPACE.billingCycle.slice(1)} cycle</div>
        </div>
      </div>

      <div className="admin-filters">
        <input className="search-input" placeholder="Search by name, email, team…" value={q} onChange={e => setQ(e.target.value)}/>
        <button className="admin-filter-btn"><Icons.Filter size={13}/> Filter</button>
        <button className="admin-filter-btn"><Icons.Sort size={13}/> Sort</button>
        <button className="admin-invite-btn" onClick={onOpenInvite}><Icons.Plus size={13}/> Invite people</button>
      </div>

      <div className="admin-table-wrap">
        {selected.size > 0 && (
          <div className="admin-bulk">
            <b>{selected.size} selected</b>
            <button>Change role</button>
            <button>Add to project</button>
            <button>Resend invite</button>
            <button onClick={() => { [...selected].forEach(removePerson); }}>Deactivate</button>
            <button className="admin-bulk-close" onClick={() => setSelected(new Set())}><Icons.Close size={14}/></button>
          </div>
        )}
        <table className="admin-table">
          <thead>
            <tr>
              <th style={{ width: 28 }}>
                <input type="checkbox" checked={selected.size === filtered.length && filtered.length > 0} onChange={toggleAll}/>
              </th>
              <th>Name</th>
              <th>Workspace role</th>
              <th>Team</th>
              <th>Projects</th>
              <th>Status</th>
              <th>Last active</th>
              <th style={{ width: 80 }}></th>
            </tr>
          </thead>
          <tbody>
            {filtered.map(p => (
              <tr key={p.id} className={selected.has(p.id) ? "is-selected" : ""}>
                <td><input type="checkbox" checked={selected.has(p.id)} onChange={() => toggleSel(p.id)}/></td>
                <td>
                  <div className="user-cell">
                    <Avatar person={p} size="md"/>
                    <div className="user-cell-info">
                      <span className="user-cell-name">{p.name}</span>
                      <span className="user-cell-email">{p.email}</span>
                    </div>
                  </div>
                </td>
                <td>
                  <RolePill role={p.wsRole} onChange={(r) => changeRole(p.id, r)}/>
                </td>
                <td style={{ color: "var(--ink-muted)" }}>{p.team}</td>
                <td>
                  <button className="project-count-cell"
                    style={{ background: "transparent", border: "none", cursor: "pointer", font: "inherit", color: "var(--brand)" }}
                    onClick={() => onOpenProjectAccess(p)}>
                    {projectCount(p.id)} project{projectCount(p.id) === 1 ? "" : "s"}
                  </button>
                </td>
                <td><span className={`status-dot s-${p.status}`}>{p.status[0].toUpperCase() + p.status.slice(1)}</span></td>
                <td style={{ color: "var(--ink-muted)" }}>{p.lastActive}</td>
                <td>
                  <div className="admin-row-actions">
                    <button title="Send message"><Icons.MessageSq size={14}/></button>
                    <RowMoreMenu
                      onResetPassword={() => setPwdTarget(p)}
                      onModuleAccess={() => setAccessTarget(p)}
                      onDeactivate={p.status !== "deactivated" ? () => removePerson(p.id) : null}
                      // Permanent delete only offered AFTER the user
                      // has been deactivated — keeps active users safe
                      // from a single stray click. Server enforces the
                      // task-count guard on top of this.
                      onPermanentDelete={p.status === "deactivated" ? () => permanentlyDeletePerson(p) : null}
                    />
                  </div>
                </td>
              </tr>
            ))}
          </tbody>
        </table>
        {filtered.length === 0 && <div className="admin-empty">No people match — try a different filter</div>}
      </div>

      {pwdTarget && (
        <ResetPasswordModal
          person={pwdTarget}
          onClose={() => setPwdTarget(null)}
          onSuccess={() => onToast && onToast(`Password reset for ${pwdTarget.name.split(" ")[0]}`)}
        />
      )}
      {accessTarget && (
        <ModuleAccessModal
          person={accessTarget}
          onClose={() => setAccessTarget(null)}
          onSaved={(next) => {
            setPeople(ps => ps.map(p => p.id === accessTarget.id ? { ...p, moduleAccess: next } : p));
            onToast && onToast(`Module access updated for ${accessTarget.name.split(" ")[0]}`);
          }}
        />
      )}
    </React.Fragment>
  );
}

// ─────────────────────────────────────────────────────────────
// Row "More" popover — Reset password / Module access / Deactivate
// ─────────────────────────────────────────────────────────────
function RowMoreMenu({ onResetPassword, onModuleAccess, onDeactivate, onPermanentDelete }) {
  const [open, setOpen]   = React.useState(false);
  const [pos, setPos]     = React.useState(null); // { top, left, width, flipUp }
  const triggerRef        = React.useRef(null);
  const menuRef           = React.useRef(null);

  // Width must match what's used for both layout-time clamping AND the
  // rendered menu, so right-edge clamping doesn't stutter.
  const MENU_WIDTH = 220;
  const GAP        = 4;

  // Compute fixed-position coordinates from the trigger's bounding rect.
  // Right-aligned to the trigger (matches the original visual), clamped
  // to the viewport's right edge with an 8px gutter, and flipped above
  // the trigger when there isn't enough room below — which is what was
  // happening on the last row of the People table (the menu fell into
  // the page's scroll fold and items got clipped).
  function computePosition() {
    const t = triggerRef.current;
    if (!t) return null;
    const r  = t.getBoundingClientRect();
    const vh = window.innerHeight || document.documentElement.clientHeight;
    const vw = window.innerWidth  || document.documentElement.clientWidth;

    // Estimate the menu's natural height: items + borders + gap.
    // Used only to decide flip direction; the actual menu sizes itself.
    const itemCount = 2 + (onDeactivate ? 1 : 0) + (onPermanentDelete ? 1 : 0);
    const ESTIMATED_HEIGHT = itemCount * 36 + 12;

    const spaceBelow = vh - r.bottom;
    const flipUp     = spaceBelow < ESTIMATED_HEIGHT + GAP + 8 && r.top > spaceBelow;

    // Right edge of the trigger, then back off MENU_WIDTH so the menu's
    // right edge aligns with the trigger's right edge (legacy `right: 0`).
    let left = r.right - MENU_WIDTH;
    // Don't run off the left edge on tiny viewports.
    if (left < 8) left = 8;
    // Clamp the right side to the viewport too.
    if (left + MENU_WIDTH > vw - 8) left = vw - MENU_WIDTH - 8;

    const top = flipUp ? (r.top - ESTIMATED_HEIGHT - GAP) : (r.bottom + GAP);

    return { top, left, width: MENU_WIDTH, flipUp };
  }

  function openMenu() {
    setPos(computePosition());
    setOpen(true);
  }
  function closeMenu() { setOpen(false); }

  // Close on outside click / Escape / scroll / resize. Recomputing on
  // scroll would require continuous tracking; closing is simpler and
  // matches what most desktop apps do when the underlying surface moves.
  React.useEffect(() => {
    if (!open) return;
    function onDocPointer(e) {
      const t = triggerRef.current;
      const m = menuRef.current;
      if (t && t.contains(e.target)) return;
      if (m && m.contains(e.target)) return;
      closeMenu();
    }
    function onKey(e) { if (e.key === "Escape") closeMenu(); }
    function onScrollOrResize() { closeMenu(); }
    document.addEventListener("mousedown", onDocPointer);
    document.addEventListener("keydown", onKey);
    // Capture phase so we hear scroll events from any ancestor scroll
    // container (.admin-page, the body, anything in between).
    window.addEventListener("scroll", onScrollOrResize, true);
    window.addEventListener("resize", onScrollOrResize);
    return () => {
      document.removeEventListener("mousedown", onDocPointer);
      document.removeEventListener("keydown", onKey);
      window.removeEventListener("scroll", onScrollOrResize, true);
      window.removeEventListener("resize", onScrollOrResize);
    };
  }, [open]);

  const itemBase = {
    display: "flex", width: "100%", padding: "9px 14px",
    border: "none", background: "transparent", textAlign: "left",
    cursor: "pointer", fontSize: 13, gap: 10, alignItems: "center",
  };

  return (
    <>
      <button ref={triggerRef} title="More" onClick={() => open ? closeMenu() : openMenu()}>
        <Icons.More size={14}/>
      </button>
      {open && pos && ReactDOM.createPortal(
        <div ref={menuRef} className="row-more-menu"
             style={{
               position: "fixed",
               top: pos.top,
               left: pos.left,
               width: pos.width,
               background: "var(--surface)",
               border: "1px solid var(--border)",
               borderRadius: 10,
               boxShadow: "0 12px 28px rgba(15,23,42,.12)",
               // Was 9000 — same modal-clipping bug as RolePill below.
               // Bumped above any modal-backdrop (10000) so the
               // "..." menu lands on top whether it's opened from
               // the People table or from inside a modal.
               zIndex: 10100,
               overflow: "hidden",
             }}>
          <button className="row-more-item"
                  onClick={() => { closeMenu(); onResetPassword?.(); }}
                  style={{ ...itemBase, color: "var(--ink-strong)" }}>
            <Icons.Lock size={13}/> Reset password…
          </button>
          <button className="row-more-item"
                  onClick={() => { closeMenu(); onModuleAccess?.(); }}
                  style={{ ...itemBase, color: "var(--ink-strong)" }}>
            <Icons.Filter size={13}/> Module access…
          </button>
          {onDeactivate && (
            <button className="row-more-item"
                    onClick={() => { closeMenu(); onDeactivate?.(); }}
                    style={{ ...itemBase, color: "var(--prio-critical, #c93636)", borderTop: "1px solid var(--border)" }}>
              <Icons.Close size={13}/> Deactivate
            </button>
          )}
          {onPermanentDelete && (
            // Two-stage delete: only available AFTER deactivation
            // (removePerson sets status='deactivated', then this row
            // exposes the permanent option). Server-side guard refuses
            // when the user still has tasks attached.
            <button className="row-more-item"
                    onClick={() => { closeMenu(); onPermanentDelete?.(); }}
                    style={{
                      ...itemBase,
                      color: "var(--prio-critical, #c93636)",
                      fontWeight: 600,
                      borderTop: "1px solid var(--border)",
                      background: "rgba(226,68,92,.04)",
                    }}>
              <Icons.Trash size={13}/> Permanently delete…
            </button>
          )}
        </div>,
        document.body
      )}
    </>
  );
}

// ─────────────────────────────────────────────────────────────
// Reset password modal — admin sets a new password for any user
// ─────────────────────────────────────────────────────────────
function ResetPasswordModal({ person, onClose, onSuccess }) {
  const [pwd, setPwd]   = React.useState("");
  const [pwd2, setPwd2] = React.useState("");
  const [show, setShow] = React.useState(false);
  const [busy, setBusy] = React.useState(false);
  const [err, setErr]   = React.useState(null);
  const [copied, setCopied] = React.useState(false);

  function generate() {
    // Generate a readable but reasonably strong password (no 0/O/1/l confusion).
    const cs = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789abcdefghjkmnpqrstuvwxyz";
    let s = "";
    for (let i = 0; i < 12; i++) s += cs[Math.floor(Math.random() * cs.length)];
    setPwd(s); setPwd2(s); setShow(true); setErr(null); setCopied(false);
  }

  function copyToClipboard() {
    if (!pwd) return;
    try {
      navigator.clipboard.writeText(pwd).then(() => {
        setCopied(true);
        setTimeout(() => setCopied(false), 1500);
      });
    } catch (_) { /* ignore */ }
  }

  async function submit(e) {
    e?.preventDefault?.();
    if (busy) return;
    if (pwd.length < 6) { setErr("Use at least 6 characters."); return; }
    if (pwd !== pwd2)   { setErr("Passwords don't match.");    return; }
    setBusy(true); setErr(null);
    try {
      if (!window.api) throw new Error("api unavailable");
      await api.users.resetPassword(person.id, pwd);
      onSuccess && onSuccess();
      onClose && onClose();
    } catch (ex) {
      setErr(ex.message || "Couldn't reset password");
    } finally {
      setBusy(false);
    }
  }

  return (
    <Modal
      open={true}
      onClose={onClose}
      title="Reset password"
      subtitle={<>Set a new password for <b>{person.name}</b>. They'll need it the next time they sign in.</>}
      width={460}
      footer={
        <>
          <button type="button" className="btn-ghost" onClick={onClose}>Cancel</button>
          <button type="button" className="btn-primary" onClick={submit} disabled={busy || !pwd || !pwd2}>
            {busy ? "Saving…" : "Reset password"}
          </button>
        </>
      }
    >
      <div className="reset-pwd-form" style={{ display: "grid", gap: 14 }}>
        <div className="invite-form-row">
          <label>New password</label>
          <div style={{ display: "flex", gap: 8 }}>
            <input
              type={show ? "text" : "password"}
              value={pwd}
              autoFocus
              autoComplete="new-password"
              onChange={(e) => { setPwd(e.target.value); setErr(null); }}
              style={{ flex: 1 }}
              placeholder="At least 6 characters"
            />
            <button type="button" className="btn-ghost" onClick={() => setShow(s => !s)}>{show ? "Hide" : "Show"}</button>
          </div>
        </div>
        <div className="invite-form-row">
          <label>Confirm password</label>
          <input
            type={show ? "text" : "password"}
            value={pwd2}
            autoComplete="new-password"
            onChange={(e) => { setPwd2(e.target.value); setErr(null); }}
            placeholder="Re-enter"
          />
        </div>
        <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
          <button type="button" className="btn-ghost" onClick={generate}>Generate strong password</button>
          {pwd && (
            <button type="button" className="btn-ghost" onClick={copyToClipboard}>
              {copied ? "Copied ✓" : "Copy"}
            </button>
          )}
        </div>
        {err && <div style={{ color: "var(--prio-critical, #c93636)", fontSize: 12 }}>{err}</div>}
        <div style={{ fontSize: 12, color: "var(--ink-muted)" }}>
          Share the new password with {person.name.split(" ")[0]} via a secure channel — we don't email it for them.
        </div>
      </div>
    </Modal>
  );
}

// ─────────────────────────────────────────────────────────────
// Module access modal — toggle which sections this user can see
// ─────────────────────────────────────────────────────────────
function ModuleAccessModal({ person, onClose, onSaved }) {
  // NOTE: the "admin" / Admin panel toggle was removed — People &
  // permissions is now strictly owner-only (not a per-user toggle),
  // so a switch here was misleading. Owners always have it; nobody
  // else ever does.
  const MODULES = [
    { key: "my_work",    label: "My work",      sub: "Personal dashboard, today's focus" },
    { key: "projects",   label: "Projects",     sub: "Workspaces, sprints, tasks, kanban" },
    { key: "dashboards", label: "Dashboards",   sub: "Charts, velocity, burndown" },
    { key: "crm",        label: "CRM",          sub: "Leads pipeline, Meta integration" },
    { key: "people",     label: "People",       sub: "Attendance, leave, schedules · also hides the floating punch widget" },
    { key: "support",    label: "Support center", sub: "Customer tickets, plans, ticket types — opt-in per agent" },
  ];
  // `support` is the only opt-in module — everything else defaults ON
  // (a missing override = full access). Support defaults OFF unless the
  // admin has explicitly turned it on in module_access.
  const initial = React.useMemo(() => {
    const cur = person.moduleAccess || null;
    const o = {};
    for (const m of MODULES) {
      if (m.key === "support") {
        o[m.key] = !!(cur && cur.support === true);
      } else {
        o[m.key] = !cur || cur[m.key] !== false;
      }
    }
    return o;
  }, [person]);
  const [state, setState] = React.useState(initial);
  const [busy, setBusy]   = React.useState(false);
  const [err,  setErr]    = React.useState(null);

  // Per-agent project scope — fetched + saved when support is on. An
  // empty assigned set = "all projects" (default).
  const [allProjects, setAllProjects] = React.useState([]);
  const [scopeIds, setScopeIds] = React.useState(null);   // null = loading
  const [scopeOriginal, setScopeOriginal] = React.useState(null);

  React.useEffect(() => {
    let cancelled = false;
    api.support.listSupportProjects().then(rows => {
      if (!cancelled) setAllProjects(rows || []);
    }).catch(() => setAllProjects([]));
    api.support.listAgentProjects(person.id).then(rows => {
      if (cancelled) return;
      const ids = (rows || []).map(r => r.id);
      setScopeIds(ids);
      setScopeOriginal(ids.slice());
    }).catch(() => { if (!cancelled) { setScopeIds([]); setScopeOriginal([]); } });
    return () => { cancelled = true; };
  }, [person.id]);

  function toggle(k) { setState(s => ({ ...s, [k]: !s[k] })); }
  function toggleProject(pid) {
    setScopeIds(arr => (arr || []).includes(pid) ? arr.filter(x => x !== pid) : [...(arr || []), pid]);
  }
  function scopeDirty() {
    if (!scopeOriginal || !scopeIds) return false;
    if (scopeIds.length !== scopeOriginal.length) return true;
    const set = new Set(scopeOriginal);
    return scopeIds.some(id => !set.has(id));
  }

  async function save() {
    if (busy) return;
    setBusy(true); setErr(null);
    // Default state: every standard module on, support off. If we're at
    // that exact state, send null so the column goes back to NULL (=
    // "use defaults"). Otherwise send the explicit map.
    const isDefault = MODULES.every(m =>
      m.key === "support" ? !state[m.key] : state[m.key]
    );
    const payload = isDefault ? null : { ...state };
    try {
      if (!window.api) throw new Error("api unavailable");
      const r = await api.users.setModuleAccess(person.id, payload);
      // If support is on AND the project scope changed, persist that too.
      if (state.support && scopeIds && scopeDirty()) {
        try { await api.support.setAgentProjects(person.id, scopeIds); }
        catch (e) { console.warn("agent project scope save failed:", e.message); }
      }
      onSaved && onSaved(r && r.module_access ? r.module_access : null);
      onClose && onClose();
    } catch (ex) {
      setErr(ex.message || "Couldn't save");
    } finally {
      setBusy(false);
    }
  }

  return (
    <Modal
      open={true}
      onClose={onClose}
      title="Module access"
      subtitle={<>Choose which sections <b>{person.name}</b> can see in the sidebar.</>}
      width={520}
      footer={
        <>
          <button type="button" className="btn-ghost" onClick={onClose}>Cancel</button>
          <button type="button" className="btn-primary" onClick={save} disabled={busy}>
            {busy ? "Saving…" : "Save access"}
          </button>
        </>
      }
    >
      <div className="module-access-list" style={{ display: "grid", gap: 8 }}>
        {MODULES.map(m => {
          const on = !!state[m.key];
          return (
            <label key={m.key} className={`module-row ${on ? "is-on" : ""}`}
              style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12,
                       padding: "12px 14px", border: "1px solid " + (on ? "var(--brand)" : "var(--border)"),
                       borderRadius: 10, background: on ? "var(--brand-soft, #e8f1ff)" : "var(--surface, #fff)",
                       cursor: "pointer", transition: "background .12s, border-color .12s" }}>
              <div>
                <div style={{ fontSize: 13, fontWeight: 700, color: "var(--ink-strong)" }}>{m.label}</div>
                <div style={{ fontSize: 12, color: "var(--ink-muted)", marginTop: 2 }}>{m.sub}</div>
              </div>
              <span style={{ position: "relative", width: 38, height: 22, flexShrink: 0,
                             background: on ? "var(--brand)" : "#cbd5e1", borderRadius: 11, transition: "background .15s" }}>
                <input type="checkbox" checked={on} onChange={() => toggle(m.key)}
                  style={{ position: "absolute", inset: 0, opacity: 0, cursor: "pointer" }}/>
                <span style={{ position: "absolute", top: 2, left: on ? 18 : 2, width: 18, height: 18,
                               borderRadius: "50%", background: "#fff",
                               boxShadow: "0 1px 2px rgba(0,0,0,.18)", transition: "left .15s" }}/>
              </span>
            </label>
          );
        })}
        {/* Per-agent project scope — only shown when support access is on. */}
        {state.support && (
          <div style={{ marginTop: 4, padding: "12px 14px",
                        border: "1px solid var(--border)", borderRadius: 10,
                        background: "var(--bg-subtle, #f7f8fb)" }}>
            <div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", marginBottom: 8 }}>
              <div style={{ fontSize: 13, fontWeight: 700, color: "var(--ink-strong)" }}>
                Support — project access
              </div>
              <span style={{ fontSize: 11.5, color: "var(--ink-muted)" }}>
                {scopeIds === null
                  ? "Loading…"
                  : scopeIds.length === 0
                  ? "All projects (no restriction)"
                  : `${scopeIds.length} of ${allProjects.length} selected`}
              </span>
            </div>
            <div style={{ fontSize: 11.5, color: "var(--ink-muted)", marginBottom: 8 }}>
              Tick the projects whose tickets this agent can see. Leave everything unticked to give them access to <b>all</b> projects.
            </div>
            <div style={{ display: "flex", flexWrap: "wrap", gap: 6, maxHeight: 160, overflow: "auto" }}>
              {allProjects.length === 0
                ? <div style={{ fontSize: 12, color: "var(--ink-faint)", padding: 6 }}>
                    No projects in this workspace yet.
                  </div>
                : allProjects.map(p => {
                    const on = !!(scopeIds && scopeIds.includes(p.id));
                    return (
                      <label key={p.id}
                             style={{
                               display: "inline-flex", alignItems: "center", gap: 6,
                               fontSize: 12, color: on ? "var(--brand-dark)" : "var(--ink-body)",
                               padding: "4px 10px",
                               border: "1px solid " + (on ? "var(--brand)" : "var(--border)"),
                               borderRadius: 999,
                               background: on ? "var(--brand-soft, #e8f1ff)" : "white",
                               cursor: "pointer", userSelect: "none",
                             }}>
                        <input type="checkbox" checked={on}
                               onChange={() => toggleProject(p.id)}
                               style={{ display: "none" }}/>
                        <span style={{ width: 8, height: 8, borderRadius: "50%",
                                       background: p.color || "var(--brand)" }}/>
                        {p.name}
                      </label>
                    );
                  })}
            </div>
          </div>
        )}

        {err && <div style={{ color: "var(--prio-critical, #c93636)", fontSize: 12 }}>{err}</div>}
        <div style={{ fontSize: 12, color: "var(--ink-muted)" }}>
          Owners always have full access. The user may need to refresh the page for changes to take effect.
        </div>
      </div>
    </Modal>
  );
}

// ─────────────────────────────────────────────────────────────
// Role pill w/ dropdown
// ─────────────────────────────────────────────────────────────
// Module-level singleton so only one RolePill dropdown is open at a
// time. Clicking a second pill before the first closes was leaving
// both menus on screen — they piled on top of each other and the
// click-outside handler attached to the first one never fired (the
// click was on the second pill's trigger, which the first handler
// recognised as "not me, but not interesting either").
let _rolePillCloseActive = null;

function RolePill({ role, options = WS_ROLES, onChange }) {
  const [open, setOpen] = React.useState(false);
  const [pos, setPos]   = React.useState(null);   // { top, left, width, flipUp }
  const triggerRef      = React.useRef(null);
  const menuRef         = React.useRef(null);

  // Width tuned to fit the longest "Owner — full workspace control"
  // description without wrapping. Used for both layout and clamp math.
  const MENU_WIDTH = 280;
  const GAP        = 4;

  // Same portal + flip strategy as RowMoreMenu — needed because the
  // RolePill is rendered inside .admin-table { overflow: hidden } and
  // .admin-page { overflow: auto }, AND inside Modal bodies (also
  // overflow: hidden), so a position: absolute dropdown gets clipped
  // every single time it's opened on a row that's near a container
  // edge. Rendering into document.body via a portal at viewport-fixed
  // coordinates side-steps every clipping ancestor.
  function computePosition() {
    const t = triggerRef.current;
    if (!t) return null;
    const r  = t.getBoundingClientRect();
    const vh = window.innerHeight || document.documentElement.clientHeight;
    const vw = window.innerWidth  || document.documentElement.clientWidth;

    // Item height + per-option subtitle pushes a 4-option menu to ~70px
    // per option × 4 ≈ 280px. We use 70 × N + 12 padding as the
    // estimate for flip detection.
    const ESTIMATED_HEIGHT = options.length * 70 + 12;

    const spaceBelow = vh - r.bottom;
    const flipUp     = spaceBelow < ESTIMATED_HEIGHT + GAP + 8 && r.top > spaceBelow;

    // Right-align the dropdown to the trigger's right edge. The role
    // pill is small (~80px) but the dropdown is 280px wide; left-
    // aligning shoots the menu off the trigger to the right and
    // (inside the Project Access modal especially) past the modal's
    // right edge entirely. Anchoring to the trigger's right keeps the
    // menu visually attached to the pill and stays inside the modal
    // box. Clamp to the viewport so the menu can never run off screen.
    let left = r.right - MENU_WIDTH;
    if (left + MENU_WIDTH > vw - 8) left = vw - MENU_WIDTH - 8;
    if (left < 8) left = 8;

    const top = flipUp ? (r.top - ESTIMATED_HEIGHT - GAP) : (r.bottom + GAP);
    return { top, left, width: MENU_WIDTH, flipUp };
  }

  function openMenu() {
    // Force-close any other RolePill that's already open. Clicking
    // a second pill while the first is open used to leave both
    // dropdowns on screen — the first one's click-outside handler
    // didn't always recognise the sibling pill's trigger as a
    // "close me" event. The module-level _rolePillCloseActive
    // singleton (defined above the component) serialises the open
    // state so only one menu can be visible at any moment.
    if (_rolePillCloseActive && _rolePillCloseActive !== closeMenu) {
      try { _rolePillCloseActive(); } catch {}
    }
    _rolePillCloseActive = closeMenu;
    setPos(computePosition());
    setOpen(true);
  }
  function closeMenu() {
    if (_rolePillCloseActive === closeMenu) _rolePillCloseActive = null;
    setOpen(false);
  }

  React.useEffect(() => {
    if (!open) return;
    function onDocPointer(e) {
      const t = triggerRef.current;
      const m = menuRef.current;
      if (t && t.contains(e.target)) return;
      if (m && m.contains(e.target)) return;
      closeMenu();
    }
    function onKey(e) { if (e.key === "Escape") closeMenu(); }
    function onScrollOrResize() { closeMenu(); }
    document.addEventListener("mousedown", onDocPointer);
    document.addEventListener("keydown", onKey);
    window.addEventListener("scroll", onScrollOrResize, true);
    window.addEventListener("resize", onScrollOrResize);
    return () => {
      document.removeEventListener("mousedown", onDocPointer);
      document.removeEventListener("keydown", onKey);
      window.removeEventListener("scroll", onScrollOrResize, true);
      window.removeEventListener("resize", onScrollOrResize);
    };
  }, [open]);

  const cur = options.find(r => r.id === role) || options[0];

  return (
    <>
      <button ref={triggerRef}
              className={`role-pill role-${role}`}
              onClick={() => open ? closeMenu() : openMenu()}>
        {cur.label}
        <Icons.ChevronSm size={11}/>
      </button>
      {open && pos && ReactDOM.createPortal(
        <div ref={menuRef}
             style={{
               position: "fixed",
               top: pos.top,
               left: pos.left,
               width: pos.width,
               background: "var(--surface)",
               border: "1px solid var(--border)",
               borderRadius: 8,
               boxShadow: "0 8px 24px rgba(0,0,0,.12)",
               // Was 9000 — sat under the .modal-backdrop (z-index
               // 10000). When the RolePill is rendered inside a
               // modal (e.g. the Project Access modal's member
               // rows), the dropdown disappeared behind the modal
               // backdrop and was unclickable. Bumped above any
               // modal so it renders on top regardless of context.
               zIndex: 10100,
               overflow: "hidden",
             }}>
          {options.map(r => (
            <div key={r.id}
              onClick={() => { onChange?.(r.id); closeMenu(); }}
              style={{ padding: "8px 12px", cursor: "pointer", borderBottom: "1px solid var(--border)", background: role === r.id ? "var(--brand-faint)" : "transparent" }}>
              <div style={{ fontSize: 13, fontWeight: 700, color: "var(--ink)" }}>{r.label}</div>
              <div style={{ fontSize: 11, color: "var(--ink-muted)", marginTop: 2 }}>{r.desc}</div>
            </div>
          ))}
        </div>,
        document.body
      )}
    </>
  );
}

// ─────────────────────────────────────────────────────────────
// Invite modal
// ─────────────────────────────────────────────────────────────
function InviteModal({ open, onClose, onInvite }) {
  // Display name field added (May 2026). When the admin invites ONE
  // person, this is used verbatim. When inviting multiple at once,
  // the typed name is ignored (we'd otherwise apply the same name
  // to every row) and per-email auto-derivation kicks in instead —
  // the placeholder spells that out so the admin isn't confused.
  const [name, setName]     = React.useState("");
  const [emails, setEmails] = React.useState("");
  const [wsRole, setWsRole] = React.useState("member");
  const [projects, setProjects] = React.useState(new Set(["checkout"]));
  const [msg, setMsg] = React.useState("Welcome to Acme Labs — you've been invited to collaborate on a few projects. Click the link in the email to get started.");

  // Reset whenever the modal re-opens so a stale name/email from a
  // previous invite doesn't bleed into the next one.
  React.useEffect(() => {
    if (!open) return;
    setName(""); setEmails(""); setWsRole("member");
    setProjects(new Set(["checkout"]));
  }, [open]);

  if (!open) return null;
  const emailList = emails.split(/[\s,]+/).filter(e => e.includes("@"));
  const isSingle  = emailList.length === 1;
  const trimmedName = name.trim();

  function toggleProj(id) {
    setProjects(s => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
  }

  return (
    <Modal open={open} onClose={onClose} title="Invite people" subtitle="Send an email invite and assign them to projects" width={560} footer={
      <>
        <button className="btn-ghost" onClick={onClose}>Cancel</button>
        <button className="btn-primary"
                disabled={emailList.length === 0}
                onClick={() => onInvite({
                  // Only forward the typed name when inviting a
                  // single person — otherwise we'd send the same
                  // name on every row.
                  name: isSingle ? trimmedName : "",
                  emails: emailList,
                  wsRole, projects: [...projects], msg
                })}>
          Send {emailList.length > 0 ? emailList.length : ""} invite{emailList.length === 1 ? "" : "s"}
        </button>
      </>
    }>
      <div className="invite-modal">
        <div className="invite-form-row">
          <label>Display name {isSingle ? "" : <span style={{ color: "var(--ink-muted)", fontWeight: 500 }}>(used when inviting one person)</span>}</label>
          <input
            type="text"
            value={name}
            onChange={e => setName(e.target.value)}
            placeholder={
              isSingle
                ? "e.g. Priya Ramesh"
                : "Auto-generated from each email when inviting multiple"
            }
            disabled={!isSingle && emailList.length > 1}
          />
          {isSingle && !trimmedName && (
            <div style={{ fontSize: 11, color: "var(--ink-muted)", marginTop: 4 }}>
              Optional — we'll derive a name from the email if you leave this blank.
            </div>
          )}
        </div>

        <div className="invite-form-row">
          <label>Email addresses</label>
          <textarea rows={2} value={emails} onChange={e => setEmails(e.target.value)}
            placeholder="jane@acme.co, dev@contractor.io, …"/>
          <div style={{ fontSize: 11, color: "var(--ink-muted)", marginTop: 4 }}>
            {emailList.length > 0 ? `${emailList.length} valid email${emailList.length === 1 ? "" : "s"}` : "Separate multiple emails with commas or spaces"}
          </div>
        </div>

        <div className="invite-form-row">
          <label>Workspace role</label>
          <div className="invite-role-picker">
            {WS_ROLES.filter(r => r.id !== "owner").map(r => (
              <div key={r.id} className={`invite-role-card ${wsRole === r.id ? "is-active" : ""}`} onClick={() => setWsRole(r.id)}>
                <div className="invite-role-card-title">{r.label}</div>
                <div className="invite-role-card-desc">{r.desc}</div>
              </div>
            ))}
          </div>
        </div>

        <div className="invite-form-row">
          <label>Add to projects</label>
          <div className="invite-project-list">
            {PROJECTS.map(p => (
              <div key={p.id} className="invite-project-row" onClick={() => toggleProj(p.id)}>
                <input type="checkbox" checked={projects.has(p.id)} onChange={() => {}}/>
                <span className="invite-project-row-dot" style={{ background: p.color }}/>
                <span className="invite-project-row-name">{p.name}</span>
                <span className="invite-project-row-access">{PROJECT_ACCESS[p.id]?.visibility === "private" ? "🔒 Private" : "Workspace"}</span>
              </div>
            ))}
          </div>
        </div>

        <div className="invite-form-row">
          <label>Personal message (optional)</label>
          <textarea rows={3} value={msg} onChange={e => setMsg(e.target.value)}/>
        </div>
      </div>
    </Modal>
  );
}

// ─────────────────────────────────────────────────────────────
// Project Access modal — per-project member management
// ─────────────────────────────────────────────────────────────
function ProjectAccessModal({ open, onClose, projectId, access, setAccess, people, onToast }) {
  const [addOpen, setAddOpen] = React.useState(false);
  const [addQ, setAddQ] = React.useState("");
  const [teamPickerOpen, setTeamPickerOpen] = React.useState(false);
  if (!open || !projectId) return null;
  const project = PROJECTS.find(p => p.id === projectId);
  const a = access[projectId] || { visibility: "workspace", owner: null, members: [] };
  const memberIds = new Set(a.members.map(m => m.id));
  const availableToAdd = people.filter(p => !memberIds.has(p.id) && p.status !== "deactivated" &&
    (addQ ? (p.name.toLowerCase().includes(addQ.toLowerCase()) || p.email.toLowerCase().includes(addQ.toLowerCase())) : true));

  function setViz(v) {
    const prevAccess = access;
    setAccess(prev => {
      const cur = prev[projectId] || a;
      return { ...prev, [projectId]: { ...cur, visibility: v } };
    });
    if (window.api) {
      api.projects.patch(projectId, { visibility: v })
        .then(() => onToast && onToast(v === "private" ? "Project set to Private" : "Project set to Workspace"))
        .catch(e => {
          setAccess(prevAccess);
          onToast && onToast("Couldn't change visibility: " + (e.message || "network error"), 4000);
        });
    }
  }
  function changeMemberRole(id, role) {
    const prevAccess = access;
    setAccess(prev => {
      const cur = prev[projectId] || a;
      return {
        ...prev,
        [projectId]: { ...cur, members: (cur.members || []).map(m => m.id === id ? { ...m, role } : m) },
      };
    });
    if (window.api) {
      api.projects.patchMember(projectId, id, { role })
        .then(() => onToast && onToast("Member role updated"))
        .catch(e => {
          setAccess(prevAccess);
          onToast && onToast("Couldn't change role: " + (e.message || "network error"), 4000);
        });
    }
  }
  function removeMember(id) {
    // Snapshot the removed row so Undo can re-add it with the same
    // role rather than the server's default. The whole `access` map
    // is captured too as a hard rollback if the server delete fails.
    const removed = (a.members || []).find(m => m.id === id);
    if (!removed) return;
    const prevAccess = access;
    // Use the functional updater's `prev` rather than the closure's
    // `a` so rapid back-to-back removes (or any state update that
    // races with this one) start from the freshest member list. This
    // avoids "deleted user reappears" flicker and the apparent "wait"
    // state that came from later setStates restoring stale members.
    setAccess(prev => {
      const cur = prev[projectId] || a;
      return {
        ...prev,
        [projectId]: { ...cur, members: (cur.members || []).filter(m => m.id !== id) },
      };
    });
    if (!window.api) return;

    // Track whether undo fired so the server-success toast doesn't
    // race past the undo + re-add round-trip and overwrite it.
    let undone = false;
    function undo() {
      if (undone) return;
      undone = true;
      // Optimistic restore — flip the local list back immediately.
      // Guard against the row already being present (e.g. SSE echo
      // arrived first) so we never duplicate the entry.
      setAccess(prev => {
        const cur = prev[projectId] || a;
        const members = cur.members || [];
        if (members.some(m => m.id === removed.id)) {
          return prev;
        }
        return {
          ...prev,
          [projectId]: { ...cur, members: [...members, removed] },
        };
      });
      // Re-add on the server with the same role. If this fails, the
      // optimistic restore already reflects the user's intent; we
      // surface the error and the next ProjectAccessModal render
      // refetches the canonical list.
      api.projects.addMember(projectId, { user_id: id, role: removed.role || "editor" })
        .then(() => onToast && onToast(`${_displayNameOf(removed, people)} restored`))
        .catch(e => onToast && onToast("Couldn't undo: " + (e.message || "network error"), 4000));
    }

    api.projects.removeMember(projectId, id)
      .then(() => {
        if (undone) return;   // Undo fired before the server replied — skip the toast
        // Wrap the toast call in try/catch — if the toast surface
        // ever blows up while rendering an action chip, we don't
        // want the whole modal to white-screen behind it. Falls
        // back to the plain string toast (no Undo) so the user at
        // least sees confirmation that the delete went through.
        try {
          if (typeof window.fbToast === "function") {
            window.fbToast({
              msg: `Removed ${_displayNameOf(removed, people)}`,
              ms: 5000,
              action: { label: "Undo", onClick: undo },
            }, 5000);
          } else if (onToast) {
            onToast(`Removed ${_displayNameOf(removed, people)}`);
          }
        } catch (toastErr) {
          console.warn("[member-remove] toast failed:", toastErr);
          if (onToast) onToast(`Removed ${_displayNameOf(removed, people)}`);
        }
      })
      .catch(e => {
        // Server refused — roll the local list back so the UI matches
        // the server, then surface the error.
        try { setAccess(prevAccess); } catch {}
        if (onToast) onToast("Couldn't remove member: " + (e.message || "network error"), 4000);
      });
  }
  function addMember(id) {
    const prevAccess = access;
    setAccess(prev => {
      const cur = prev[projectId] || a;
      const members = cur.members || [];
      // De-dupe in case the row is already present (SSE race) so the
      // optimistic add doesn't double the user.
      if (members.some(m => m.id === id)) return prev;
      return { ...prev, [projectId]: { ...cur, members: [...members, { id, role: "editor" }] } };
    });
    setAddQ("");
    setAddOpen(false);
    if (window.api) {
      api.projects.addMember(projectId, { user_id: id, role: "editor" })
        .then(() => onToast && onToast("Member added"))
        .catch(e => {
          setAccess(prevAccess);
          onToast && onToast("Couldn't add member: " + (e.message || "network error"), 4000);
        });
    }
  }

  return (
    <Modal open={open} onClose={onClose} title="" width={620} footer={
      <>
        <span style={{ flex: 1, fontSize: 12, color: "var(--ink-muted)" }}>
          Changes are saved automatically
        </span>
        <button className="btn-primary" onClick={onClose}>Done</button>
      </>
    }>
      <div className="access-panel" style={{ width: "auto", maxHeight: "60vh" }}>
        <div className="access-header" style={{ padding: 0, borderBottom: "none", marginBottom: 12 }}>
          <div className="access-header-dot" style={{ background: project?.color }}/>
          <div style={{ flex: 1 }}>
            <div className="access-header-title">{project?.name}</div>
            <div className="access-header-sub">Manage who can see and edit this project</div>
            <div className="access-visibility-row">
              <button className={`access-viz-pill ${a.visibility === "workspace" ? "is-active" : ""}`} onClick={() => setViz("workspace")}>
                <Icons.Users size={12}/> Workspace-wide
              </button>
              <button className={`access-viz-pill ${a.visibility === "private" ? "is-active" : ""}`} onClick={() => setViz("private")}>
                🔒 Private
              </button>
            </div>
            <div style={{ fontSize: 11, color: "var(--ink-muted)", marginTop: 8, lineHeight: 1.5 }}>
              {a.visibility === "workspace"
                ? "Everyone in the workspace can view this project. Members below have explicit elevated roles."
                : "Only members listed below can see this project. Admins and Owners always have access."}
            </div>
          </div>
        </div>

        <div style={{ display: "flex", gap: 8, marginBottom: 10 }}>
          <div style={{ position: "relative", flex: 1 }}>
            <input
              className="search-input"
              style={{ width: "100%", paddingLeft: 32 }}
              placeholder="Add a person by name or email…"
              value={addQ}
              onChange={e => { setAddQ(e.target.value); setAddOpen(true); }}
              onFocus={() => setAddOpen(true)}
            />
            {addOpen && addQ && availableToAdd.length > 0 && (
              <div className="access-add-suggestions" style={{ position: "absolute", top: "100%", left: 0, right: 0, marginTop: 4 }}>
                {availableToAdd.slice(0, 6).map(p => (
                  <div key={p.id} className="access-suggestion" onClick={() => addMember(p.id)}>
                    <Avatar person={p} size="md"/>
                    <div className="access-suggestion-info">
                      <div className="access-suggestion-name">{p.name}</div>
                      <div className="access-suggestion-email">{p.email}</div>
                    </div>
                    <Icons.Plus size={14} style={{ color: "var(--brand)" }}/>
                  </div>
                ))}
              </div>
            )}
          </div>
          <button
            className="admin-invite-btn"
            onClick={() => setTeamPickerOpen(true)}
            title="Add a whole team to this project"
            style={{ whiteSpace: "nowrap" }}>
            <Icons.Users size={13}/> Add team
          </button>
        </div>

        <div style={{ fontSize: 11, fontWeight: 700, color: "var(--ink-muted)", textTransform: "uppercase", letterSpacing: ".06em", marginBottom: 6 }}>
          Members · {a.members.length}
        </div>
        <div className="access-members-list" style={{ border: "1px solid var(--border)", borderRadius: 8, maxHeight: 320 }}>
          {a.members.map(m => {
            const p = people.find(x => x.id === m.id);
            if (!p) return null;
            return (
              <div key={m.id} className="access-member-row">
                <Avatar person={p} size="md"/>
                <div className="access-member-info">
                  <span className="access-member-name">{p.name} {m.id === a.owner && <span style={{ fontSize: 10, color: "#a25ddc", marginLeft: 4 }}>★ OWNER</span>}</span>
                  <span className="access-member-email">{p.email}</span>
                </div>
                <RolePill role={m.role} options={PROJECT_ROLES} onChange={(r) => changeMemberRole(m.id, r)}/>
                {m.id !== a.owner && (
                  <button className="access-member-remove" onClick={() => removeMember(m.id)} title="Remove">
                    <Icons.Close size={14}/>
                  </button>
                )}
              </div>
            );
          })}
          {a.members.length === 0 && (
            <div style={{ padding: 24, textAlign: "center", color: "var(--ink-muted)", fontSize: 13 }}>
              No members yet — add someone above
            </div>
          )}
        </div>
      </div>
      {teamPickerOpen && (
        <AddTeamToProjectModal
          projectId={projectId}
          people={people}
          onClose={() => setTeamPickerOpen(false)}
          onAdded={(team, result) => {
            setTeamPickerOpen(false);
            // Surface the per-team breakdown immediately, then re-fetch
            // the canonical member list from the server. The server is
            // the source of truth (especially when overwrite=true bumps
            // existing roles), so we avoid trying to mirror its decision
            // tree client-side.
            const added = result.added || 0;
            const updated = result.updated || 0;
            const kept = result.kept || 0;
            const parts = [];
            if (added) parts.push(`${added} added`);
            if (updated) parts.push(`${updated} updated`);
            if (kept) parts.push(`${kept} already on project`);
            onToast && onToast(`${team.name}: ${parts.join(" · ") || "no changes"}`);
            if (window.api) {
              api.projects.members(projectId)
                .then(rows => {
                  const norm = (rows || []).map(r => ({
                    id: r.user_id || r.id,
                    role: r.role,
                  }));
                  setAccess(prev => ({
                    ...prev,
                    [projectId]: { ...(prev[projectId] || a), members: norm },
                  }));
                })
                .catch(() => {});
            }
          }}
          onError={msg => onToast && onToast(msg, 4500)}
        />
      )}
    </Modal>
  );
}

// ─────────────────────────────────────────────────────────────
// User peek — popover on avatar click
// ─────────────────────────────────────────────────────────────
function UserPeek({ person, anchor, onClose, access, onOpenAdmin }) {
  const ref = React.useRef(null);
  React.useEffect(() => {
    function onDoc(e) { if (ref.current && !ref.current.contains(e.target) && (!anchor || !anchor.contains(e.target))) onClose(); }
    document.addEventListener("mousedown", onDoc);
    return () => document.removeEventListener("mousedown", onDoc);
  }, [anchor, onClose]);
  if (!person) return null;

  const rect = anchor?.getBoundingClientRect();
  const style = rect ? { position: "fixed", top: rect.bottom + 6, left: Math.max(12, Math.min(rect.left, window.innerWidth - 340)), zIndex: 2000 }
                     : { position: "fixed", top: 80, left: 200, zIndex: 2000 };

  const personProjects = Object.entries(access)
    .filter(([, a]) => a.members.some(m => m.id === person.id))
    .map(([pid, a]) => ({
      project: PROJECTS.find(p => p.id === pid),
      role: a.members.find(m => m.id === person.id).role,
    }))
    .filter(x => x.project);

  const openTasks = ALL_TASKS.filter(t => t.owners.includes(person.id) && t.status !== "done").length;

  return ReactDOM.createPortal(
    <div ref={ref} className="user-peek" style={style}>
      <div className="user-peek-header">
        <Avatar person={person} size="xl"/>
        <div>
          <div className="user-peek-name">{person.name}</div>
          <div className="user-peek-title">{person.title}</div>
          <div style={{ marginTop: 4 }}>
            <span className={`role-pill role-${person.wsRole}`}>{WS_ROLES.find(r => r.id === person.wsRole)?.label}</span>
          </div>
        </div>
      </div>
      <div className="user-peek-meta">
        <span className="user-peek-meta-label">Email</span><span style={{ color: "var(--ink)" }}>{person.email}</span>
        <span className="user-peek-meta-label">Team</span><span style={{ color: "var(--ink)" }}>{person.team}</span>
        <span className="user-peek-meta-label">Open tasks</span><span style={{ color: "var(--ink)", fontWeight: 600 }}>{openTasks}</span>
        <span className="user-peek-meta-label">Last active</span><span style={{ color: "var(--ink-muted)" }}>{person.lastActive}</span>
      </div>
      <div className="user-peek-projects">
        <div className="user-peek-projects-label">Projects · {personProjects.length}</div>
        <div>
          {personProjects.map(({ project, role }) => (
            <span key={project.id} className="user-peek-project-chip" title={`${PROJECT_ROLES.find(r => r.id === role)?.label}`}>
              <span className="user-peek-project-chip-dot" style={{ background: project.color }}/>
              {project.name}
            </span>
          ))}
          {personProjects.length === 0 && <span style={{ fontSize: 11, color: "var(--ink-muted)" }}>No projects</span>}
        </div>
      </div>
      <div className="user-peek-footer">
        <button><Icons.MessageSq size={12}/> Message</button>
        <button onClick={onOpenAdmin}><Icons.Users size={12}/> Manage</button>
      </div>
    </div>,
    document.body
  );
}

// ─────────────────────────────────────────────────────────────
// Acting-as switcher (topbar)
// ─────────────────────────────────────────────────────────────
function ActingAs({ currentUserId, onChange, people }) {
  const [open, setOpen] = React.useState(false);
  const ref = React.useRef(null);
  React.useEffect(() => {
    function onDoc(e) { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }
    document.addEventListener("mousedown", onDoc);
    return () => document.removeEventListener("mousedown", onDoc);
  }, []);
  const cur = people.find(p => p.id === currentUserId) || people[0];
  // Show every active workspace member, sorted by role (owner →
  // admin → member → guest) and then by name. Previously this list
  // was hard-coded to "one of each role" which left most teams out
  // of the picker.
  const ROLE_ORDER = { owner: 0, admin: 1, member: 2, guest: 3 };
  const options = (people || [])
    .filter(p => p && p.id && p.status !== "deactivated")
    .filter((p, i, arr) => arr.findIndex(x => x.id === p.id) === i) // dedupe
    .sort((a, b) => {
      const ra = ROLE_ORDER[a.wsRole] ?? 99;
      const rb = ROLE_ORDER[b.wsRole] ?? 99;
      if (ra !== rb) return ra - rb;
      return String(a.name || "").localeCompare(String(b.name || ""));
    });

  return (
    <div ref={ref} className="acting-as" onClick={() => setOpen(o => !o)}>
      <Avatar person={cur} size="md"/>
      <div style={{ display: "flex", flexDirection: "column", lineHeight: 1.1 }}>
        <span className="acting-as-label">Acting as</span>
        <span className="acting-as-name">{cur.name.split(" ")[0]}</span>
      </div>
      <Icons.ChevronSm size={12} style={{ color: "var(--ink-muted)" }}/>
      {open && (
        <div className="acting-as-menu" onMouseDown={e => e.stopPropagation()}>
          <div className="acting-as-menu-label">
            Switch user (demo)
            <span style={{ marginLeft: 6, color: "var(--ink-muted)", fontWeight: 500 }}>
              · {options.length}
            </span>
          </div>
          <div style={{ maxHeight: 360, overflowY: "auto" }}>
            {options.map(p => (
              <div key={p.id} className={`acting-as-option ${p.id === currentUserId ? "is-active" : ""}`}
                   onClick={() => { onChange(p.id); setOpen(false); }}>
                <Avatar person={p} size="md"/>
                <div className="acting-as-option-info">
                  <div className="acting-as-option-name">{p.name}</div>
                  <div className="acting-as-option-role">{WS_ROLES.find(r => r.id === p.wsRole)?.label || p.wsRole || "—"}{p.team ? " · " + p.team : ""}</div>
                </div>
                {p.id === currentUserId && <Icons.Check size={14} style={{ color: "var(--brand)" }}/>}
              </div>
            ))}
          </div>
          <div style={{ padding: "8px 14px", borderTop: "1px solid var(--border)", fontSize: 11, color: "var(--ink-muted)" }}>
            The sidebar & project list will update to match this user's access.
          </div>
        </div>
      )}
    </div>
  );
}

// ─────────────────────────────────────────────────────────────
// TeamsAdminPanel — admin/owner-only management of user teams.
//
// Teams are bundles of workspace users that admins can drop into a
// project in one click. Backed by routes/teams.routes.js — list is
// open to all members but mutations are gated server-side. We keep
// the team list in local state and refresh on every successful
// mutation rather than wiring the bootstrap payload, because teams
// are admin-only data that the rest of the app doesn't need.
// ─────────────────────────────────────────────────────────────
function TeamsAdminPanel({ people, onToast }) {
  const [teams, setTeams]   = React.useState(null); // null = loading; [] = empty
  const [editing, setEditing] = React.useState(null); // null | {} (new) | {team object}
  const [q, setQ] = React.useState("");

  const loadTeams = React.useCallback(async () => {
    try {
      const list = await api.teams.list();
      setTeams(Array.isArray(list) ? list : []);
    } catch (e) {
      onToast && onToast("Couldn't load teams: " + (e.message || "network error"), 4000);
      setTeams([]);
    }
  }, [onToast]);

  React.useEffect(() => { loadTeams(); }, [loadTeams]);

  const filtered = (teams || []).filter(t =>
    !q.trim() || t.name.toLowerCase().includes(q.trim().toLowerCase()) ||
    (t.description || "").toLowerCase().includes(q.trim().toLowerCase()));

  async function deleteTeam(t) {
    if (!window.confirm(`Delete team "${t.name}"?\n\nThis only removes the team grouping — users themselves are not affected, and existing project memberships stay intact.`)) return;
    try {
      await api.teams.remove(t.id);
      setTeams(ts => (ts || []).filter(x => x.id !== t.id));
      onToast && onToast("Team deleted");
    } catch (e) {
      onToast && onToast("Couldn't delete: " + (e.message || "network error"), 4000);
    }
  }

  return (
    <React.Fragment>
      <div className="teams-admin-toolbar">
        <input
          className="search-input"
          placeholder="Search teams…"
          value={q}
          onChange={e => setQ(e.target.value)}
          style={{ flex: 1, maxWidth: 320 }}
        />
        <button className="admin-invite-btn" onClick={() => setEditing({})}>
          <Icons.Plus size={13}/> New team
        </button>
      </div>

      <div className="teams-grid">
        {teams === null && (
          <div className="teams-empty">Loading teams…</div>
        )}
        {teams !== null && filtered.length === 0 && (
          <div className="teams-empty">
            {q.trim()
              ? "No teams match — try a different search"
              : "No teams yet — click “New team” to bundle people together"}
          </div>
        )}
        {filtered.map(t => {
          const members = (t.member_ids || []).map(uid => people.find(p => p.id === uid)).filter(Boolean);
          return (
            <div key={t.id} className="team-card">
              <div className="team-card-head">
                <div className="team-card-dot" style={{ background: t.color || "#0073ea" }}/>
                <div style={{ flex: 1, minWidth: 0 }}>
                  <div className="team-card-name">{t.name}</div>
                  <div className="team-card-meta">
                    {members.length} {members.length === 1 ? "member" : "members"}
                  </div>
                </div>
                <button className="team-card-action team-card-action-edit" title="Edit" onClick={() => setEditing(t)}>
                  Edit
                </button>
                <button className="team-card-action" title="Delete" onClick={() => deleteTeam(t)}>
                  <Icons.Trash size={14}/>
                </button>
              </div>
              {t.description && <div className="team-card-desc">{t.description}</div>}
              <div className="team-card-avatars">
                {members.slice(0, 8).map(p => (
                  <span key={p.id} className="team-card-avatar" title={p.name}>
                    <Avatar person={p} size="sm"/>
                  </span>
                ))}
                {members.length > 8 && (
                  <span className="team-card-avatar team-card-more">+{members.length - 8}</span>
                )}
                {members.length === 0 && (
                  <span className="team-card-empty-members">No members yet</span>
                )}
              </div>
            </div>
          );
        })}
      </div>

      {editing !== null && (
        <TeamEditModal
          team={editing && editing.id ? editing : null}
          people={people}
          onClose={() => setEditing(null)}
          onSaved={(saved, action) => {
            setEditing(null);
            setTeams(ts => {
              const arr = ts || [];
              if (action === "created") return [...arr, saved];
              return arr.map(t => t.id === saved.id ? saved : t);
            });
            onToast && onToast(action === "created" ? "Team created" : "Team updated");
          }}
          onError={msg => onToast && onToast(msg, 4000)}
        />
      )}

      <style>{TEAMS_ADMIN_CSS}</style>
    </React.Fragment>
  );
}

// ─────────────────────────────────────────────────────────────
// TeamEditModal — create or edit a team. Members are managed
// inline with a search-and-toggle list; the modal does a single
// PUT /members on save so partial network failures don't leave
// the team half-attached.
// ─────────────────────────────────────────────────────────────
function TeamEditModal({ team, people, onClose, onSaved, onError }) {
  const isNew = !team;
  const [name, setName]               = React.useState(team?.name || "");
  const [color, setColor]             = React.useState(team?.color || "#0073ea");
  const [description, setDescription] = React.useState(team?.description || "");
  const [memberIds, setMemberIds]     = React.useState(() => new Set(team?.member_ids || []));
  const [q, setQ]                     = React.useState("");
  const [busy, setBusy]               = React.useState(false);

  // Show only active workspace users in the picker. Sales portal
  // users live in a separate table and don't surface here.
  const candidates = (people || [])
    .filter(p => p.status !== "deactivated")
    .filter(p => {
      if (!q.trim()) return true;
      const s = q.trim().toLowerCase();
      return p.name.toLowerCase().includes(s) || (p.email || "").toLowerCase().includes(s);
    });

  function toggle(id) {
    setMemberIds(prev => {
      const n = new Set(prev);
      n.has(id) ? n.delete(id) : n.add(id);
      return n;
    });
  }

  async function save() {
    if (!name.trim()) { onError && onError("Team name is required"); return; }
    setBusy(true);
    try {
      let saved;
      if (isNew) {
        saved = await api.teams.create({
          name: name.trim(),
          color, description: description.trim(),
          user_ids: [...memberIds],
        });
        onSaved && onSaved(saved, "created");
      } else {
        // Update meta + replace member list. Two calls; the server
        // emits its own events so other tabs pick up the change.
        await api.teams.patch(team.id, {
          name: name.trim(), color, description: description.trim(),
        });
        await api.teams.setMembers(team.id, [...memberIds]);
        saved = {
          ...team,
          name: name.trim(), color, description: description.trim(),
          member_ids: [...memberIds],
          member_count: memberIds.size,
        };
        onSaved && onSaved(saved, "updated");
      }
    } catch (e) {
      const msg = (e && e.body && (e.body.message || e.body.error)) || (e && e.message) || "network error";
      onError && onError("Couldn't save team: " + msg);
      setBusy(false);
    }
  }

  // A small swatch palette so the user doesn't need a color picker
  // to look reasonable. Custom colors via the input still allowed.
  const SWATCHES = [
    "#0073ea", "#a25ddc", "#5e6ad2", "#0bb",
    "#11a89c", "#26d07c", "#f1c40f", "#f0832c",
    "#e44e6e", "#9aa0a6",
  ];

  return (
    <Modal open={true} onClose={onClose}
      title={isNew ? "New team" : "Edit team"}
      subtitle="Bundle workspace users into a named group"
      width={620}
      footer={
        <>
          <span style={{ flex: 1, fontSize: 12, color: "var(--ink-muted)" }}>
            {memberIds.size} {memberIds.size === 1 ? "member" : "members"} selected
          </span>
          <button className="btn-ghost" onClick={onClose} disabled={busy}>Cancel</button>
          <button className="btn-primary" onClick={save} disabled={busy || !name.trim()}>
            {busy ? "Saving…" : (isNew ? "Create team" : "Save changes")}
          </button>
        </>
      }>
      <div className="team-edit">
        <label className="team-field">
          <span className="team-field-label">Name</span>
          <input
            className="search-input"
            placeholder="e.g. Engineering, Design, Sales"
            value={name}
            onChange={e => setName(e.target.value)}
            maxLength={120}
            autoFocus
          />
        </label>

        <label className="team-field">
          <span className="team-field-label">Color</span>
          <div className="team-swatches">
            {SWATCHES.map(c => (
              <button
                key={c}
                type="button"
                className={`team-swatch ${color === c ? "is-active" : ""}`}
                style={{ background: c }}
                onClick={() => setColor(c)}
                aria-label={c}
              />
            ))}
            <input
              type="color"
              className="team-swatch-custom"
              value={color}
              onChange={e => setColor(e.target.value)}
              title="Custom color"
            />
          </div>
        </label>

        <label className="team-field">
          <span className="team-field-label">Description (optional)</span>
          <textarea
            className="search-input"
            rows={2}
            placeholder="What this team works on"
            value={description}
            onChange={e => setDescription(e.target.value)}
            maxLength={500}
          />
        </label>

        <div className="team-field">
          <span className="team-field-label">Members</span>
          <input
            className="search-input"
            placeholder="Search by name or email…"
            value={q}
            onChange={e => setQ(e.target.value)}
            style={{ marginBottom: 8 }}
          />
          <div className="team-member-list">
            {candidates.length === 0 && (
              <div className="team-member-empty">No people match — try a different search</div>
            )}
            {candidates.map(p => {
              const on = memberIds.has(p.id);
              return (
                <div
                  key={p.id}
                  className={`team-member-row ${on ? "is-on" : ""}`}
                  onClick={() => toggle(p.id)}>
                  <input
                    type="checkbox"
                    checked={on}
                    onChange={() => toggle(p.id)}
                    onClick={e => e.stopPropagation()}
                  />
                  <Avatar person={p} size="md"/>
                  <div className="team-member-info">
                    <span className="team-member-name">{p.name}</span>
                    <span className="team-member-email">{p.email}</span>
                  </div>
                  <span className="team-member-role">
                    {WS_ROLES.find(r => r.id === p.wsRole)?.label || p.wsRole}
                  </span>
                </div>
              );
            })}
          </div>
        </div>
      </div>
    </Modal>
  );
}

// ─────────────────────────────────────────────────────────────
// AddTeamToProjectModal — pick a team + role and bulk-add every
// member to a project. Used from inside ProjectAccessModal.
// ─────────────────────────────────────────────────────────────
function AddTeamToProjectModal({ projectId, people, onClose, onAdded, onError }) {
  const [teams, setTeams]   = React.useState(null);
  const [pickedId, setPickedId] = React.useState("");
  const [role, setRole]     = React.useState("editor");
  const [overwrite, setOverwrite] = React.useState(false);
  const [busy, setBusy]     = React.useState(false);

  React.useEffect(() => {
    let alive = true;
    api.teams.list()
      .then(list => { if (alive) setTeams(Array.isArray(list) ? list : []); })
      .catch(() => { if (alive) setTeams([]); });
    return () => { alive = false; };
  }, []);

  const picked = (teams || []).find(t => t.id === pickedId);
  const previewMembers = picked
    ? (picked.member_ids || []).map(uid => people.find(p => p.id === uid)).filter(Boolean)
    : [];

  async function add() {
    if (!picked) return;
    setBusy(true);
    try {
      const r = await api.teams.addToProject(picked.id, {
        project_id: projectId, role, overwrite,
      });
      onAdded && onAdded(picked, r);
    } catch (e) {
      const msg = (e && e.body && (e.body.message || e.body.error)) || (e && e.message) || "network error";
      onError && onError("Couldn't add team: " + msg);
      setBusy(false);
    }
  }

  return (
    <Modal open={true} onClose={onClose}
      title="Add a team to this project"
      subtitle="Every team member gets the role you pick below"
      width={560}
      footer={
        <>
          <span style={{ flex: 1, fontSize: 12, color: "var(--ink-muted)" }}>
            {picked ? `${previewMembers.length} ${previewMembers.length === 1 ? "person" : "people"} will be added` : "Pick a team to continue"}
          </span>
          <button className="btn-ghost" onClick={onClose} disabled={busy}>Cancel</button>
          <button className="btn-primary" onClick={add} disabled={!picked || busy}>
            {busy ? "Adding…" : "Add team"}
          </button>
        </>
      }>
      <div className="team-edit">
        <label className="team-field">
          <span className="team-field-label">Team</span>
          {teams === null ? (
            <div style={{ fontSize: 13, color: "var(--ink-muted)", padding: "10px 0" }}>Loading…</div>
          ) : teams.length === 0 ? (
            <div style={{ fontSize: 13, color: "var(--ink-muted)", padding: "10px 0" }}>
              No teams yet. Create one first in <b>Admin → Teams</b>.
            </div>
          ) : (
            <select
              className="search-input"
              value={pickedId}
              onChange={e => setPickedId(e.target.value)}>
              <option value="">— Choose a team —</option>
              {teams.map(t => (
                <option key={t.id} value={t.id}>
                  {t.name} ({(t.member_ids || []).length})
                </option>
              ))}
            </select>
          )}
        </label>

        <label className="team-field">
          <span className="team-field-label">Role for everyone</span>
          <select
            className="search-input"
            value={role}
            onChange={e => setRole(e.target.value)}>
            {PROJECT_ROLES.filter(r => r.id !== "owner").map(r => (
              <option key={r.id} value={r.id}>{r.label} — {r.desc}</option>
            ))}
          </select>
        </label>

        {picked && (
          <div className="team-field">
            <span className="team-field-label">Members</span>
            <div className="team-add-preview">
              {previewMembers.length === 0 ? (
                <span className="team-member-empty" style={{ padding: 0 }}>This team has no members yet.</span>
              ) : (
                previewMembers.map(p => (
                  <span key={p.id} className="team-add-chip" title={p.name}>
                    <Avatar person={p} size="sm"/>
                    <span>{p.name}</span>
                  </span>
                ))
              )}
            </div>
          </div>
        )}

        <label className="team-overwrite">
          <input
            type="checkbox"
            checked={overwrite}
            onChange={e => setOverwrite(e.target.checked)}
          />
          <span>
            <b>Bump existing members up to this role</b><br/>
            <span style={{ color: "var(--ink-muted)", fontSize: 12 }}>
              By default, anyone already on the project keeps their current role.
            </span>
          </span>
        </label>
      </div>
    </Modal>
  );
}

// ─────────────────────────────────────────────────────────────
// CSS for the Teams admin views. Kept inline so a missed CSS
// bump doesn't leave the page unstyled while the rest of the
// app is fine.
// ─────────────────────────────────────────────────────────────
const TEAMS_ADMIN_CSS = `
.teams-admin-toolbar {
  display: flex; gap: 10px; align-items: center;
  padding: 18px 28px 0;
  background: var(--surface, #fff);
}
.teams-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
  gap: 14px;
  padding: 18px 28px 28px;
}
.teams-empty {
  grid-column: 1 / -1;
  text-align: center; color: var(--ink-muted); font-size: 13px;
  padding: 36px 16px;
  background: var(--surface, #fff);
  border: 1px dashed var(--border); border-radius: 10px;
}
.team-card {
  background: var(--surface, #fff);
  border: 1px solid var(--border);
  border-radius: 12px;
  padding: 14px 16px;
  display: flex; flex-direction: column; gap: 10px;
  transition: box-shadow .15s, transform .15s;
}
.team-card:hover { box-shadow: 0 4px 14px rgba(15,23,41,.06); }
.team-card-head { display: flex; align-items: center; gap: 10px; }
.team-card-dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }
.team-card-name { font-weight: 700; color: var(--ink); font-size: 14px; }
.team-card-meta { font-size: 11px; color: var(--ink-muted); margin-top: 1px; }
.team-card-desc {
  font-size: 12px; color: var(--ink-muted);
  line-height: 1.4;
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.team-card-action {
  border: none; background: transparent;
  height: 28px; min-width: 28px; padding: 0 8px; border-radius: 6px;
  color: var(--ink-muted); cursor: pointer;
  display: inline-flex; align-items: center; justify-content: center;
  font: inherit; font-size: 12px; font-weight: 600;
}
.team-card-action:hover { background: var(--surface-2, #f1f4f9); color: var(--ink); }
.team-card-action-edit { color: var(--brand, #0073ea); }
.team-card-action-edit:hover { color: var(--brand, #0073ea); background: rgba(0,115,234,.08); }
.team-card-avatars { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
.team-card-avatar { display: inline-flex; }
.team-card-more {
  width: 26px; height: 26px; border-radius: 50%;
  background: var(--surface-2, #eef0f5); color: var(--ink-muted);
  font-size: 11px; font-weight: 700;
  display: inline-flex; align-items: center; justify-content: center;
}
.team-card-empty-members { font-size: 11px; color: var(--ink-muted); font-style: italic; }

.team-edit { display: flex; flex-direction: column; gap: 14px; }
.team-field { display: flex; flex-direction: column; gap: 6px; }
.team-field-label {
  font-size: 11px; font-weight: 700; color: var(--ink-muted);
  text-transform: uppercase; letter-spacing: .06em;
}
.team-swatches { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.team-swatch {
  width: 24px; height: 24px; border-radius: 50%;
  border: 2px solid transparent; cursor: pointer; padding: 0;
  box-shadow: inset 0 0 0 1px rgba(0,0,0,.08);
}
.team-swatch.is-active { border-color: var(--ink); transform: scale(1.08); }
.team-swatch-custom {
  width: 28px; height: 28px; border: 1px solid var(--border);
  border-radius: 6px; cursor: pointer; padding: 0;
  background: transparent;
}

.team-member-list {
  border: 1px solid var(--border); border-radius: 8px;
  max-height: 320px; overflow: auto;
  background: var(--surface, #fff);
}
.team-member-row {
  display: flex; align-items: center; gap: 10px;
  padding: 8px 12px; cursor: pointer;
  border-bottom: 1px solid var(--border);
}
.team-member-row:last-child { border-bottom: none; }
.team-member-row:hover { background: var(--surface-2, #f6f7fb); }
.team-member-row.is-on { background: rgba(0, 115, 234, 0.06); }
.team-member-row input[type=checkbox] { accent-color: var(--brand, #0073ea); }
.team-member-info { display: flex; flex-direction: column; flex: 1; min-width: 0; }
.team-member-name { font-size: 13px; font-weight: 600; color: var(--ink); }
.team-member-email { font-size: 11px; color: var(--ink-muted); }
.team-member-role {
  font-size: 11px; color: var(--ink-muted); background: var(--surface-2, #f1f4f9);
  padding: 2px 8px; border-radius: 999px; font-weight: 600;
}
.team-member-empty { padding: 20px 12px; text-align: center; font-size: 12px; color: var(--ink-muted); }

.team-add-preview {
  display: flex; flex-wrap: wrap; gap: 6px;
  padding: 10px;
  border: 1px solid var(--border); border-radius: 8px;
  background: var(--surface-2, #f6f7fb);
  max-height: 160px; overflow: auto;
}
.team-add-chip {
  display: inline-flex; align-items: center; gap: 6px;
  background: #fff;
  border: 1px solid var(--border); border-radius: 999px;
  padding: 2px 10px 2px 2px; font-size: 12px; color: var(--ink);
}
.team-overwrite {
  display: flex; align-items: flex-start; gap: 10px;
  background: var(--surface-2, #f6f7fb);
  padding: 10px 12px; border-radius: 8px;
  font-size: 13px; color: var(--ink);
  cursor: pointer;
}
.team-overwrite input { margin-top: 3px; accent-color: var(--brand, #0073ea); }
`;

Object.assign(window, {
  AdminPeoplePage, RolePill, InviteModal, ProjectAccessModal, UserPeek, ActingAs,
  TeamsAdminPanel, TeamEditModal, AddTeamToProjectModal,
});
