// screens-clientmgmt.jsx -- Client Management hub (three tabs)
//
// Extracted from screens-closed-newclients.jsx, which was carrying an unrelated
// Closed Deals report in the same file. Same precedent as the Calendar
// extraction in HANDOFF_29.
//
// TABS
//   new      new_clients, stages 1-8            (sql_91)
//   pending  client_pending_projects, steps 0-8 (sql_89)
//   active   list_active_clients RPC            (sql_92 / sql_94)
//
// DESIGN FIDELITY. Built to "Client Management Hub.dc.html", variants
// ver:{active:3, pending:3, new:1}. Every colour is an existing theme.css token:
// the design's 61 tokens are 46 real ones plus 15 local aliases that map 1:1
// onto Vault's (--pri->--accent, --mut->--muted, --grn->--ok, ...). No sixth
// local palette -- HANDOFF_31 got that count to zero.
//
// WHAT THE DATA MADE US CHANGE, and why each is not a design betrayal:
//
//   No score on the roster. project_scores covers 66 of 2,613 active projects
//   (3%). The design shows a score per row; at 3% that column is em-dashes, so
//   list_active_clients does not even return it.
//
//   buyer_name is the row title. registry.project_name holds Harvey's `details`
//   field, which is TARGET CRITERIA -- multi-line, ~138 chars, EBITDA and
//   geography. It is not a name. It renders in the drawer as Target Criteria.
//
//   category is suppressed when redundant. It is very often the buyer name, or
//   the buyer name plus " add-ons" ("Uptime Fleet"/"Uptime Fleet"). Showing both
//   gives rows that say the same thing twice. See categoryLine().
//
//   Priority is stored as 'H'/'M'/'L', NOT 'High'/'Medium'/'Low'. That mismatch
//   is what broke sql_92's ORDER BY. The letters are the truth; PRIO_LABEL maps
//   them for display in exactly one place and nowhere else.
//
// HOOK DISCIPLINE. Every component here is declared at MODULE scope. Defining a
// component inside a render function makes React remount it on each keystroke
// and the focused input loses focus -- the bug that ate a session once already.

const CM_NC_STAGES = [
  "New Project Request", "Sector/Deal Ideation", "Conflict Check (Internal)",
  "Supplemental Materials", "Secondary Project Discussion", "EL Sent / Sending",
  "EL Redlines", "EL Executed",
];

// High-water mark, not independent checkboxes: clicking step 5 means "through
// step 5". Index 8 == all done == ready to promote to Active.
const CM_PP_STAGES = [
  "Kick-Off Call", "Summary Document", "File Creation", "Database Project Creation",
  "List 1 Research", "Marketing Deck Draft", "Marketing Deck Finalized", "List Approved",
];

const CM_REGIONS = [
  "West (Pacific)", "West (Mountain)", "South (W. South Central)",
  "Midwest (W. North Central)", "South (E. South Central)", "South (Southeast)",
  "Midwest (E. North Central)", "Northeast (Mid-Atlantic)", "Northeast (New England)",
];

// The ONE place letters become words.
const CM_PRIO_LABEL = { H: "High", M: "Medium", L: "Low" };
const CM_PRIO_COLOR = { H: "var(--risk)", M: "var(--gold)", L: "var(--muted)" };
const CM_PRIO_ORDER = { H: 0, M: 1, L: 2 };

const CM_TABS = [
  { id: "new",     label: "New Client Discussions" },
  { id: "pending", label: "Pending Projects" },
  { id: "active",  label: "Active Clients" },
];

// ---------------------------------------------------------------------------
// Suppress a category line that only repeats the buyer name. Add-on projects are
// routinely categorised "<Buyer> add-ons"; platforms carry real sector names.
function cmCategoryLine(row) {
  const cat = String(row.category || "").trim();
  const buyer = String(row.buyerName || "").trim();
  if (!cat) return "";
  const norm = s => s.toLowerCase().replace(/[.,]/g, "").replace(/\b(llc|inc|lp|ltd|co)\b/g, "").trim();
  const c = norm(cat), b = norm(buyer);
  if (!b) return cat;
  if (c === b) return "";
  if (c === (b + " add-ons") || c === (b + " add ons") || c === (b + " addons")) return "";
  // Acronym forms ("EIS add-ons" for "Environmental Infrastructure Solutions")
  // are NOT suppressed: the acronym is information the buyer name does not carry.
  return cat;
}

function cmInitials(name) {
  const parts = String(name || "").trim().split(/\s+/).filter(Boolean);
  if (!parts.length) return "?";
  if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
  return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}

function cmPersonName(id) {
  const F = window.VAULT_FIRM;
  const p = F && F.PEOPLE_BY_ID ? F.PEOPLE_BY_ID[id] : null;
  return p ? p.name : (id || "");
}

// Stable per-person colour from the categorical ramp, so the same person is the
// same colour on every card without storing a colour anywhere.
function cmPersonColor(id) {
  const ramp = ["var(--cat-2)", "var(--cat-3)", "var(--cat-4)", "var(--cat-5)", "var(--cat-6)", "var(--cat-7)"];
  const s = String(id || "");
  let h = 0;
  for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
  return ramp[h % ramp.length];
}

// Standards s5: never toLocaleDateString. VaultDate resolves the user's zone
// from notification_prefs.tz and applies it last, so this cannot drift from the
// rest of Vault at 05:00Z.
// LOCAL today as YYYY-MM-DD. `new Date().toISOString().slice(0,10)` is UTC, so
// anything created after 5pm Pacific stamps TOMORROW's date. orig_date and
// el_date are both real dates people read, so this matters.
function cmToday() {
  const d = new Date();
  const p = n => String(n).padStart(2, "0");
  return d.getFullYear() + "-" + p(d.getMonth() + 1) + "-" + p(d.getDate());
}

function cmFmtDate(v) {
  if (!v) return "";
  return window.VaultDate(v, { month: "short", day: "numeric" });
}

// ---------------------------------------------------------------------------
// Shared presentational pieces. All module scope. All theme tokens.

function CMAvatar({ personId, size, title }) {
  const S = size || 26;
  return (
    <div title={title || cmPersonName(personId)}
      style={{
        width: S, height: S, borderRadius: "50%", flexShrink: 0,
        background: cmPersonColor(personId), color: "var(--accent-ink)",
        display: "flex", alignItems: "center", justifyContent: "center",
        fontSize: Math.round(S * 0.42), fontWeight: "var(--w-medium)", letterSpacing: ".02em",
      }}>
      {cmInitials(cmPersonName(personId))}
    </div>
  );
}

function CMPriorityDot({ priority }) {
  if (!priority) return null;
  const key = String(priority).trim().toUpperCase().charAt(0);
  return <window.VaultDot color={CM_PRIO_COLOR[key] || "var(--muted)"}/>;
}

function CMTypePill({ type }) {
  if (!type) return null;
  const isPlatform = String(type).toLowerCase() === "platform";
  return (
    <span style={{
      fontSize: "var(--t-micro)", fontWeight: "var(--w-medium)",
      padding: "1px 7px", borderRadius: "var(--r-pill)", whiteSpace: "nowrap",
      color: isPlatform ? "var(--accent)" : "var(--muted)",
      background: isPlatform ? "var(--accent-soft)" : "var(--surface-2)",
      border: "1px solid " + (isPlatform ? "var(--accent)" : "var(--line)"),
    }}>{type}</span>
  );
}

// Region multi-select. Same interaction as VaultFilterBar's menus -- pill
// button, popover, VaultCheck rows, "N regions" collapse -- but bound to one
// card's regions[] rather than a screen-level filter, so it cannot reuse that
// component directly.
function CMRegionPicker({ value, onToggle, open, onOpen, disabled, compact }) {
  const sel = Array.isArray(value) ? value : [];
  // When chips already render the regions, the control collapses to a "+" so it
  // does not repeat what is right beside it.
  const label = compact ? "+" : sel.length === 0 ? "Regions" : sel.length === 1 ? sel[0] : sel.length + " regions";
  return (
    <div style={{ position: "relative" }}>
      <button type="button" disabled={disabled}
        onClick={e => { e.stopPropagation(); onOpen && onOpen(); }}
        style={{
          display: "inline-flex", alignItems: "center", gap: 6,
          background: "var(--surface)", cursor: disabled ? "default" : "pointer",
          border: "1px solid " + (sel.length ? "var(--accent)" : "var(--line-strong)"),
          borderRadius: "var(--r-pill)", padding: "3px 10px",
          fontSize: "var(--t-micro)", fontFamily: "inherit",
          color: sel.length ? "var(--ink-2)" : "var(--muted)", whiteSpace: "nowrap",
        }}>
        {label}{compact ? null : <span style={{ fontSize: 7, color: "var(--muted)" }}>{"\u25BC"}</span>}
      </button>
      {open ? (
        <React.Fragment>
          <div onClick={e => { e.stopPropagation(); onOpen && onOpen(); }}
            style={{ position: "fixed", inset: 0, zIndex: 35 }}/>
          <div onClick={e => e.stopPropagation()}
            style={{
              position: "absolute", top: "calc(100% + 5px)", left: 0, zIndex: 40,
              background: "var(--surface)", border: "1px solid var(--line-2)",
              borderRadius: "var(--r-card)", boxShadow: "var(--shadow-pop)",
              padding: "8px 5px", minWidth: 230, maxHeight: 300, overflowY: "auto",
            }}>
            {CM_REGIONS.map(r => (
              <div key={r} className="cm-opt" onClick={() => onToggle && onToggle(r)}
                style={{
                  display: "flex", alignItems: "center", gap: 9, padding: "6px 11px",
                  borderRadius: "var(--r-ctl)", cursor: "pointer",
                  fontSize: "var(--t-body)", color: "var(--ink-2)",
                }}>
                <window.VaultCheck on={sel.indexOf(r) !== -1}/>
                <span>{r}</span>
              </div>
            ))}
          </div>
        </React.Fragment>
      ) : null}
    </div>
  );
}

window.CM_NC_STAGES = CM_NC_STAGES;
window.CM_PP_STAGES = CM_PP_STAGES;
window.CM_REGIONS = CM_REGIONS;
window.cmCategoryLine = cmCategoryLine;
window.cmToday = cmToday;
window.cmRosterMatch = cmRosterMatch;
window.CMAvatar = CMAvatar;
window.CMRegionPicker = CMRegionPicker;

// ---------------------------------------------------------------------------
// TAB 1 -- New Client Discussions (design variant "new: 1", discussion board)

// Inline owner picker. B2: New and Pending cards had no way to change who owns
// them. The avatar IS the control -- click it, pick someone.
function CMOwnerPicker({ ownerId, choices, open, onOpen, onPick, size, placeholder }) {
  return (
    <div style={{ position: "relative", flexShrink: 0 }}>
      <button type="button" title={ownerId ? cmPersonName(ownerId) : (placeholder || "Assign an owner")}
        onClick={e => { e.stopPropagation(); onOpen && onOpen(); }}
        style={{ border: "none", background: "transparent", padding: 0, cursor: "pointer",
          display: "block", lineHeight: 0 }}>
        {ownerId ? <CMAvatar personId={ownerId} size={size || 22}/> : (
          <span style={{
            width: size || 22, height: size || 22, borderRadius: "50%",
            border: "1px dashed var(--line-strong)", color: "var(--muted)",
            display: "flex", alignItems: "center", justifyContent: "center",
            fontSize: Math.round((size || 22) * 0.5),
          }}>{"+"}</span>
        )}
      </button>
      {open ? (
        <React.Fragment>
          <div onClick={e => { e.stopPropagation(); onOpen && onOpen(); }}
            style={{ position: "fixed", inset: 0, zIndex: 45 }}/>
          <div onClick={e => e.stopPropagation()}
            style={{ position: "absolute", top: "calc(100% + 5px)", right: 0, zIndex: 50,
              background: "var(--surface)", border: "1px solid var(--line-2)",
              borderRadius: "var(--r-card)", boxShadow: "var(--shadow-pop)",
              padding: "6px 5px", minWidth: 190, maxHeight: 280, overflowY: "auto" }}>
            <div onClick={() => onPick(null)}
              style={{ padding: "6px 11px", borderRadius: "var(--r-ctl)", cursor: "pointer",
                color: "var(--muted)", fontSize: "var(--t-body)" }}>Unassigned</div>
            {choices.map(p => (
              <div key={p.id} className="cm-opt" onClick={() => onPick(p.id)}
                style={{ display: "flex", alignItems: "center", gap: 8, padding: "6px 11px",
                  borderRadius: "var(--r-ctl)", cursor: "pointer", fontSize: "var(--t-body)",
                  color: "var(--ink-2)",
                  background: p.id === ownerId ? "var(--accent-soft)" : "transparent" }}>
                <CMAvatar personId={p.id} size={18}/>
                <span>{p.name}</span>
              </div>
            ))}
          </div>
        </React.Fragment>
      ) : null}
    </div>
  );
}

function CMLeadCard({ lead, onDrag, onOpenRegions, regionsOpen, onToggleRegion, onPromote, onRetire,
                     ownerChoices, ownerOpen, onOpenOwner, onPickOwner, onDropOnCard, dropHint, onOpenCard }) {
  const isExec = lead.stageLabel === "EL Executed";
  const regions = Array.isArray(lead.regions) ? lead.regions : [];
  return (
    <div draggable className="cm-card" onDragStart={() => onDrag(lead.id)}
      onClick={() => onOpenCard && onOpenCard(lead)} title="Edit this card"
      onDragOver={e => { e.preventDefault(); e.stopPropagation(); onDropOnCard && onDropOnCard(lead, e, true); }}
      onDrop={e => { e.preventDefault(); e.stopPropagation(); onDropOnCard && onDropOnCard(lead, e, false); }}
      style={{
        background: "var(--surface)", border: "1px solid var(--line)",
        borderRadius: "var(--r-card)", padding: "9px 10px", cursor: "grab",
        // B5: the insertion point is shown as an edge, not a ghost card.
        boxShadow: dropHint === "above" ? "inset 0 2px 0 0 var(--accent)"
          : dropHint === "below" ? "inset 0 -2px 0 0 var(--accent)" : "none",
      }}>
      {/* The whole card opens the modal now. Every control inside stops
          propagation so the buttons still do their own job. */}
      {/* Design: the card NAME and the stage header sit at the same step
          (--t-body). It was one notch up, which is why the cards read heavy. */}
      <div style={{ fontWeight: "var(--w-medium)", color: "var(--ink)", fontSize: "var(--t-body)" }}>
        {lead.project}
      </div>
      {/* Design puts the sector line in the accent, not muted -- it is the
          second most scannable thing on the card. */}
      {lead.market || lead.service ? (
        <div style={{ fontSize: "var(--t-micro)", color: "var(--accent)",
          fontWeight: "var(--w-medium)", marginTop: 2, lineHeight: 1.3 }}>
          {[lead.market, lead.service].filter(Boolean).join(" \u00B7 ")}
        </div>
      ) : null}
      <div style={{ fontSize: "var(--t-micro)", color: "var(--muted)", marginTop: 4 }}>
        {[lead.client || lead.buyer || "\u2014", lead.harveyProjectId].filter(Boolean).join(" \u00B7 ")}
      </div>

      <div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-end",
        marginTop: 7, gap: 6 }}>
        <div onClick={e => e.stopPropagation()}
          style={{ display: "flex", gap: 3, flexWrap: "wrap", alignItems: "center", minWidth: 0 }}>
          {regions.map(r => (
            <span key={r} style={{
              fontSize: "var(--t-kicker)", fontWeight: "var(--w-medium)", color: "var(--muted)",
              border: "1px solid var(--line)", borderRadius: "var(--r-chip)", padding: "1px 5px", whiteSpace: "nowrap",
            }}>{r}</span>
          ))}
          {/* The design's V1 board renders regions as read-only chips. Brian
              asked for the multi-select, so the chips stay and this opens it. */}
          <CMRegionPicker value={regions} open={regionsOpen} compact={regions.length > 0}
            onOpen={() => onOpenRegions(lead.id)} onToggle={r => onToggleRegion(lead, r)}/>
        </div>
        <CMOwnerPicker ownerId={lead.owner} choices={ownerChoices} size={22}
          open={ownerOpen} onOpen={() => onOpenOwner(lead.id)}
          onPick={pid => onPickOwner(lead, pid)}/>
      </div>

      {isExec ? (
        <button type="button" onClick={e => { e.stopPropagation(); onPromote(lead); }}
          style={{
            marginTop: 7, width: "100%", background: "var(--ok-soft)", color: "var(--ok)",
            border: "1px solid var(--ok)", borderRadius: "var(--r-ctl)", padding: "5px 0",
            fontWeight: "var(--w-medium)", fontSize: "var(--t-micro)", fontFamily: "inherit",
            cursor: "pointer",
          }}>{"Move to Pending Projects \u2192"}</button>
      ) : null}
      {/* Retire lives in the card modal now. A per-card button cost ~34px of
          height on every card and the design has no such control. */}
    </div>
  );
}

function CMNewBoard({ leads, onDropStage, onDrag, regionsOpenId, onOpenRegions, onToggleRegion, onPromote, onRetire,
                      ownerChoices, ownerOpenId, onOpenOwner, onPickOwner, onDropOnCard, dropTarget, onOpenCard,
                      onNewInStage }) {
  return (
    <div style={{ display: "flex", gap: 10, overflowX: "auto", paddingBottom: 8,
      alignItems: "flex-start", minWidth: 0, maxWidth: "100%" }}>
      {CM_NC_STAGES.map((stage, i) => {
        const cards = leads.filter(l => l.stageLabel === stage);
        const accent = i >= 5 ? "var(--ok)" : i === 4 ? "var(--accent)" : "var(--line)";
        return (
          <div key={stage}
            onDragOver={e => e.preventDefault()}
            onDrop={e => { e.preventDefault(); onDropStage(stage); }}
            style={{
              flex: "none", width: "14rem", minHeight: 120,
              background: "var(--surface)", border: "1px solid var(--line)",
              borderRadius: "var(--r-card)",
            }}>
            <div style={{
              padding: "9px 11px", borderBottom: "2px solid " + accent,
              display: "flex", justifyContent: "space-between", alignItems: "center", gap: 6,
            }}>
              <span style={{ fontWeight: "var(--w-medium)", fontSize: "var(--t-body)",
                color: "var(--ink)", lineHeight: 1.25 }}>{stage}</span>
              <span style={{ display: "flex", alignItems: "center", gap: 5, flexShrink: 0 }}>
                <span style={{ fontSize: "var(--t-micro)", color: "var(--muted)",
                  background: "var(--surface-2)", borderRadius: "var(--r-pill)",
                  padding: "1px 7px" }}>{cards.length}</span>
                {/* Same modal as the toolbar button, with this column's stage
                    already chosen. Not a second control -- one prop. */}
                <button type="button" className="cm-opt" title={"New discussion in " + stage}
                  aria-label={"New discussion in " + stage}
                  onClick={() => onNewInStage(stage)}
                  style={{ width: 20, height: 20, lineHeight: 1, padding: 0,
                    display: "flex", alignItems: "center", justifyContent: "center",
                    borderRadius: "var(--r-ctl)", border: "1px solid var(--line-strong)",
                    background: "var(--surface)", color: "var(--muted)",
                    cursor: "pointer", fontFamily: "inherit",
                    fontSize: "var(--t-body-lg)" }}>{"\u002B"}</button>
              </span>
            </div>
            <div style={{ padding: 7, display: "flex", flexDirection: "column", gap: 7 }}>
              {cards.map(l => (
                <CMLeadCard key={l.id} lead={l} onDrag={onDrag}
                  regionsOpen={regionsOpenId === l.id}
                  onOpenRegions={onOpenRegions} onToggleRegion={onToggleRegion}
                  onPromote={onPromote} onRetire={onRetire}
                  ownerChoices={ownerChoices} ownerOpen={ownerOpenId === l.id}
                  onOpenOwner={onOpenOwner} onPickOwner={onPickOwner}
                  onDropOnCard={onDropOnCard} onOpenCard={onOpenCard}
                  dropHint={dropTarget && dropTarget.id === l.id ? dropTarget.edge : null}/>
              ))}
            </div>
          </div>
        );
      })}
    </div>
  );
}

// ---------------------------------------------------------------------------
// TAB 2 -- Pending Projects (design variant "pending: 3", progress cards)

function CMPendingCard({ proj, onSetStage, onPromote, onRetire, ownerChoices, ownerOpen, onOpenOwner, onPickOwner, onOpenCard }) {
  const nextStage = proj.stage >= CM_PP_STAGES.length
    ? "List Approved \u2713"
    : CM_PP_STAGES[proj.stage];
  return (
    <div className="cm-card" onClick={() => onOpenCard && onOpenCard(proj)} title="Edit this project"
      style={{
        background: "var(--surface)", border: "1px solid var(--line)",
        borderRadius: "var(--r-card)", padding: 14, cursor: "pointer",
      }}>
      <div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 10 }}>
        <div style={{ minWidth: 0 }}>
          <div style={{ fontWeight: "var(--w-medium)", color: "var(--ink)", fontSize: "var(--t-body-lg)" }}>
            {proj.projectName}
          </div>
          <div style={{ fontSize: "var(--t-micro)", color: "var(--muted)", marginTop: 2 }}>
            {"EL executed " + (proj.elDate ? cmFmtDate(proj.elDate) : "\u2014")}
            {proj.harveyProjectId ? " \u00B7 " + proj.harveyProjectId : ""}
          </div>
        </div>
        <CMOwnerPicker ownerId={proj.ownerId} choices={ownerChoices} size={26}
          open={ownerOpen} onOpen={() => onOpenOwner(proj.id)}
          onPick={pid => onPickOwner(proj, pid)}/>
      </div>

      <div style={{
        background: "var(--accent-soft)", borderRadius: "var(--r-ctl)",
        padding: "8px 11px", margin: "12px 0", fontSize: "var(--t-body)",
      }}>
        <span style={{ color: "var(--muted)" }}>Next: </span>
        <span style={{ fontWeight: "var(--w-medium)", color: "var(--accent)" }}>{nextStage}</span>
      </div>

      {/* Always visible, with the connector rail. The design has no toggle and
          no progress bar -- the vertical timeline IS the progress indicator. */}
      <div style={{ display: "flex", flexDirection: "column" }}>
        {CM_PP_STAGES.map((name, idx) => {
          const done = idx < proj.stage;
          const cur = idx === proj.stage;
          return (
            <div key={name} className="cm-step"
              onClick={e => { e.stopPropagation(); onSetStage(proj, idx); }}
              style={{ display: "flex", alignItems: "center", gap: 10, cursor: "pointer", padding: "3px 0" }}>
              <div style={{ display: "flex", flexDirection: "column", alignItems: "center", width: 14 }}>
                <span style={{
                  width: 11, height: 11, borderRadius: "50%", flexShrink: 0,
                  border: "2px solid " + (done ? "var(--ok)" : cur ? "var(--accent)" : "var(--line-strong)"),
                  background: done ? "var(--ok)" : "transparent",
                }}/>
                {idx < CM_PP_STAGES.length - 1
                  ? <span style={{ width: 2, height: 10, background: "var(--line)" }}/> : null}
              </div>
              <span style={{
                fontSize: "var(--t-body)",
                fontWeight: cur ? "var(--w-medium)" : "var(--w-regular)",
                color: done ? "var(--muted)" : cur ? "var(--accent)" : "var(--ink-2)",
              }}>{name}</span>
            </div>
          );
        })}
      </div>

      <div style={{ display: "flex", gap: 8, marginTop: 13 }}>
        <button type="button" disabled={proj.stage < CM_PP_STAGES.length}
          onClick={e => { e.stopPropagation(); onPromote(proj); }}
          style={{
            flex: 1, padding: "6px 10px", fontSize: "var(--t-micro)", fontFamily: "inherit",
            fontWeight: "var(--w-medium)", borderRadius: "var(--r-ctl)",
            cursor: proj.stage >= CM_PP_STAGES.length ? "pointer" : "default",
            background: proj.stage >= CM_PP_STAGES.length ? "var(--ok-soft)" : "var(--surface-2)",
            color: proj.stage >= CM_PP_STAGES.length ? "var(--ok)" : "var(--muted)",
            border: "1px solid " + (proj.stage >= CM_PP_STAGES.length ? "var(--ok)" : "var(--line)"),
          }}>{"Move to Active \u2192"}</button>
        <button type="button" onClick={e => { e.stopPropagation(); onRetire(proj); }}
          style={{
            padding: "6px 10px", fontSize: "var(--t-micro)", fontFamily: "inherit", cursor: "pointer",
            background: "transparent", color: "var(--muted)",
            border: "1px solid var(--line)", borderRadius: "var(--r-ctl)",
          }}>Retire</button>
      </div>
    </div>
  );
}

// ---------------------------------------------------------------------------
// TAB 3 -- Active Clients (design variant "active: 3", split notes)

// Roster search. Multi-term AND, so "kent water" narrows rather than widens --
// each space-separated term must appear somewhere on the row.
//
// It searches `detail` (the Target Criteria block) as well as the name, because
// that is where the end markets and the EBITDA band live and it is the field
// you actually want to search by. `detail` is registry.project_name, which
// holds Harvey's `details` text, NOT a name -- see the header comment.
function cmRosterMatch(row, ownerLabel, q) {
  const terms = String(q || "").toLowerCase().split(/\s+/).filter(Boolean);
  if (!terms.length) return true;
  const hay = [row.buyerName, row.category, row.detail, row.projectId,
               row.projectType, ownerLabel]
    .filter(Boolean).join(" ").toLowerCase();
  return terms.every(t => hay.indexOf(t) !== -1);
}

// Roster search renders through window.VaultSearch (vault-ui.jsx). The local
// CMRosterSearch that shipped with it is gone -- it was the third private copy
// of this control and Rule 4 exists to stop exactly that.

function CMActiveRow({ row, assignedOwner, selected, onSelect }) {
  const catLine = cmCategoryLine(row);
  const prioKey = String(row.priority || "").toUpperCase().charAt(0);
  const sub = [catLine, CM_PRIO_LABEL[prioKey]].filter(Boolean).join(" \u00B7 ");
  return (
    <div className="cm-row" onClick={() => onSelect(row.projectId)}
      style={{
        display: "flex", alignItems: "center", gap: 10, padding: "11px 16px",
        borderTop: "1px solid var(--line)", cursor: "pointer",
        background: selected ? "var(--accent-soft)" : "transparent",
      }}>
      <window.VaultDot color={CM_PRIO_COLOR[prioKey] || "var(--line-strong)"}/>
      <div style={{ flex: 1, minWidth: 0 }}>
        <div style={{ fontWeight: "var(--w-medium)", color: "var(--ink)",
          overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{row.buyerName}</div>
        {sub ? (
          <div style={{ fontSize: "var(--t-micro)", color: "var(--muted)",
            overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{sub}</div>
        ) : null}
      </div>
      {/* The design shows a health score here. project_scores covers 3% of the
          book, so the slot carries the open-action count instead -- a number
          this screen actually produces. */}
      {row.openActions > 0 ? (
        <span title={row.openActions + " open action(s)"}
          style={{ fontWeight: "var(--w-medium)", color: "var(--accent)", flexShrink: 0 }}>
          {row.openActions}
        </span>
      ) : null}
      {assignedOwner ? <CMAvatar personId={assignedOwner} size={22}/> : null}
    </div>
  );
}

function CMNoteRow({ note, onDelete, canDelete, onEdit }) {
  // Editing happens IN PLACE. Re-creating the note would rewrite created_by and
  // created_at, so a typo fix would silently reassign authorship to whoever
  // fixed it and move the date to today.
  const [draft, setDraft] = React.useState(null);   // null = not editing
  // KIND IS WHAT HAPPENED; THE LINK IS WHETHER SOMETHING IS ATTACHED. Those are
  // independent since sql_202, so the layout is chosen by whether there IS a
  // link, not by the kind -- a call with a recording and a note with a document
  // both render as the compact one-line form.
  // FOUR KINDS since sql_220 (Brian, 2026-09-03): a Call Sheet and a Project
  // Overview are things that happen on a buyer, and Development counts them by
  // author. Anything but a plain note is tinted accent.
  const KIND_TAG = { call: "UPDATE CALL", call_sheet: "CALL SHEET", project_overview: "PROJECT OVERVIEW" };
  const tag = KIND_TAG[note.kind] || "NOTE";
  const tagFg = note.kind !== "note" ? "var(--accent)" : "var(--muted)";
  const tagBg = note.kind !== "note" ? "var(--accent-soft)" : "var(--surface-2)";
  const isFile = !!note.linkUrl;
  // authorLabel resolves the email to a person via VAULT_FIRM; it falls back to
  // the raw email only when no person matches that address.
  const who = note.authorLabel || note.authorEmail || "";
  return (
    <div style={{ borderLeft: "2px solid var(--line)", padding: "3px 0 3px 12px" }}>
      {isFile ? (
        // A link is a one-line record: tag, name, date, who, delete -- all on
        // the same row, name truncated rather than wrapped.
        <div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
          <span style={{ fontWeight: "var(--w-medium)", color: tagFg, background: tagBg,
            padding: "1px 7px", borderRadius: "var(--r-chip)", fontSize: "var(--t-micro)", flexShrink: 0 }}>{tag}</span>
          <a href={note.linkUrl} target="_blank" rel="noopener noreferrer" title={note.note}
            style={{ color: "var(--accent)", textDecoration: "none", minWidth: 0,
              overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", flex: 1 }}>{note.note}</a>
          <span style={{ fontSize: "var(--t-micro)", color: "var(--muted)", flexShrink: 0 }}>
            {cmFmtDate(note.createdAt)}
          </span>
          {note.authorId ? <CMAvatar personId={note.authorId} size={18} title={who}/> : (
            <span style={{ fontSize: "var(--t-micro)", color: "var(--muted)", flexShrink: 0 }}>{who}</span>
          )}
          {canDelete ? (
            <button type="button" onClick={() => onDelete(note)} title="Delete"
              style={{ background: "transparent", border: "none", cursor: "pointer", padding: 0,
                color: "var(--muted)", fontSize: "var(--t-micro)", fontFamily: "inherit",
                flexShrink: 0 }}>Delete</button>
          ) : null}
        </div>
      ) : (
        <React.Fragment>
          <div style={{ display: "flex", gap: 8, alignItems: "center",
            fontSize: "var(--t-micro)", color: "var(--muted)" }}>
            {/* THE SAME HEADER AS THE LINKED FORM: tag, then date and avatar
                pushed right. It used to print "Aug 21 \u00b7 Brian Scott" as
                text on the left while a linked entry showed an avatar on the
                right -- two layouts for the same fact, differing only by
                whether something happened to be attached. */}
            <span style={{ fontWeight: "var(--w-medium)", color: tagFg, background: tagBg,
              padding: "1px 7px", borderRadius: "var(--r-chip)", fontSize: "var(--t-micro)" }}>{tag}</span>
            <span style={{ marginLeft: "auto", flexShrink: 0 }}>{cmFmtDate(note.createdAt)}</span>
            {note.authorId ? (
              <CMAvatar personId={note.authorId} size={18} title={who}/>
            ) : (
              <span style={{ flexShrink: 0 }}>{who}</span>
            )}
            {canDelete && draft === null ? (
              <button type="button" onClick={() => setDraft(note.note || "")}
                style={{ background: "transparent", border: "none", cursor: "pointer",
                  color: "var(--muted)", fontSize: "var(--t-micro)", fontFamily: "inherit", padding: 0,
                  flexShrink: 0 }}>
                Edit
              </button>
            ) : null}
            {canDelete ? (
              <button type="button" onClick={() => onDelete(note)}
                style={{ background: "transparent", border: "none", cursor: "pointer",
                  color: "var(--muted)", fontSize: "var(--t-micro)", fontFamily: "inherit", padding: 0,
                  flexShrink: 0 }}>
                Delete
              </button>
            ) : null}
          </div>
          {draft === null ? (
            <div style={{ marginTop: 4, color: "var(--ink-2)", whiteSpace: "pre-wrap",
              wordBreak: "break-word" }}>{note.note}</div>
          ) : (
            <div style={{ marginTop: 4, display: "grid", gap: 6 }}>
              <textarea value={draft} onChange={e => setDraft(e.target.value)} autoFocus
                style={{ width: "100%", boxSizing: "border-box", background: "var(--surface)",
                  border: "1px solid var(--accent)", borderRadius: "var(--r-ctl)",
                  padding: "7px 9px", minHeight: 54, resize: "vertical",
                  fontFamily: "inherit", color: "var(--ink)" }}/>
              <div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
                <button type="button" className="btn sm" onClick={() => setDraft(null)}>Cancel</button>
                <button type="button" onClick={() => { onEdit(note, draft); setDraft(null); }}
                  style={{ padding: "4px 12px", background: "var(--accent)", color: "var(--accent-ink)",
                    border: "none", borderRadius: "var(--r-ctl)", cursor: "pointer",
                    fontFamily: "inherit", fontSize: "var(--t-micro)" }}>Save</button>
              </div>
            </div>
          )}
        </React.Fragment>
      )}
    </div>
  );
}

function CMConfirm({ title, message, confirmLabel, onCancel, onConfirm }) {
  return (
    <div className="cm-screen" onClick={onCancel}
      style={{ position: "fixed", inset: 0, background: "var(--scrim)", zIndex: 1300,
        display: "flex", alignItems: "center", justifyContent: "center", padding: 20 }}>
      <div onClick={e => e.stopPropagation()}
        style={{ width: "21rem", background: "var(--surface)", borderRadius: "var(--r-card)",
          border: "1px solid var(--line)", boxShadow: "var(--shadow-modal)", padding: "16px 18px" }}>
        <div style={{ fontWeight: "var(--w-medium)", color: "var(--ink)", marginBottom: 6 }}>{title}</div>
        <div style={{ color: "var(--muted)", lineHeight: 1.5, marginBottom: 14 }}>{message}</div>
        <div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}>
          <button type="button" className="btn ghost" onClick={onCancel}>Cancel</button>
          <button type="button" onClick={onConfirm}
            style={{ padding: "6px 12px", background: "var(--risk)", color: "var(--accent-ink)",
              border: "none", borderRadius: "var(--r-ctl)", cursor: "pointer",
              fontWeight: "var(--w-medium)", fontFamily: "inherit" }}>{confirmLabel || "Delete"}</button>
        </div>
      </div>
    </div>
  );
}

function CMActionRow({ action, onToggle, onEdit, editing, ownerChoices, onSaveEdit, onCancelEdit, onRemoveAction }) {
  const { useState } = React;
  const overdue = !action.done && action.dueDate
    && new Date(action.dueDate) < new Date(new Date().toDateString());

  if (editing) {
    return <CMActionEditor action={action} ownerChoices={ownerChoices}
      onSave={onSaveEdit} onCancel={onCancelEdit} onRemove={onRemoveAction}/>;
  }

  return (
    <div style={{
      display: "flex", alignItems: "center", gap: 10, padding: "8px 10px",
      border: "1px solid var(--line)", borderRadius: "var(--r-ctl)",
      background: "var(--surface-2)",
    }}>
      {/* The checkbox completes. The BODY opens the editor. Previously the whole
          row toggled, so there was no way to fix a typo or assign someone. */}
      <span onClick={() => onToggle(action)} title="Mark complete"
        style={{
          width: 16, height: 16, flexShrink: 0, borderRadius: "var(--r-chip)", cursor: "pointer",
          border: "2px solid " + (action.done ? "var(--ok)" : "var(--line-strong)"),
          background: action.done ? "var(--ok)" : "transparent",
          display: "flex", alignItems: "center", justifyContent: "center",
          color: "var(--accent-ink)", fontSize: 10, fontWeight: "var(--w-medium)",
        }}>{action.done ? "\u2713" : ""}</span>
      <span onClick={() => onEdit(action)} title="Edit this action"
        style={{ flex: 1, minWidth: 0, wordBreak: "break-word", cursor: "text",
          textDecoration: action.done ? "line-through" : "none",
          color: action.done ? "var(--muted)" : "var(--ink-2)" }}>{action.nextAction}</span>
      {action.ownerId ? <CMAvatar personId={action.ownerId} size={20}/> : null}
      <span style={{ fontSize: "var(--t-micro)", flexShrink: 0,
        color: overdue ? "var(--risk)" : "var(--muted)" }}>
        {action.dueDate ? cmFmtDate(action.dueDate) : ""}
      </span>
      <button type="button" onClick={() => onEdit(action)} title="Edit"
        style={{ background: "transparent", border: "none", cursor: "pointer", padding: 0,
          color: "var(--muted)", fontSize: "var(--t-micro)", fontFamily: "inherit" }}>Edit</button>
    </div>
  );
}

// Inline editor, not a modal: an action is three fields and a modal for that
// would be heavier than the thing it edits.
function CMActionEditor({ action, ownerChoices, onSave, onCancel, onRemove }) {
  const { useState } = React;
  const [text, setText] = useState(action.nextAction || "");
  const [due, setDue] = useState(action.dueDate || "");
  const [owner, setOwner] = useState(action.ownerId || "");
  const st = {
    background: "var(--surface)", border: "1px solid var(--line-strong)",
    borderRadius: "var(--r-ctl)", padding: "7px 9px", fontFamily: "inherit", color: "var(--ink)",
  };
  return (
    <div style={{ border: "1px solid var(--accent)", borderRadius: "var(--r-ctl)",
      background: "var(--surface-2)", padding: "9px 10px", display: "flex",
      flexDirection: "column", gap: 8 }}>
      <input value={text} onChange={e => setText(e.target.value)}
        style={Object.assign({}, st, { width: "100%", boxSizing: "border-box" })}/>
      <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
        <select value={owner} onChange={e => setOwner(e.target.value)}
          style={Object.assign({}, st, { cursor: "pointer" })}>
          <option value="">Unassigned</option>
          {ownerChoices.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
        </select>
        <input type="date" value={due} onChange={e => setDue(e.target.value)} style={st}/>
        {/* REMOVE (Brian, 2026-09-01: actions could be edited but not removed).
            It sits on the LEFT, away from Save, and is styled as risk rather
            than as a third equal choice -- deleting and saving should not be
            adjacent buttons that look alike. */}
        <button type="button" onClick={() => onRemove(action)}
          style={{ padding: "6px 12px", background: "transparent", color: "var(--risk)",
            border: "1px solid var(--line)", borderRadius: "var(--r-ctl)", cursor: "pointer",
            fontFamily: "inherit", marginRight: "auto" }}>Remove</button>
        <button type="button" onClick={onCancel}
          style={{ padding: "6px 12px", background: "transparent", color: "var(--muted)",
            border: "1px solid var(--line)", borderRadius: "var(--r-ctl)", cursor: "pointer",
            fontFamily: "inherit" }}>Cancel</button>
        <button type="button"
          onClick={() => onSave(action, { nextAction: text.trim(), dueDate: due || null, ownerId: owner || null })}
          style={{ padding: "6px 14px", background: "var(--accent)", color: "var(--accent-ink)",
            border: "none", borderRadius: "var(--r-ctl)", cursor: "pointer",
            fontWeight: "var(--w-medium)", fontFamily: "inherit" }}>Save</button>
      </div>
    </div>
  );
}

function CMStatTiles({ stats }) {
  return (
    <div style={{ display: "grid", gap: 12, marginBottom: 16, minWidth: 0,
      gridTemplateColumns: "repeat(auto-fit, minmax(10.6rem, 1fr))" }}>
      {stats.map(st => (
        <div key={st.label} className="v-card" style={{ padding: "11px 14px" }}>
          <div style={{ fontSize: "var(--t-micro)", fontWeight: "var(--w-medium)",
            letterSpacing: "1.2px", textTransform: "uppercase", color: "var(--muted)" }}>{st.label}</div>
          <div style={{ fontSize: "var(--t-page)", fontWeight: "var(--w-medium)",
            color: "var(--ink)", marginTop: 2 }}>{st.val}</div>
        </div>
      ))}
    </div>
  );
}

function CMTabBar({ tab, counts, onChange }) {
  return (
    <div style={{ display: "flex", gap: 6, background: "var(--surface)",
      border: "1px solid var(--line)", borderRadius: "var(--r-card)", padding: 4 }}>
      {CM_TABS.map(t => {
        const on = t.id === tab;
        return (
          <button key={t.id} type="button" onClick={() => onChange(t.id)} data-seg={t.id}
            style={{
              border: "none", borderRadius: "var(--r-ctl)", padding: "7px 14px", cursor: "pointer",
              fontFamily: "inherit", fontSize: "var(--t-body)", fontWeight: "var(--w-medium)",
              background: on ? "var(--accent)" : "transparent",
              color: on ? "var(--accent-ink)" : "var(--muted)",
            }}>
            {t.label}
            <span style={{ fontWeight: "var(--w-regular)", opacity: .7, marginLeft: 4 }}>
              {counts[t.id]}
            </span>
          </button>
        );
      })}
    </div>
  );
}

// Card editor. Modelled on the Research Plan EntryModal: same scrim, same 560px
// panel, same header/footer shape, same "Remove" behind a confirm. Clicking a
// card opens it; the owner picker on the card face stays for the one-click case.
//
// Fields differ by kind because the two tables do:
//   prospect -> new_clients   (project, contact, buyer, market, service, stage, owner)
//   pending  -> client_pending_projects (project_name, buyer_name, harvey_project_id,
//                                        el_date, owner_id)
// harvey_project_id is the "proj id" Brian asked for. On a prospect there is no
// such column -- new_clients has never carried one -- so the field is absent
// rather than faked.
// ONE modal, two modes. A separate "new card" modal would be a second copy of
// eleven fields and would diverge the first time a column is added -- the same
// shape as the duplicated push control BUILD_39 warns about. `mode` is the only
// difference: the title, the Retire button, the save verb, and one extra field.
function CMCardModal({ kind, card, mode, ownerChoices, onCancel, onSave, onRetire }) {
  const { useState } = React;
  const isPending = kind === "pending";
  const isCreate = mode === "create";

  const [name, setName] = useState(isPending ? (card.projectName || "") : (card.project || ""));
  const [buyer, setBuyer] = useState(isPending ? (card.buyerName || "") : (card.buyer || ""));
  const [contact, setContact] = useState(card.client || "");
  const [market, setMarket] = useState(card.market || "");
  const [service, setService] = useState(card.service || "");
  const [projId, setProjId] = useState(card.harveyProjectId || "");
  const [regions, setRegions] = useState(Array.isArray(card.regions) ? card.regions : []);
  const [elDate, setElDate] = useState(isPending ? (card.elDate || "") : "");
  const [stage, setStage] = useState(isPending ? "" : (card.stageLabel || CM_NC_STAGES[0]));
  const [owner, setOwner] = useState(isPending ? (card.ownerId || "") : (card.owner || ""));
  const [note, setNote] = useState(card.note || card.notes || "");
  // new_clients.orig_date is NOT NULL and the edit form has never exposed it.
  // It stays absent from edit mode -- adding a field there would be a behaviour
  // change riding along with a new feature -- and appears only on create, where
  // the column has to be satisfied.
  const [origDate, setOrigDate] = useState(card.origDate || cmToday());
  const [confirmRetire, setConfirmRetire] = useState(false);
  const [saving, setSaving] = useState(false);

  const field = (label, node) => (
    <div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
      <div style={{ fontSize: "var(--t-micro)", fontWeight: "var(--w-medium)", letterSpacing: "1.2px",
        textTransform: "uppercase", color: "var(--muted)" }}>{label}</div>
      {node}
    </div>
  );
  const inputStyle = {
    background: "var(--surface-2)", border: "1px solid var(--line-strong)",
    borderRadius: "var(--r-ctl)", padding: "8px 10px", fontFamily: "inherit",
    color: "var(--ink)", width: "100%", boxSizing: "border-box",
  };
  // Metadata sits a step below the name.
  const metaStyle = Object.assign({}, inputStyle, {
    fontSize: "var(--t-micro)", padding: "7px 10px", color: "var(--ink-2)",
  });

  const submit = async () => {
    if (!String(name).trim()) { window.VaultUI.toast("info", "A name is required."); return; }
    // NO owner check here. onSaveCard already refuses a pending project with no
    // owner and produces a readable message; a second check in this file is a
    // redundant guard whose mutant passes every test, which is how two previous
    // guards in this project earned their deletion.
    setSaving(true);
    try {
      await onSave(isPending ? {
        projectName: name.trim(), buyerName: buyer.trim() || null,
        harveyProjectId: String(projId).trim() === "" ? null : String(projId).trim(),
        elDate: elDate || null, ownerId: owner || null, note: note || null,
      } : Object.assign({
        project: name.trim(), buyer: buyer.trim(), client: contact.trim(),
        market: market.trim(), service: service.trim(),
        // sql_96 added this column; it is nullable because a discussion usually
        // predates the Harvey project.
        harveyProjectId: String(projId).trim() || null,
        regions: regions,
        stageLabel: stage, owner: owner || "Unassigned", notes: note || null,
      // toNewClientPayload tests `"origDate" in camel`, not its value, so a key
      // present-but-undefined would write orig_date = NULL on every EDIT and hit
      // the NOT NULL. The key must be ABSENT unless we are creating.
      }, isCreate ? { origDate: origDate || cmToday() } : {}));
    } finally { setSaving(false); }
  };

  return (
    <div className="cm-screen" onClick={onCancel} style={{ position: "fixed", inset: 0, background: "var(--scrim)",
      display: "flex", alignItems: "center", justifyContent: "center", zIndex: 1200, padding: 20 }}>
      <div onClick={e => e.stopPropagation()} style={{ position: "relative", width: "42.5rem", maxWidth: "100%",
        maxHeight: "88vh", overflowY: "auto", background: "var(--surface)",
        border: "1px solid var(--line)", borderRadius: "var(--r-card)", boxShadow: "var(--shadow-modal)" }}>

        <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12,
          padding: "13px 16px", borderBottom: "1px solid var(--line)" }}>
          <span style={{ fontWeight: "var(--w-medium)", color: "var(--ink)" }}>
            {isCreate
              ? (isPending ? "New Pending Project" : "New Discussion")
              : (isPending ? "Edit Pending Project" : "Edit Discussion")}
          </span>
          <button type="button" onClick={onCancel}
            style={{ width: 24, height: 24, borderRadius: "var(--r-ctl)", border: "1px solid var(--line)",
              background: "var(--surface)", color: "var(--muted)", cursor: "pointer",
              fontFamily: "inherit" }}>{"\u2715"}</button>
        </div>

        {/* Two columns. Stacked, the prospect form ran past the viewport and the
            footer fell off the bottom of the screen. */}
        <div style={{ padding: "13px 16px 15px", display: "grid",
          gridTemplateColumns: "1fr 1fr", gap: "11px 14px" }}>
          {/* Name is the headline. Everything under it is metadata and reads
              one step down, so the hierarchy on the card carries into the form. */}
          <div style={{ gridColumn: "1 / -1" }}>
            {field("Name",
              <input value={name} onChange={e => setName(e.target.value)}
                style={Object.assign({}, inputStyle, {
                  fontSize: "var(--t-section)", fontWeight: "var(--w-medium)",
                  color: "var(--ink)", padding: "9px 12px",
                })}/>)}
          </div>
          {field("Buyer", <input value={buyer} onChange={e => setBuyer(e.target.value)} style={metaStyle}/>)}

          {isPending ? (
            <React.Fragment>
              {field("Project ID",
                <input value={projId} onChange={e => setProjId(e.target.value)}
                  placeholder="Blank until step 4" style={metaStyle}/>)}
              {field("EL Executed",
                <input type="date" value={elDate} onChange={e => setElDate(e.target.value)} style={metaStyle}/>)}
            </React.Fragment>
          ) : (
            <React.Fragment>
              {field("Contact", <input value={contact} onChange={e => setContact(e.target.value)} style={metaStyle}/>)}
              {field("Market", <input value={market} onChange={e => setMarket(e.target.value)} style={metaStyle}/>)}
              {field("Service", <input value={service} onChange={e => setService(e.target.value)} style={metaStyle}/>)}
              {/* sql_96. The design's leads carry `pid` and render it beside the
                  contact; the column did not exist until now. */}
              {field("Project ID",
                <input value={projId} onChange={e => setProjId(e.target.value)}
                  placeholder="Harvey project id, once one exists" style={metaStyle}/>)}
              {field("Stage",
                <select value={stage} onChange={e => setStage(e.target.value)} style={metaStyle}>
                  {CM_NC_STAGES.map(st => <option key={st} value={st}>{st}</option>)}
                </select>)}
              {isCreate ? field("Originated",
                <input type="date" value={origDate} onChange={e => setOrigDate(e.target.value)}
                  style={metaStyle}/>) : null}
              <div style={{ gridColumn: "1 / -1" }}>
              {field("Regions",
                <div style={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
                  {CM_REGIONS.map(r => {
                    const on = regions.indexOf(r) !== -1;
                    return (
                      <button key={r} type="button" className="cm-opt"
                        onClick={() => setRegions(prev => on ? prev.filter(x => x !== r) : prev.concat([r]))}
                        style={{
                          fontSize: "var(--t-micro)", padding: "3px 8px", cursor: "pointer",
                          borderRadius: "var(--r-pill)", fontFamily: "inherit",
                          border: "1px solid " + (on ? "var(--accent)" : "var(--line-strong)"),
                          background: on ? "var(--accent-soft)" : "var(--surface)",
                          color: on ? "var(--accent)" : "var(--muted)",
                        }}>{r}</button>
                    );
                  })}
                </div>)}
              </div>
            </React.Fragment>
          )}

          {field("Owner",
            <select value={owner} onChange={e => setOwner(e.target.value)} style={metaStyle}>
              <option value="">{isPending ? "Required" : "Unassigned"}</option>
              {ownerChoices.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
            </select>)}
          <div style={{ gridColumn: "1 / -1" }}>
            {field("Note",
              <textarea value={note} onChange={e => setNote(e.target.value)} rows={2}
                style={Object.assign({}, inputStyle, { resize: "vertical" })}/>)}
          </div>
        </div>

        <div style={{ display: "flex", justifyContent: "space-between", gap: 8,
          padding: "12px 16px", borderTop: "1px solid var(--line)" }}>
          {/* Nothing to retire before the row exists. Left in the layout as an
              empty slot so space-between still pushes the actions right, and
              because onRetire(card) on an id-less card would PATCH
              `?id=eq.undefined`, match zero rows, and close the modal looking
              like it worked. */}
          {isCreate ? <div/> : (
            <button type="button" onClick={() => setConfirmRetire(true)}
              style={{ padding: "7px 13px", background: "transparent", color: "var(--risk)",
                border: "1px solid var(--risk)", borderRadius: "var(--r-ctl)", cursor: "pointer",
                fontFamily: "inherit", fontSize: "var(--t-micro)" }}>Retire</button>
          )}
          <div style={{ display: "flex", gap: 8 }}>
            <button type="button" className="btn ghost" onClick={onCancel}>Cancel</button>
            <button type="button" className="btn primary" onClick={submit} disabled={saving}>
              {saving ? (isCreate ? "Creating\u2026" : "Saving\u2026") : (isCreate ? "Create" : "Save")}
            </button>
          </div>
        </div>

        {confirmRetire ? (
          <div onClick={() => setConfirmRetire(false)}
            style={{ position: "absolute", inset: 0, background: "var(--scrim)", display: "flex",
              alignItems: "center", justifyContent: "center", zIndex: 2, padding: 20 }}>
            <div onClick={e => e.stopPropagation()}
              style={{ width: "21rem", background: "var(--surface)", borderRadius: "var(--r-card)",
                border: "1px solid var(--line)", boxShadow: "var(--shadow-modal)", padding: "16px 18px" }}>
              <div style={{ fontWeight: "var(--w-medium)", color: "var(--ink)", marginBottom: 6 }}>
                {"Retire " + (name || "this card") + "?"}
              </div>
              <div style={{ color: "var(--muted)", lineHeight: 1.5, marginBottom: 14 }}>
                It moves to the Retired list. Nothing is deleted and it can be restored.
              </div>
              <div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}>
                <button type="button" onClick={() => setConfirmRetire(false)}
                  style={{ padding: "6px 12px", background: "transparent", color: "var(--muted)",
                    border: "1px solid var(--line)", borderRadius: "var(--r-ctl)", cursor: "pointer",
                    fontFamily: "inherit" }}>Cancel</button>
                <button type="button" onClick={() => onRetire(card)}
                  style={{ padding: "6px 12px", background: "var(--risk)", color: "var(--accent-ink)",
                    border: "none", borderRadius: "var(--r-ctl)", cursor: "pointer",
                    fontWeight: "var(--w-medium)", fontFamily: "inherit" }}>Retire</button>
              </div>
            </div>
          </div>
        ) : null}
      </div>
    </div>
  );
}

function CMArchiveModal({ title, rows, kind, onClose, onRestore }) {
  return (
    <div onClick={onClose}
      style={{ position: "fixed", inset: 0, background: "var(--scrim)", zIndex: 60,
        display: "flex", alignItems: "center", justifyContent: "center", padding: 20 }}>
      <div onClick={e => e.stopPropagation()}
        style={{
          background: "var(--surface)", borderRadius: "var(--r-card)", boxShadow: "var(--shadow-modal)",
          width: "min(720px, 100%)", maxHeight: "80vh", display: "flex", flexDirection: "column",
        }}>
        <div style={{ padding: "16px 20px", borderBottom: "1px solid var(--line)",
          display: "flex", alignItems: "center", justifyContent: "space-between" }}>
          <div style={{ fontSize: "var(--t-section)", fontWeight: "var(--w-medium)", color: "var(--ink)" }}>{title}</div>
          <button type="button" onClick={onClose}
            style={{ background: "transparent", border: "none", cursor: "pointer",
              color: "var(--muted)", fontSize: 18, fontFamily: "inherit", padding: 0 }}>{"\u00D7"}</button>
        </div>
        <div style={{ padding: "8px 20px 18px", overflowY: "auto" }}>
          {rows.length === 0 ? (
            <div style={{ padding: "26px 0", color: "var(--muted)", fontSize: "var(--t-body)", textAlign: "center" }}>
              Nothing here yet.
            </div>
          ) : rows.map(r => {
            const name = kind === "prospect" ? r.project : r.projectName;
            // Rows migrated from stages 10-12 have no successor pointer -- there
            // was no Harvey project id on new_clients to resolve. They render as
            // history rather than as a link that goes nowhere.
            const pointer = kind === "prospect" ? r.promotedToPendingId : r.promotedToProjectId;
            return (
              <div key={r.id} style={{ padding: "10px 0", borderBottom: "1px solid var(--line)",
                display: "flex", alignItems: "flex-start", gap: 10 }}>
                <div style={{ minWidth: 0, flex: 1 }}>
                  <div style={{ fontSize: "var(--t-body-lg)", color: "var(--ink)" }}>{name}</div>
                  <div style={{ fontSize: "var(--t-micro)", color: "var(--muted)", marginTop: 2 }}>
                    {cmFmtDate(r.archivedAt)}
                    {r.archivedReason === "progressed" && !pointer ? "  \u00B7  no successor recorded" : ""}
                  </div>
                  {r.archivedNote ? (
                    <div style={{ fontSize: "var(--t-micro)", color: "var(--muted)", marginTop: 3 }}>{r.archivedNote}</div>
                  ) : null}
                </div>
                <button type="button" onClick={() => onRestore(r)}
                  style={{ padding: "4px 10px", fontSize: "var(--t-micro)", fontFamily: "inherit",
                    cursor: "pointer", background: "transparent", color: "var(--accent)",
                    border: "1px solid var(--accent)", borderRadius: "var(--r-ctl)" }}>Restore</button>
              </div>
            );
          })}
        </div>
      </div>
    </div>
  );
}

function CMClientPanel({ row, assignment, notes, actions, notesLoading, noteDraft, setNoteDraft,
                         noteKind, setNoteKind, linkDraft, setLinkDraft, onAddNote, onDeleteNote,
                         onToggleAction, actionDraft, setActionDraft, actionDue, setActionDue,
                         onAddAction, myEmail, ownerChoices, onAssign, onSetPriority,
                         actionOwner, setActionOwner, editingActionId, onEditAction,
                         onSaveActionEdit, onCancelActionEdit, onRemoveAction, onEditNote }) {
  if (!row) {
    return (
      <div className="v-card" style={{ padding: "18px 20px", minHeight: 420 }}>
        <window.EmptyState title="Select a Client" hint="Pick one from the roster to see criteria, notes and actions."/>
      </div>
    );
  }
  // OPEN vs COMPLETED. Two lists off one field -- `done` already distinguishes
  // them, so nothing is archived, moved or deleted. The completed set folds.
  //
  // The toggle is keyed on the CLIENT: opening the completed list for one
  // client and then switching to another must not land you in an expanded list
  // you did not ask for. State alone would persist across the switch, because
  // this component is not remounted when `row` changes.
  const open_ = (actions || []).filter(a => !a.done);
  const doneActions = (actions || []).filter(a => a.done);
  const [showDone, setShowDone] = React.useState(false);
  const lastRowRef = React.useRef(row && row.projectId);
  if (lastRowRef.current !== (row && row.projectId)) {
    lastRowRef.current = row && row.projectId;
    if (showDone) setShowDone(false);
  }

  const catLine = cmCategoryLine(row);
  const prioKey = String(row.priority || "").toUpperCase().charAt(0);
  const ownerName = assignment ? cmPersonName(assignment.ownerId) : "unassigned";
  return (
    <div style={{ background: "var(--surface)", border: "1px solid var(--line)",
      borderRadius: "var(--r-card)", padding: "18px 20px", minHeight: 420 }}>
      <div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 12 }}>
        <div style={{ minWidth: 0 }}>
          <div style={{ fontSize: "var(--t-section)", fontWeight: "var(--w-medium)", color: "var(--ink)" }}>
            {row.buyerName}
          </div>
          <div style={{ color: "var(--muted)", fontSize: "var(--t-body)", marginTop: 2 }}>
            {[catLine, row.projectType, "owner " + ownerName].filter(Boolean).join(" \u00B7 ")}
          </div>
        </div>
        <div style={{ display: "flex", alignItems: "center", gap: 8, flexShrink: 0 }}>
          {/* B3: priority IS a Vault field -- project_priority, the same table
              Project Statistics writes. So it is editable here rather than a
              dead pill that sends you somewhere else. Values stay 'H'/'M'/'L'. */}
          <select value={prioKey} onChange={e => onSetPriority(row.projectId, e.target.value)}
            style={{
              fontSize: "var(--t-micro)", fontWeight: "var(--w-medium)", padding: "3px 10px",
              borderRadius: "var(--r-pill)", cursor: "pointer", fontFamily: "inherit",
              background: prioKey === "H" ? "var(--risk-soft)" : prioKey === "M" ? "var(--gold-soft)" : "var(--surface-2)",
              color: CM_PRIO_COLOR[prioKey] || "var(--muted)",
              border: "1px solid " + (prioKey ? (CM_PRIO_COLOR[prioKey] || "var(--line)") : "var(--line-strong)"),
            }}>
            {/* Each option carries ITS OWN colour. The select's colour follows
                the current value, so without this every option inherited the
                chosen priority's colour and the list read as one hue. */}
            <option value="" style={{ color: "var(--muted)", background: "var(--surface)" }}>No Priority</option>
            <option value="H" style={{ color: CM_PRIO_COLOR.H, background: "var(--surface)" }}>High Priority</option>
            <option value="M" style={{ color: CM_PRIO_COLOR.M, background: "var(--surface)" }}>Medium Priority</option>
            <option value="L" style={{ color: CM_PRIO_COLOR.L, background: "var(--surface)" }}>Low Priority</option>
          </select>
          <select value={assignment ? assignment.ownerId : ""}
            onChange={e => onAssign(row.projectId, e.target.value)}
            style={{
              padding: "6px 10px", fontSize: "var(--t-micro)", fontFamily: "inherit",
              background: "var(--surface)", color: "var(--ink-2)",
              border: "1px solid var(--line-strong)", borderRadius: "var(--r-ctl)", cursor: "pointer",
            }}>
            <option value="">Unassigned</option>
            {ownerChoices.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
          </select>
        </div>
      </div>

      <div style={{ display: "flex", alignItems: "baseline", gap: 10 }}>
        <window.VaultKicker style={{ margin: "18px 0 8px" }}>Target Criteria</window.VaultKicker>
        {/* B6: this is projects_registry.project_name -- Harvey's `details`
            field, written by the Projects Master scrape. Vault cannot edit it,
            so the control goes to where it IS editable. */}
        {window.HarveyLink ? (
          <window.HarveyLink projectId={row.projectId}
            style={{ fontSize: "var(--t-micro)", color: "var(--accent)" }}>
            {"Edit in Harvey \u2197"}
          </window.HarveyLink>
        ) : null}
        <span style={{ fontSize: "var(--t-micro)", color: "var(--muted)", marginLeft: "auto" }}>
          {"Project " + row.projectId}
        </span>
      </div>
      {row.detail ? (
        <React.Fragment>
          <div style={{ background: "var(--surface-2)", border: "1px solid var(--line)",
            borderRadius: "var(--r-ctl)", padding: "10px 12px", color: "var(--ink-2)",
            whiteSpace: "pre-wrap", lineHeight: 1.5 }}>{row.detail}</div>
        </React.Fragment>
      ) : (
        <div style={{ background: "var(--surface-2)", border: "1px dashed var(--line-strong)",
          borderRadius: "var(--r-ctl)", padding: "10px 12px", color: "var(--muted)" }}>
          No criteria recorded on the Harvey project.
        </div>
      )}

      {/* COMPLETED ACTIONS FOLD AWAY (Brian, 2026-09-01: "can we remove completed
          actions from a client page to an archive so they don't stack up").
          Not deleted and not a separate table -- `done` already says which is
          which, and a client with a long history is one whose completed work is
          the record of what was done. Folded, counted, one click away.
          The heading says "Next Few Weeks", so a permanently-visible list of
          finished work was contradicting the section's own name. */}
      <window.VaultKicker style={{ margin: "18px 0 8px" }}>{"Priority Actions \u2014 Next Few Weeks"}</window.VaultKicker>
      <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
        {open_.length === 0 && doneActions.length === 0 ? (
          <div className="v-empty" style={{ color: "var(--muted)" }}>No actions yet.</div>
        ) : null}
        {open_.length === 0 && doneActions.length > 0 ? (
          <div className="v-empty" style={{ color: "var(--muted)" }}>
            Nothing outstanding.
          </div>
        ) : null}
        {open_.map(a => (
          <CMActionRow key={a.id} action={a} onToggle={onToggleAction}
            onEdit={onEditAction} editing={editingActionId === a.id}
            ownerChoices={ownerChoices} onSaveEdit={onSaveActionEdit}
            onCancelEdit={onCancelActionEdit} onRemoveAction={onRemoveAction}/>
        ))}

        {doneActions.length ? (
          <React.Fragment>
            <button type="button" onClick={() => setShowDone(v => !v)}
              style={{ alignSelf: "flex-start", background: "transparent", border: "none",
                padding: "2px 0", cursor: "pointer", fontFamily: "inherit",
                fontSize: "var(--t-micro)", color: "var(--muted)" }}>
              {(showDone ? "\u25be " : "\u25b8 ") + doneActions.length + " completed"}
            </button>
            {showDone ? doneActions.map(a => (
              <CMActionRow key={a.id} action={a} onToggle={onToggleAction}
                onEdit={onEditAction} editing={editingActionId === a.id}
                ownerChoices={ownerChoices} onSaveEdit={onSaveActionEdit}
                onCancelEdit={onCancelActionEdit} onRemoveAction={onRemoveAction}/>
            )) : null}
          </React.Fragment>
        ) : null}
      </div>
      <div style={{ display: "flex", gap: 8, marginTop: 10 }}>
        <input value={actionDraft} onChange={e => setActionDraft(e.target.value)}
          placeholder="Add an action"
          style={{ flex: 1, minWidth: 0, background: "var(--surface-2)",
            border: "1px solid var(--line-strong)", borderRadius: "var(--r-ctl)",
            padding: "9px 11px", fontFamily: "inherit", color: "var(--ink)" }}/>
        {/* B1: sql_95 made owner_id nullable, so this may stay Unassigned --
            but there was no way to SET it at all. */}
        <select value={actionOwner} onChange={e => setActionOwner(e.target.value)}
          style={{ background: "var(--surface-2)", border: "1px solid var(--line-strong)",
            borderRadius: "var(--r-ctl)", padding: "9px 10px", fontFamily: "inherit",
            fontSize: "var(--t-micro)", color: "var(--ink-2)", cursor: "pointer" }}>
          <option value="">Unassigned</option>
          {ownerChoices.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
        </select>
        <input type="date" value={actionDue} onChange={e => setActionDue(e.target.value)}
          style={{ background: "var(--surface-2)", border: "1px solid var(--line-strong)",
            borderRadius: "var(--r-ctl)", padding: "9px 10px", fontFamily: "inherit",
            fontSize: "var(--t-micro)", color: "var(--ink-2)" }}/>
        <button type="button" className="btn primary" onClick={onAddAction}>Add</button>
      </div>

      <window.VaultKicker style={{ margin: "18px 0 8px" }}>Notes Repository</window.VaultKicker>
      <div style={{ display: "flex", gap: 8, alignItems: "flex-start", marginBottom: 12 }}>
        <div style={{ flex: 1, minWidth: 0 }}>
          <textarea value={noteDraft} onChange={e => setNoteDraft(e.target.value)}
            placeholder={noteKind === "call"
              ? "What was discussed, decided, or agreed\u2026"
              : noteKind === "call_sheet" ? "Call sheet name or summary \u2014 attach the link below\u2026"
              : noteKind === "project_overview" ? "Project overview name or summary \u2014 attach the link below\u2026"
              : "Add a key detail, or paste text\u2026"}
            style={{ width: "100%", boxSizing: "border-box", background: "var(--surface-2)",
              border: "1px solid var(--line-strong)", borderRadius: "var(--r-ctl)",
              padding: "9px 11px", minHeight: 38, resize: "vertical", fontFamily: "inherit",
              color: "var(--ink)" }}/>
          {/* THE LINK IS ALWAYS AVAILABLE (Brian, 2026-09-01). It used to appear
              only for kind "file", which made an attachment a KIND of entry
              rather than something attached to one -- so a call with a recording
              had to be filed as neither a call nor a note. Optional on both. */}
          {true ? (
            <input value={linkDraft} onChange={e => setLinkDraft(e.target.value)}
              placeholder="https://  OneDrive or SharePoint link (optional)"
              style={{ width: "100%", boxSizing: "border-box", marginTop: 7,
                background: "var(--surface-2)", border: "1px solid var(--line-strong)",
                borderRadius: "var(--r-ctl)", padding: "8px 11px", fontFamily: "inherit",
                color: "var(--ink)" }}/>
          ) : null}
        </div>
        {/* TWO KINDS, because there are two things that happen: you wrote
            something down, or you had a call. "Link" was never a third -- it is
            an attachment, and it now sits on the field above, available to
            both. sql_202 reclassified the 13 existing link rows as Update Calls
            on Brian's ruling and dropped the constraint that made a link legal
            only on kind='file'. */}
        {/* Call Sheet and Project Overview added 2026-09-03 (sql_220). Each is
            a kind of entry, the link rides along; Development counts a Call
            Sheet on save and sends a Project Overview to the lead for review. */}
        <window.VaultSeg options={[["note", "Note"], ["call", "Update Call"], ["call_sheet", "Call Sheet"], ["project_overview", "Project Overview"]]}
          value={noteKind} onChange={setNoteKind}/>
        <button type="button" className="btn primary" onClick={onAddNote}>Add</button>
      </div>
      <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
        {notesLoading ? <window.Skeleton rows={3}/>
          : notes.length === 0 ? <div style={{ color: "var(--muted)" }}>No notes yet.</div>
          : notes.slice().sort((x, y) => {
              if (!!x.pinned !== !!y.pinned) return x.pinned ? -1 : 1;
              return String(y.createdAt || "").localeCompare(String(x.createdAt || ""));
            }).map(n => (
            <CMNoteRow key={n.id} note={n} onDelete={onDeleteNote}
              canDelete={/* A NOTE BELONGS TO THE MODULE, NOT TO WHOEVER TYPED IT
                (Brian, 2026-09-01). This was author-only, so an Update Call
                Blake recorded could not be removed by anyone else on the team.
                sql_204 moved the policy; this matches it. Client Management
                shows one module at a time, so everyone looking can act. */ true}
              onEdit={onEditNote}/>
          ))}
      </div>
    </div>
  );
}

// ---------------------------------------------------------------------------
// The screen.

function ClientManagementScreen({ user }) {
  const { useState, useEffect, useCallback } = React;

  // Module scope. VaultOrg.defaultModule returns null when the viewer has no
  // team -- render an empty state rather than silently showing someone else's
  // book, which is what the old hardcoded "bscott" default did.
  // SCOPING POLICY: QUEUE (BUILD_37 §2, ruled 2026-08-26). Client Management is
  // a work board -- discussions you are driving, projects you are standing up --
  // so another module's cards are their work, not your scoreboard. Your own
  // module by default; another is selectable ONLY with canSeeAllModules().
  // There is deliberately NO "All": a merged board puts two modules' cards in
  // one column with nothing to tell them apart.
  //
  // Built from VaultOrg primitives, never a `|| ` chain (BUILD_37 acceptance 2).
  // resolveModule() clamps a requested handle to something this viewer may
  // actually have and returns null when they have none, which the render below
  // shows as an empty state rather than as somebody else's book.
  const org = window.VaultOrg || {};
  const canSeeAll = org.canSeeAllModules ? org.canSeeAllModules(user) : false;
  const modChoices = org.selectableModules ? org.selectableModules(user) : [];
  const [modPick, setModPick] = useState(null);
  const moduleId = org.resolveModule
    ? org.resolveModule(user, modPick)
    : (org.defaultModule ? org.defaultModule(user) : null);
  const myEmail = String((user && (user.email || user.userEmail)) || "").toLowerCase();

  const [tab, setTab] = useState("new");
  const [loading, setLoading] = useState(true);
  const [err, setErr] = useState(null);

  const [leads, setLeads] = useState([]);
  const [pending, setPending] = useState([]);
  const [active, setActive] = useState([]);
  const [assignments, setAssignments] = useState([]);

  const [regionsOpenId, setRegionsOpenId] = useState(null);
  const [ppOpenId, setPpOpenId] = useState(null);
  const [selId, setSelId] = useState(null);

  const [notes, setNotes] = useState([]);
  const [notesLoading, setNotesLoading] = useState(false);
  const [actions, setActions] = useState([]);
  const [noteDraft, setNoteDraft] = useState("");
  const [noteKind, setNoteKind] = useState("note");
  const [linkDraft, setLinkDraft] = useState("");
  const [actionDraft, setActionDraft] = useState("");
  const [actionDue, setActionDue] = useState("");

  const [editCard, setEditCard] = useState(null); // {kind, card}
  const [editingActionId, setEditingActionId] = useState(null);
  const [confirmDeleteNote, setConfirmDeleteNote] = useState(null);
  const [prioFilter, setPrioFilter] = useState("all");
  const [rosterSort, setRosterSort] = useState("priority"); // priority | name | actions
  const [rosterQ, setRosterQ] = useState("");
  const [ownerOpenId, setOwnerOpenId] = useState(null);
  const [dropTarget, setDropTarget] = useState(null); // {id, edge}
  const [actionOwner, setActionOwner] = useState("");
  const [subteam, setSubteam] = useState("all");
  const [person, setPerson] = useState("all");
  const [archiveView, setArchiveView] = useState(null); // {kind, reason}
  const [archiveRows, setArchiveRows] = useState([]);

  // One roster, from VaultOrg. Was a local filter that kept inactive people and
  // matched `p.id === moduleId` as well as `p.team`, which quietly added the
  // module lead a second time when they had no team of their own.
  const ownerChoices = (org.roster ? org.roster(moduleId) : []);

  const reload = useCallback(async () => {
    setLoading(true); setErr(null);
    try {
      const [l, p, a, asg] = await Promise.all([
        window.VaultAPI.listNewClients(moduleId),
        window.VaultAPI.listPendingProjects(moduleId),
        moduleId ? window.VaultAPI.listActiveClients(moduleId) : Promise.resolve([]),
        window.VaultAPI.listClientAssignments(moduleId),
      ]);
      setLeads(l); setPending(p); setActive(a); setAssignments(asg);
      if (!selId && a.length) setSelId(a[0].projectId);
    } catch (e) {
      // Name the error. Burying it behind an empty board makes a permissions
      // problem look like an empty pipeline.
      setErr(String((e && e.message) || e));
    } finally {
      setLoading(false);
    }
  }, [moduleId, selId]);

  useEffect(() => { reload(); }, [moduleId]);

  // Detail load for the selected active client.
  useEffect(() => {
    if (tab !== "active" || !selId) { setNotes([]); setActions([]); return; }
    let cancelled = false;
    setNotesLoading(true);
    // NOT list_project_actions(): its WHERE is owner_id = ANY(p_owner_ids), and
    // sql_95 made owner_id nullable, so an unclaimed action can never come back
    // from that RPC. This reads the table on (project_id, due_date) instead.
    Promise.all([
      window.VaultAPI.listClientNotes("project", selId),
      // Guarded: if a stale vault-api.js is deployed this reports a readable
      // error instead of throwing inside an effect and killing the screen.
      typeof window.VaultAPI.listProjectActionsForProject === "function"
        ? window.VaultAPI.listProjectActionsForProject(selId, true)
        : Promise.reject(new Error("vault-api.js is out of date: listProjectActionsForProject is missing. Redeploy vault-api.js.")),
    ]).then(([n, acts]) => {
      if (cancelled) return;
      setNotes(n);
      setActions(acts || []);
    }).catch(e => { if (!cancelled) setErr(String((e && e.message) || e)); })
      .finally(() => { if (!cancelled) setNotesLoading(false); });
    return () => { cancelled = true; };
  }, [tab, selId]);

  const dragRef = React.useRef(null);

  async function guard(fn) {
    try { await fn(); }
    catch (e) { window.VaultUI.toast("error", String((e && e.message) || e)); }
  }

  // -- New tab handlers -----------------------------------------------------
  const onDropStage = stage => guard(async () => {
    const id = dragRef.current; if (!id) return;
    dragRef.current = null;
    const lead = leads.find(l => l.id === id);
    if (!lead || lead.stageLabel === stage) return;
    setLeads(prev => prev.map(l => l.id === id ? { ...l, stageLabel: stage } : l));
    await window.VaultAPI.updateNewClient(id, { stageLabel: stage });
  });

  // B5: drop ONTO a card reorders within the stage. sort_order is numeric so an
  // insert takes the midpoint of its neighbours and no sibling row is rewritten
  // -- the same reason rpm_cards uses numeric. Only the moved card is PATCHed.
  const onDropOnCard = (target, e, isHover) => {
    const id = dragRef.current;
    if (!id || id === target.id) { if (!isHover) setDropTarget(null); return; }
    const rect = e.currentTarget.getBoundingClientRect();
    const edge = e.clientY > rect.top + rect.height / 2 ? "below" : "above";
    if (isHover) { setDropTarget({ id: target.id, edge }); return; }
    setDropTarget(null);
    dragRef.current = null;
    guard(async () => {
      const drag = leads.find(l => l.id === id);
      if (!drag) return;
      const stage = target.stageLabel;
      const col = leads.filter(l => l.stageLabel === stage && l.id !== id);
      const ti = col.findIndex(l => l.id === target.id);
      if (ti < 0) return;
      const insertAt = ti + (edge === "below" ? 1 : 0);
      const before = insertAt > 0 ? col[insertAt - 1] : null;
      const after = insertAt < col.length ? col[insertAt] : null;
      const bo = before && before.sortOrder != null ? Number(before.sortOrder) : null;
      const ao = after && after.sortOrder != null ? Number(after.sortOrder) : null;
      let next;
      if (bo == null && ao == null) next = 1000;
      else if (bo == null) next = ao - 1;
      else if (ao == null) next = bo + 1;
      else next = (bo + ao) / 2;
      setLeads(prev => {
        const rest = prev.filter(l => l.id !== id);
        const moved = { ...drag, stageLabel: stage, sortOrder: next };
        const at = rest.findIndex(l => l.id === target.id);
        const pos = at + (edge === "below" ? 1 : 0);
        return rest.slice(0, pos).concat([moved]).concat(rest.slice(pos));
      });
      await window.VaultAPI.updateNewClient(id, { stageLabel: stage, sortOrder: next });
    });
  };

  // B2: change the owner of a discussion card.
  const onPickLeadOwner = (lead, pid) => guard(async () => {
    setOwnerOpenId(null);
    setLeads(prev => prev.map(l => l.id === lead.id ? { ...l, owner: pid || "Unassigned" } : l));
    await window.VaultAPI.updateNewClient(lead.id, { owner: pid || "Unassigned" });
  });

  // B2: change the owner of a pending project. sql_89 has owner_id NOT NULL,
  // so clearing it is refused here with a readable message rather than a 400.
  const onPickPendingOwner = (proj, pid) => guard(async () => {
    setOwnerOpenId(null);
    if (!pid) throw new Error("A pending project must have an owner.");
    setPending(prev => prev.map(p => p.id === proj.id ? { ...p, ownerId: pid } : p));
    await window.VaultAPI.updatePendingProject(proj.id, { ownerId: pid });
  });

  // B3: priority is project_priority -- a Vault table, editable here.
  const onSetPriority = (projectId, letter) => guard(async () => {
    const row = active.find(r => r.projectId === projectId);
    setActive(prev => prev.map(r => r.projectId === projectId ? { ...r, priority: letter || null } : r));
    if (!letter) {
      // project_priority.priority is NOT NULL -- upserting null returned 23502.
      // "No priority" is the ABSENCE of a row, and list_active_clients LEFT
      // JOINs, so deleting reads back as null.
      await window.VaultAPI.clearProjectPriority(projectId);
      return;
    }
    await window.VaultAPI.upsertProjectPriority({
      project_id: projectId,
      project_name: row ? row.buyerName : null,
      priority: letter,
    });
  });

  const onToggleRegion = (lead, region) => guard(async () => {
    const cur = Array.isArray(lead.regions) ? lead.regions : [];
    const next = cur.indexOf(region) === -1 ? cur.concat([region]) : cur.filter(r => r !== region);
    setLeads(prev => prev.map(l => l.id === lead.id ? { ...l, regions: next } : l));
    await window.VaultAPI.updateNewClient(lead.id, { regions: next });
  });

  const onPromoteLead = lead => guard(async () => {
    const created = await window.VaultAPI.createPendingProject({
      module: moduleId, ownerId: lead.owner || moduleId,
      projectName: lead.project, buyerName: lead.buyer || "",
      // Was toISOString().slice(0,10) -- UTC, so a promote after 5pm Pacific
      // stamped tomorrow's EL date. cmToday() is local.
      elDate: cmToday(), stage: 0,
      sourceNewClientId: lead.id,
      note: "Promoted from New Client Discussions (EL executed).",
    });
    await window.VaultAPI.archiveNewClient(lead.id, "progressed",
      "Promoted to Pending Projects.", created.id);
    setLeads(prev => prev.filter(l => l.id !== lead.id));
    setPending(prev => [created].concat(prev));
    window.VaultUI.toast("success", lead.project + " moved to Pending Projects.");
  });

  const onRetireLead = lead => guard(async () => {
    const yes = await window.VaultUI.confirm({
      title: "Retire this discussion?",
      message: lead.project + " moves to the Retired list. Nothing is deleted.",
    });
    if (!yes) return;
    await window.VaultAPI.archiveNewClient(lead.id, "retired", null, null);
    setLeads(prev => prev.filter(l => l.id !== lead.id));
  });

  // -- Pending tab handlers -------------------------------------------------
  const onSetStage = (proj, idx) => guard(async () => {
    // Design behaviour: clicking the step you are already past by one steps back.
    const next = proj.stage === idx + 1 ? idx : idx + 1;
    setPending(prev => prev.map(p => p.id === proj.id ? { ...p, stage: next } : p));
    await window.VaultAPI.setPendingStage(proj.id, next);
  });

  const onPromotePending = proj => guard(async () => {
    await window.VaultAPI.archivePendingProject(proj.id, "progressed",
      "List approved -- now an active engagement.", proj.harveyProjectId || null);
    setPending(prev => prev.filter(p => p.id !== proj.id));
    window.VaultUI.toast("success", proj.projectName + " promoted. It appears in Active once the registry scrape marks it active.");
  });

  const onRetirePending = proj => guard(async () => {
    const yes = await window.VaultUI.confirm({
      title: "Retire this project?",
      message: proj.projectName + " moves to the Retired list. Nothing is deleted.",
    });
    if (!yes) return;
    await window.VaultAPI.archivePendingProject(proj.id, "retired", null, null);
    setPending(prev => prev.filter(p => p.id !== proj.id));
  });

  // -- Active tab handlers --------------------------------------------------
  const onAssign = (projectId, ownerId) => guard(async () => {
    if (!ownerId) {
      await window.VaultAPI.clearClientAssignment(projectId);
      setAssignments(prev => prev.filter(a => a.projectId !== projectId));
      return;
    }
    const saved = await window.VaultAPI.upsertClientAssignment(projectId, ownerId, moduleId);
    setAssignments(prev => prev.filter(a => a.projectId !== projectId).concat(saved ? [saved] : []));
  });

  const onAddNote = () => guard(async () => {
    const body = String(noteDraft || "").trim();
    if (!body) { window.VaultUI.toast("info", "Write something first."); return; }
    const created = await window.VaultAPI.createClientNote({
      subjectKind: "project", subjectKey: selId, module: moduleId,
      kind: noteKind, note: body,
      // The link rides along with EITHER kind now, and an empty box means no
      // attachment rather than an empty string -- link_https_ck rejects "".
      linkUrl: String(linkDraft || "").trim() || null,
      authorEmail: myEmail || null,
    });
    setNotes(prev => [created].concat(prev));
    setNoteDraft(""); setLinkDraft("");
  });

  // Deleting a note is destructive and used to happen on one click.
  const [confirmRemoveAction, setConfirmRemoveAction] = React.useState(null);
  const onDeleteNote = note => setConfirmDeleteNote(note);
  const onConfirmDeleteNote = () => guard(async () => {
    const note = confirmDeleteNote;
    setConfirmDeleteNote(null);
    if (!note) return;
    await window.VaultAPI.deleteClientNote(note.id);
    setNotes(prev => prev.filter(n => n.id !== note.id));
  });

  // EDITING A NOTE. In place, keeping created_by and created_at: a correction is
  // not a new note by whoever fixed it, and re-creating would rewrite both.
  const onEditNote = (note, text) => guard(async () => {
    const body = String(text || "").trim();
    if (!body) return;                       // note_nonempty_ck rejects "" anyway
    const saved = await window.VaultAPI.updateClientNote(note.id, { note: body });
    setNotes(prev => prev.map(n => (n.id === note.id ? Object.assign({}, n, saved || { note: body }) : n)));
  });

  // REMOVING A PRIORITY ACTION. Confirmed, like note deletion -- an action can
  // be the only record that something was promised.
  const onRemoveAction = action => setConfirmRemoveAction(action);
  const onConfirmRemoveAction = () => guard(async () => {
    const a = confirmRemoveAction;
    setConfirmRemoveAction(null);
    if (!a) return;
    await window.VaultAPI.deleteProjectAction(a.id);
    setActions(prev => prev.filter(x => x.id !== a.id));
    setEditingActionId(null);
  });

  const onAddAction = () => guard(async () => {
    const text = String(actionDraft || "").trim();
    if (!text) { window.VaultUI.toast("info", "Describe the action first."); return; }
    const row = active.find(r => r.projectId === selId);
    // createProjectAction has had no caller since the Action Dashboard was
    // deleted (HANDOFF_29 s7.2). This is it.
    const created = await window.VaultAPI.createProjectAction({
      ownerId: actionOwner || null, projectId: selId, projectName: row ? row.buyerName : "",
      module: moduleId, nextAction: text, dueDate: actionDue || null, done: false,
    });
    setActions(prev => prev.concat([created || {
      id: "tmp" + Date.now(), nextAction: text, dueDate: actionDue || null, done: false, ownerId: null,
    }]));
    setActionDraft(""); setActionDue(""); setActionOwner("");
    setActive(prev => prev.map(r => r.projectId === selId
      ? { ...r, openActions: (r.openActions || 0) + 1 } : r));
  });

  const onToggleAction = action => guard(async () => {
    const next = !action.done;
    setActions(prev => prev.map(a => a.id === action.id ? { ...a, done: next } : a));
    await window.VaultAPI.updateProjectAction(action.id, { done: next });
    setActive(prev => prev.map(r => r.projectId === selId
      ? { ...r, openActions: Math.max(0, (r.openActions || 0) + (next ? -1 : 1)) } : r));
  });

  // -- Card editing ---------------------------------------------------------
  // Opens the shared modal in create mode. `stage` is only meaningful for a
  // prospect and comes from the column the + was clicked in.
  const openNewCard = (kind, stage) => {
    if (!moduleId) { window.VaultUI.toast("error", "No module scope, so there is nowhere to file this."); return; }
    setEditCard({
      kind, mode: "create",
      card: kind === "pending"
        ? { projectName: "", buyerName: "", harveyProjectId: "", elDate: "", ownerId: "", note: "" }
        : { project: "", buyer: "", client: "", market: "", service: "",
            harveyProjectId: "", regions: [], notes: "",
            stageLabel: stage || CM_NC_STAGES[0], owner: "" },
    });
  };

  const onSaveCard = patch => guard(async () => {
    const { kind, card, mode } = editCard;
    const creating = mode === "create";
    if (kind === "pending") {
      // sql_89 has owner_id NOT NULL -- refuse here with a readable message
      // rather than letting PostgREST 400 with a constraint name.
      if (!patch.ownerId) throw new Error("A pending project must have an owner.");
      if (creating) {
        // module is NOT NULL *and* listPendingProjects filters on it. Omit it
        // and the insert 400s; get it wrong and the row saves, then disappears
        // on the next load -- present in the table, invisible on every screen.
        // onPromoteLead already stamps it; this is the same stamp.
        const created = await window.VaultAPI.createPendingProject(Object.assign({}, patch, {
          module: moduleId, stage: 0, stageEnteredAt: new Date().toISOString(),
        }));
        setPending(prev => [created].concat(prev));
      } else {
        const saved = await window.VaultAPI.updatePendingProject(card.id, patch);
        setPending(prev => prev.map(p => p.id === card.id ? saved : p));
      }
    } else {
      if (creating) {
        // sql_141. Same reason module is stamped on a pending project: without
        // it the row lands with a NULL module and is invisible to every board,
        // including the one that just created it.
        const created = await window.VaultAPI.createNewClient(
          Object.assign({}, patch, { module: moduleId }));
        setLeads(prev => [created].concat(prev));
      } else {
        const saved = await window.VaultAPI.updateNewClient(card.id, patch);
        setLeads(prev => prev.map(l => l.id === card.id ? saved : l));
      }
    }
    setEditCard(null);
    window.VaultUI.toast("success", creating ? "Created." : "Saved.");
    // A row created for someone outside the current filter writes fine and then
    // is not on screen. Say so, rather than leaving it looking like the save
    // failed -- this app has lost days to writes that succeeded invisibly.
    if (creating) {
      const newOwner = kind === "pending" ? patch.ownerId : patch.owner;
      if (!inScope(newOwner)) {
        window.VaultUI.toast("info", "Saved, but the current sub-team or individual filter hides it. Use Clear Scope to see it.");
      }
    }
  });

  const onRetireFromModal = card => guard(async () => {
    const kind = editCard.kind;
    setEditCard(null);
    if (kind === "pending") {
      await window.VaultAPI.archivePendingProject(card.id, "retired", null, null);
      setPending(prev => prev.filter(p => p.id !== card.id));
    } else {
      await window.VaultAPI.archiveNewClient(card.id, "retired", null, null);
      setLeads(prev => prev.filter(l => l.id !== card.id));
    }
  });

  const onSaveActionEdit = (action, patch) => guard(async () => {
    if (!String(patch.nextAction || "").trim()) {
      throw new Error("An action needs some text.");
    }
    setEditingActionId(null);
    setActions(prev => prev.map(a => a.id === action.id ? { ...a, ...patch } : a));
    await window.VaultAPI.updateProjectAction(action.id, patch);
  });

  // -- Archive views --------------------------------------------------------
  const openArchive = (kind, reason) => guard(async () => {
    const rows = kind === "prospect"
      ? await window.VaultAPI.listArchivedNewClients(reason, moduleId)
      : await window.VaultAPI.listArchivedPendingProjects(moduleId, reason);
    setArchiveRows(rows);
    setArchiveView({ kind, reason });
  });

  const onRestore = row => guard(async () => {
    if (archiveView.kind === "prospect") await window.VaultAPI.unarchiveNewClient(row.id);
    else await window.VaultAPI.unarchivePendingProject(row.id);
    setArchiveRows(prev => prev.filter(r => r.id !== row.id));
    await reload();
  });

  // -- Render ---------------------------------------------------------------
  const selRow = active.find(r => r.projectId === selId) || null;
  const asgByPid = {};
  assignments.forEach(a => { asgByPid[a.projectId] = a.ownerId; });

  const peopleAll = (org.roster ? org.roster(moduleId) : []);

  // B4: the dropdown read p.subteam, which data-firm.js builds as
  // "st-" + branchId and explicitly documents as NOT a sub-team -- hence the
  // single "st-bscott" entry. The real sub-teams (Burton, Scheftz) are
  // F.SUBSUBTEAMS, keyed by parentSubteam and carrying memberIds. Same source
  // ScopeFilters uses, so the two screens now agree.
  const subteams = ((window.VAULT_FIRM && window.VAULT_FIRM.SUBSUBTEAMS) || [])
    .filter(sst => !moduleId || sst.parentSubteam === "st-" + moduleId)
    .map(sst => ({ id: sst.id, label: sst.label, members: new Set(sst.memberIds || []) }));
  const subteamById = {};
  subteams.forEach(st => { subteamById[st.id] = st; });

  const peopleOpts = peopleAll
    .filter(p => subteam === "all" || (subteamById[subteam] && subteamById[subteam].members.has(p.id)))
    .map(p => ({ id: p.id, name: p.name }))
    .sort((x, y) => String(x.name).localeCompare(String(y.name)));

  // One predicate, three tabs. Each carries its owner on a different field.
  const inScope = ownerId => {
    if (person !== "all") return ownerId === person;
    if (subteam === "all") return true;
    const st = subteamById[subteam];
    return !!st && st.members.has(ownerId);
  };

  const vLeads   = leads.filter(l => inScope(l.owner));
  const vPending = pending.filter(p => inScope(p.ownerId));
  const vActive  = active.filter(r => inScope(asgByPid[r.projectId]) || (person === "all" && subteam === "all"));

  const counts = { new: vLeads.length, pending: vPending.length, active: vActive.length };

  const stats = tab === "new" ? [
    { label: "Live Discussions", val: vLeads.length },
    { label: "At EL Stage", val: vLeads.filter(l => String(l.stageLabel).indexOf("EL ") === 0).length },
    { label: "Untagged Regions", val: vLeads.filter(l => !(l.regions || []).length).length },
    { label: "Owners", val: new Set(vLeads.map(l => l.owner).filter(Boolean)).size },
  ] : tab === "pending" ? [
    { label: "In Setup", val: vPending.length },
    { label: "Ready to Promote", val: vPending.filter(p => p.stage >= CM_PP_STAGES.length).length },
    { label: "Awaiting List", val: vPending.filter(p => p.stage > 0 && p.stage < CM_PP_STAGES.length).length },
    { label: "Not Started", val: vPending.filter(p => p.stage === 0).length },
  ] : [
    { label: "Active Clients", val: vActive.length },
    { label: "High Priority", val: vActive.filter(r => String(r.priority || "").toUpperCase().charAt(0) === "H").length },
    { label: "Open Actions", val: vActive.reduce((n, r) => n + (r.openActions || 0), 0) },
    { label: "Unassigned", val: vActive.filter(r => !asgByPid[r.projectId]).length },
  ];

  const prioOf = r => String(r.priority || "").toUpperCase().charAt(0);
  // Search first, then priority. Both narrow the SAME set, so the roster count,
  // the bucket headings and the rows can never disagree. (The four stat tiles
  // above deliberately keep showing the module total -- that is how prioFilter
  // has always behaved, and a tile that moves with a search stops being a total.)
  const qActive = rosterQ
    ? vActive.filter(r => cmRosterMatch(r, cmPersonName(asgByPid[r.projectId]), rosterQ))
    : vActive;
  const fActive = prioFilter === "all" ? qActive : qActive.filter(r => prioOf(r) === prioFilter);

  // Priority keeps the bucketed roster the design draws. The other two sorts
  // collapse to one flat list, because bucket headings would be meaningless
  // when the order inside them is alphabetical.
  const sortedFlat = fActive.slice().sort((a, b) => {
    if (rosterSort === "name") return String(a.buyerName).localeCompare(String(b.buyerName));
    if (rosterSort === "actions") return (b.openActions || 0) - (a.openActions || 0)
      || String(a.buyerName).localeCompare(String(b.buyerName));
    return 0;
  });

  const buckets = rosterSort !== "priority" ? [{ key: "flat", label: null, rows: sortedFlat }] : [
    { key: "H", label: "High Priority" },
    { key: "M", label: "Medium Priority" },
    { key: "L", label: "Low Priority" },
    { key: "", label: "No Priority Set" },
  ].map(bk => ({
    ...bk,
    rows: fActive.filter(r => prioOf(r) === bk.key),
  })).filter(bk => bk.rows.length);

  return (
    <div className="cm-screen" style={{ padding: "20px 28px 60px", minWidth: 0 }}>
      {/* Hover feedback on every interactable, matching Offers & Better, which
          scopes a <style> block to its own class rather than styling inline. */}
      <style>{`
        .cm-screen button:not(:disabled):hover { filter: brightness(.94); }
        .cm-screen select:hover,
        .cm-screen input:hover,
        .cm-screen textarea:hover { border-color: var(--accent); }
        .cm-screen .cm-card:hover { border-color: var(--accent); }
        .cm-screen .cm-row:hover { background: var(--accent-soft); }
        .cm-screen .cm-opt:hover { background: var(--surface-2); }
        .cm-screen .cm-step:hover { opacity: .75; }
        .cm-screen a:hover { text-decoration: underline; }
      `}</style>
      <window.PageToolbar title="Client Management"
        subtitle={"Stage progression and active roster \u00B7 feeds the Weekly Client Management Report"}/>

      <div style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap", marginBottom: 16 }}>
        {/* One control, two renders. With one selectable module there is
            nothing to choose, so it stays the chip it has always been; a
            dropdown offering a single option reads as a broken dropdown. */}
        {moduleId && canSeeAll && modChoices.length > 1 ? (
          <select value={moduleId}
            onChange={e => { setModPick(e.target.value); setSubteam("all"); setPerson("all"); setSelId(null); }}
            title="Module"
            style={{ fontWeight: "var(--w-medium)", color: "var(--ink)",
              background: "var(--accent-soft)", border: "1px solid var(--accent)",
              borderRadius: "var(--r-pill)", padding: "4px 12px",
              fontSize: "var(--t-body)", cursor: "pointer", fontFamily: "inherit" }}>
            {modChoices.map(m => <option key={m} value={m}>{cmPersonName(m)}</option>)}
          </select>
        ) : moduleId ? (
          <div style={{ fontWeight: "var(--w-medium)", color: "var(--ink)",
            background: "var(--accent-soft)", borderRadius: "var(--r-pill)",
            padding: "4px 12px", fontSize: "var(--t-body)" }}>{cmPersonName(moduleId)}</div>
        ) : null}
        <select value={subteam} onChange={e => { setSubteam(e.target.value); setPerson("all"); }}
          style={{ background: "var(--surface)", border: "1px solid var(--line-strong)",
            borderRadius: "var(--r-ctl)", padding: "6px 10px", cursor: "pointer", fontFamily: "inherit" }}>
          <option value="all">All Sub-Teams</option>
          {subteams.map(t => <option key={t.id} value={t.id}>{t.label}</option>)}
        </select>
        <select value={person} onChange={e => setPerson(e.target.value)}
          style={{ background: "var(--surface)", border: "1px solid var(--line-strong)",
            borderRadius: "var(--r-ctl)", padding: "6px 10px", cursor: "pointer", fontFamily: "inherit" }}>
          <option value="all">All Individuals</option>
          {peopleOpts.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
        </select>
        <button type="button" onClick={() => { setSubteam("all"); setPerson("all"); }}
          style={{ border: "none", background: "none", color: "var(--muted)", cursor: "pointer",
            fontSize: "var(--t-body)", fontFamily: "inherit" }}>Clear Scope</button>
        <div style={{ flex: 1 }}/>
        {tab !== "active" ? (
          <React.Fragment>
            {/* The primary action on this screen. It lives in the toolbar rather
                than only on the board because the board is not rendered at all
                when a tab is empty -- a column + alone would be unreachable in
                exactly the state where you most need to add something. */}
            <button type="button" className="btn primary sm"
              onClick={() => openNewCard(tab === "new" ? "prospect" : "pending", null)}>
              {tab === "new" ? "New Discussion" : "New Project"}
            </button>
            <button type="button" onClick={() => openArchive(tab === "new" ? "prospect" : "pending", "progressed")}
              style={{ padding: "6px 12px", fontSize: "var(--t-micro)", fontFamily: "inherit",
                cursor: "pointer", background: "transparent", color: "var(--ok)",
                border: "1px solid var(--ok)", borderRadius: "var(--r-pill)" }}>Progressed</button>
            <button type="button" onClick={() => openArchive(tab === "new" ? "prospect" : "pending", "retired")}
              style={{ padding: "6px 12px", fontSize: "var(--t-micro)", fontFamily: "inherit",
                cursor: "pointer", background: "transparent", color: "var(--muted)",
                border: "1px solid var(--line-strong)", borderRadius: "var(--r-pill)" }}>Retired</button>
          </React.Fragment>
        ) : null}
      </div>

      <div style={{ marginBottom: 16 }}>
        <CMTabBar tab={tab} counts={counts}
          onChange={v => { setTab(v); setRegionsOpenId(null); }}/>
      </div>

      {err ? (
        <div style={{ background: "var(--risk-soft)", border: "1px solid var(--risk)",
          borderRadius: "var(--r-card)", padding: "11px 14px", marginBottom: 14,
          color: "var(--risk)", fontSize: "var(--t-body)" }}>{err}</div>
      ) : null}

      {loading ? <window.VaultLoader/> : !moduleId ? (
        <window.EmptyState title="No Module Scope"
          hint="Your Vault person record has no team, so there is no client book to show. Ask an admin to set it on the Org Chart."/>
      ) : (
        <React.Fragment>
          <CMStatTiles stats={stats}/>
          {tab === "new" ? (
            vLeads.length === 0 ? (
              <window.EmptyState title="Nothing in Discussion"
                hint="Retired and progressed discussions are behind the two buttons above."
                actionLabel="New Discussion" onAction={() => openNewCard("prospect", null)}/>
            ) : (
              <CMNewBoard leads={vLeads} onDropStage={onDropStage}
                onNewInStage={stage => openNewCard("prospect", stage)}
                onDrag={id => { dragRef.current = id; }}
                regionsOpenId={regionsOpenId}
                onOpenRegions={id => setRegionsOpenId(regionsOpenId === id ? null : id)}
                onToggleRegion={onToggleRegion}
                onPromote={onPromoteLead} onRetire={onRetireLead}
                ownerChoices={peopleOpts} ownerOpenId={ownerOpenId}
                onOpenOwner={id => setOwnerOpenId(ownerOpenId === id ? null : id)}
                onPickOwner={onPickLeadOwner}
                onOpenCard={c => setEditCard({ kind: "prospect", card: c })}
                onDropOnCard={onDropOnCard} dropTarget={dropTarget}/>
            )
          ) : tab === "pending" ? (
            vPending.length === 0 ? (
              <window.EmptyState title="Nothing in Setup"
                hint="Projects land here when a discussion reaches EL Executed and is promoted, or you can add one directly."
                actionLabel="New Project" onAction={() => openNewCard("pending", null)}/>
            ) : (
              <div style={{ display: "grid",
                gridTemplateColumns: "repeat(auto-fill, minmax(17.5rem, 1fr))",
                gap: 14, alignItems: "start" }}>
                {vPending.map(p => (
                  <CMPendingCard key={p.id} proj={p} onSetStage={onSetStage}
                    onPromote={onPromotePending} onRetire={onRetirePending}
                    ownerChoices={peopleOpts} ownerOpen={ownerOpenId === p.id}
                    onOpenOwner={id => setOwnerOpenId(ownerOpenId === id ? null : id)}
                    onPickOwner={onPickPendingOwner}
                    onOpenCard={c => setEditCard({ kind: "pending", card: c })}/>
                ))}
              </div>
            )
          ) : (
            vActive.length === 0 ? (
              <window.EmptyState title="No Active Clients"
                hint="The roster reads projects_registry where active is true, scoped to your module."/>
            ) : (
              <div style={{ display: "grid", minWidth: 0,
                gridTemplateColumns: "minmax(0, 1.1fr) minmax(0, 1.6fr)", maxWidth: "100%",
                gap: 16, alignItems: "start" }}>
                <div className="v-card" style={{ overflow: "hidden", padding: 0,
                  maxHeight: "72vh", overflowY: "auto" }}>
                  <div style={{ padding: "10px 12px", borderBottom: "1px solid var(--line)",
                    background: "var(--surface-2)", position: "sticky", top: 0, zIndex: 1,
                    display: "flex", alignItems: "center", gap: 7, flexWrap: "wrap" }}>
                    <span style={{ fontWeight: "var(--w-medium)", color: "var(--ink)" }}>Client Roster</span>
                    <span style={{ fontSize: "var(--t-micro)", color: "var(--muted)" }}>{fActive.length}</span>
                    <div style={{ flex: 1 }}/>
                    <window.VaultSearch size="sm" value={rosterQ} onChange={setRosterQ}
                      placeholder={"Search clients\u2026"} label="Search the client roster"/>
                    <select value={prioFilter} onChange={e => setPrioFilter(e.target.value)}
                      title="Filter by priority"
                      style={{ fontSize: "var(--t-micro)", fontFamily: "inherit", cursor: "pointer",
                        background: "var(--surface)", color: "var(--ink-2)",
                        border: "1px solid var(--line-strong)", borderRadius: "var(--r-ctl)",
                        padding: "3px 7px" }}>
                      <option value="all">All Priorities</option>
                      <option value="H" style={{ color: CM_PRIO_COLOR.H }}>High</option>
                      <option value="M" style={{ color: CM_PRIO_COLOR.M }}>Medium</option>
                      <option value="L" style={{ color: CM_PRIO_COLOR.L }}>Low</option>
                      <option value="">Unset</option>
                    </select>
                    <select value={rosterSort} onChange={e => setRosterSort(e.target.value)}
                      title="Sort the roster"
                      style={{ fontSize: "var(--t-micro)", fontFamily: "inherit", cursor: "pointer",
                        background: "var(--surface)", color: "var(--ink-2)",
                        border: "1px solid var(--line-strong)", borderRadius: "var(--r-ctl)",
                        padding: "3px 7px" }}>
                      <option value="priority">By Priority</option>
                      <option value="name">By Name</option>
                      <option value="actions">By Open Actions</option>
                    </select>
                  </div>
                  {buckets.length === 0 ? (
                    <div className="v-empty">
                      {rosterQ ? "Nothing matches that search." : "Nothing at this priority."}
                    </div>
                  ) : null}
                  {buckets.map(bk => (
                    <div key={bk.key || "none"}>
                      {bk.label ? (
                        <div style={{ padding: "8px 16px 6px", background: "var(--surface-2)",
                          fontSize: "var(--t-micro)", fontWeight: "var(--w-medium)",
                          letterSpacing: "1.2px", textTransform: "uppercase", color: "var(--muted)",
                          borderTop: "1px solid var(--line)" }}>
                          {bk.label + "  \u00B7  " + bk.rows.length}
                        </div>
                      ) : null}
                      {bk.rows.map(r => (
                        <CMActiveRow key={r.projectId} row={r} assignedOwner={asgByPid[r.projectId]}
                          selected={r.projectId === selId} onSelect={setSelId}/>
                      ))}
                    </div>
                  ))}
                </div>
                <CMClientPanel row={selRow} assignment={assignments.find(a => a.projectId === selId)}
                  notes={notes} actions={actions} notesLoading={notesLoading}
                  noteDraft={noteDraft} setNoteDraft={setNoteDraft}
                  noteKind={noteKind} setNoteKind={setNoteKind}
                  linkDraft={linkDraft} setLinkDraft={setLinkDraft}
                  onAddNote={onAddNote} onDeleteNote={onDeleteNote}
                  onToggleAction={onToggleAction}
                  actionDraft={actionDraft} setActionDraft={setActionDraft}
                  actionDue={actionDue} setActionDue={setActionDue}
                  onAddAction={onAddAction} myEmail={myEmail}
                  ownerChoices={peopleOpts} onAssign={onAssign}
                  onSetPriority={onSetPriority}
                  actionOwner={actionOwner} setActionOwner={setActionOwner}
                  editingActionId={editingActionId}
                  onEditAction={a => setEditingActionId(a.id)}
                  onSaveActionEdit={onSaveActionEdit}
                  onCancelActionEdit={() => setEditingActionId(null)}
                  onRemoveAction={onRemoveAction} onEditNote={onEditNote}/>
              </div>
            )
          )}
        </React.Fragment>
      )}

      {confirmDeleteNote ? (
        <CMConfirm title="Delete This Note?"
          message={"\u201C" + String(confirmDeleteNote.note || "").slice(0, 80) + "\u201D will be removed. This cannot be undone."}
          onCancel={() => setConfirmDeleteNote(null)} onConfirm={onConfirmDeleteNote}/>
      ) : null}

      {confirmRemoveAction ? (
        <CMConfirm title="Remove This Action?"
          message={"\u201C" + String(confirmRemoveAction.nextAction || "").slice(0, 80)
                   + "\u201D will be removed. This cannot be undone."}
          onCancel={() => setConfirmRemoveAction(null)} onConfirm={onConfirmRemoveAction}/>
      ) : null}

      {editCard ? (
        <CMCardModal kind={editCard.kind} card={editCard.card} mode={editCard.mode || "edit"}
          ownerChoices={peopleOpts}
          onCancel={() => setEditCard(null)} onSave={onSaveCard} onRetire={onRetireFromModal}/>
      ) : null}

      {archiveView ? (
        <CMArchiveModal
          title={(archiveView.reason === "progressed" ? "Progressed" : "Retired") + " \u00B7 "
            + (archiveView.kind === "prospect" ? "New Client Discussions" : "Pending Projects")}
          rows={archiveRows} kind={archiveView.kind}
          onClose={() => setArchiveView(null)} onRestore={onRestore}/>
      ) : null}
    </div>
  );
}

window.ClientManagementScreen = ClientManagementScreen;
