// NO PROD JSON API (bucket B — feed is a DataTables render payload or
//   {"html":...}, not data) — mock until backend (see PROD_API_INVENTORY.md)
// Represents: GET /users · UsersController — see docs/ISYSTEM_REFERENCE.md §Batch 1 "Users"
/* Host Users module — the hierarchical network below the operator: Skin Access(2) ·
   Master(8) · Agent(10) · Promoter(15) · Shop(20) (+ Regulation User(9)).
   Covers, per the reference:
   - the list (GET /users, data feed GET /getUsers/{parent_id}/ → getUsersList) with
     its drill-down subnet semantics (user_path), filters, block toggles and exports;
   - the Deleted-users variant (GET /deleted_users → deletedIndex(), same view with
     $deleted = true — superadmin only, no actions, no restore anywhere);
   - the SIX role create forms (newShop/newPromoter/newAgent/newMaster/newAdmin/
     newRegulationUser modals → saveNew* in UsersController);
   - the per-user editor (GET /users/{id}/ → showUserDetails) with tabs Home ·
     Transactions · Credit Transactions · Sport Coupon History · Permissions ·
     Providers · Deposit · Logs, saved via POST /saveUser/{id}/{tab}/.
   Variant note — `backend.cost` (sidebar.blade.php L131-138): a second sidebar entry
   shown ONLY to Affiliate (user_level 1) users links to this same /users route, but
   UsersController::index() L87-88 403s any affiliate unconditionally, so the link is
   dead. Represented honestly as a note in the page Explainer — no fake affiliate
   cost-view is built here. */

const { useState: useStateHU, useMemo: useMemoHU, useEffect: useEffectHU } = React;

/* ---------------- shared helpers ---------------- */
const huMoney = (cur, n) => `${cur} ${Number(n || 0).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
const huParse = (s) => s ? Date.parse(s.replace(" ", "T") + "Z") : null;
const huNorm = (s) => String(s || "").trim().toLowerCase();
// validateUsername: charset check lives server-side and is not committed —
// alphanumeric + . _ - inferred from observed usernames ("label inferred" policy).
const huValidUser = (u) => /^[A-Za-z0-9._-]+$/.test(u);

/* usersLevels() (UsersController L1188-1218). Values are users.user_level.
   4 Customer Care / 6 Administration / 1 Affiliate are excluded from this screen's
   fixed query constraints and filter; 30 Player appears only in the deleted view.
   Shop/Promoter/Agent/Master display names are overridable per skin via the
   custom_{shop,promoter,agent,master}_name custom settings (suppressed for
   super-admin viewers). */
const HU_ROLES = [
  { lvl: 0,  name: "Super Admin" },
  { lvl: 2,  name: "Skin Access" },   // key usertype_admin_new, slug "Admin"
  { lvl: 8,  name: "Master" },        // custom_master_name
  { lvl: 9,  name: "Regulation User" }, // slug deliberately "regulation_user"
  { lvl: 10, name: "Agent" },         // custom_agent_name
  { lvl: 15, name: "Promoter" },      // custom_promoter_name
  { lvl: 20, name: "Shop" },          // custom_shop_name
  { lvl: 30, name: "Player" },        // deleted view only
];
const huRoleName = (lvl) => { const r = HU_ROLES.find(x => x.lvl === lvl); return r ? r.name : `Level ${lvl}`; };
// User Type filter: usersLevels() minus CC(4)/Administration(6)/Affiliate(1);
// Player(30) included only in the deleted view (index.blade.php L121-123).
const HU_FILTER_ROLES = (deleted) => HU_ROLES.filter(r => r.lvl !== 30 || deleted);

/* ------------------------------------------------------------------ *
 * Reference data and rows — read, not declared.
 *
 * What used to live here: an eleven-skin literal with hardcoded currencies and
 * timezones, five invented commission-profile names, a fifteen-row provider
 * list, twelve "featured" operator rows with real-looking ids and balances
 * (4713956 / 107,876,275.67), a seeded RNG that grew a four-level network under
 * four of them, and a fourteen-row deleted-users set. All of it is a table now.
 *
 * The paths are the one structural change. isystem stores the ancestor chain as
 * a slash-delimited string in users.user_path and matches descendants with
 * `LIKE '/1/5/%'` — which cannot use an index and also matches /1/50. The column
 * here is an ltree ('1.2.5'), so the separator is a dot and containment is `@>`.
 * Anything in this file that walks a path uses HU_SEP rather than a literal.
 * ------------------------------------------------------------------ */
const HU_SEP = ".";
const huPathIds = (p) => String(p || "").split(HU_SEP).filter(Boolean).map(Number);
const huIsDescendantOf = (childPath, ancestorPath) =>
  String(childPath || "").indexOf(String(ancestorPath || "") + HU_SEP) === 0;

const huTs = (iso) => { if (!iso) return null; const t = Date.parse(iso); return isNaN(t) ? null : t; };
const huNum = (v) => (v == null ? 0 : Number(v) || 0);

/* PostgREST returns a one-to-one embed (user_balances, whose PK is the FK) as an
   object, but a to-many embed as an array. Tolerate both rather than depending
   on which side of that line PostgREST puts a given relationship. */
const huOne = (v) => (Array.isArray(v) ? (v[0] || null) : (v || null));

/* users row -> the shape both the table and the editor already render. */
const huUserRow = (r) => {
  const w = huOne(r.wallet) || {};
  const c = huOne(r.commission);
  const prof = c ? huOne(c.profile) : null;
  return {
    id: Number(r.id),
    username: String(r.username || ""),
    lvl: Number(r.user_level),
    skin: r.skin ? r.skin.name : "",
    skinId: r.skin_id == null ? null : Number(r.skin_id),
    cur: r.currency || (r.skin ? r.skin.currency : "") || "",
    /* The ACCOUNT's timezone, not the brand's. The settings panel used to
       render the skin's as the only option, which showed a brand setting in a
       field that edits the user. */
    timezone: r.timezone || "UTC",
    /* Real money is balance + balance_withdrawable — the generated column, not
       one half of it. isystem's own export column mixes the two up. */
    bal: huNum(w.real_total),
    credits: huNum(w.credits),
    /* Filled in from user_subnet_balance below; null for SHOP, as upstream. */
    sub: null,
    profile: prof ? prof.name
           : (c && c.special_mode != null ? huMasterModeName(c.special_mode) : ""),
    profileId: prof ? Number(prof.id) : null,
    /* The at-cost sentinel, kept SEPARATE from profileId all the way to the
       form. Upstream keeps both in one integer column where -1 sits beside real
       profile ids; 007 split the table so a sentinel cannot masquerade as one,
       and collapsing them back here would undo that at the last step. */
    specialMode: (c && c.special_mode != null) ? Number(c.special_mode) : null,
    /* Empty string, not null: the field renders as "Not set" and the copy
       button disables. A shop with no code is a real and common state. */
    promoterCode: r.promoter_code || "",
    parentId: r.parent_id == null ? null : Number(r.parent_id),
    parent: r.parent ? r.parent.username : "",
    path: String(r.path || ""),
    reg: huTs(r.created_at),
    last: huTs(r.last_login_at),
    cashBlock: !!r.cash_blocked,
    userBlock: !!r.blocked,
    firstname: r.firstname || "",
    lastname: r.lastname || "",
    email: r.email || "",
    mobile: r.mobile || "",
    testUser: !!r.test_user,
    /* 028's profile columns, carried through so the Home tab renders the stored
       value rather than an empty box. Every one is "" when absent and never a
       placeholder — a blank Province is "not recorded", and the moment it shows
       anything else it is claiming to know where somebody lives. */
    country: r.country || "",
    province: r.province || "",
    city: r.city || "",
    address: r.address || "",
    postcode: r.postcode || "",
    /* A DATE column, and `<input type="date">` wants exactly YYYY-MM-DD. It
       arrives that way from PostgREST; sliced anyway so a timestamp-shaped
       value cannot silently blank the field. */
    birthdate: r.birthdate ? String(r.birthdate).slice(0, 10) : "",
    gender: r.gender || "",
    documentType: r.document_type || "",
    documentNumber: r.document_number || "",
  };
};

/* Soft-deleted users. isystem copies the row into a 100-column deleted_users
   table and hard-deletes the original, which is why its schema drifts and its
   export returns live users; here the row simply carries deleted_at, so `id`
   is the SAME id it always had — there is no separate deleted_users PK to
   show, and the "Old user id" column is the same number. */
const huDeletedRow = (r) => ({
  id: Number(r.id),
  oldId: Number(r.id),
  lvl: Number(r.user_level),
  username: String(r.username || ""),
  skin: r.skin ? r.skin.name : "",
  cur: r.currency || "",
  bal: 0,
  credits: 0,
  parent: r.parent ? r.parent.username : "",
  /* users.deleted_by does not exist. Recording who deleted a row is a real gap,
     not something to fill with a plausible name.
     <!-- SUGGESTION: add users.deleted_by (bigint references users) and set it on the soft delete. Without it "Deleted by" can never be answered, and it is the first question asked about a missing account. --> */
  deletedBy: null,
  deletedAt: huTs(r.deleted_at),
  last: huTs(r.last_login_at),
  cashBlock: !!r.cash_blocked,
  userBlock: !!r.blocked,
  profile: "",
  firstname: r.firstname || "",
  lastname: r.lastname || "",
  email: r.email || "",
});

/* Master pseudo-profiles are stored as -1 / -2 in special_mode. Upstream keeps
   them in the same column as real profile ids, where a commission run that
   mistakes one for the other silently changes who gets paid. */
const HU_MASTER_PSEUDO = [[-1, "Master and subnet at cost"], [-2, "Master at cost and subnet at commissions"]];

/* THE IANA ZONE LIST, FROM THE RUNTIME — not a hand-typed dozen. `users.timezone`
   is free text and the database will accept anything, so a short invented list
   would both hide the zone an account already holds and let two spellings of one
   city become two zones. Older engines lack Intl.supportedValuesOf; there the
   select falls back to whatever the account already has plus UTC, which is a
   narrower list that still cannot mislabel anything. */
const HU_TIMEZONES = (() => {
  try {
    if (typeof Intl !== "undefined" && Intl.supportedValuesOf) {
      const z = Intl.supportedValuesOf("timeZone");
      if (z && z.length) return ["UTC"].concat(z.filter(t => t !== "UTC"));
    }
  } catch (_e) {}
  return ["UTC"];
})();
const huMasterModeName = (m) => { const h = HU_MASTER_PSEUDO.find(x => x[0] === Number(m)); return h ? h[1] : ""; };

/* WHO MAY BE VIEWED AS, mirrored from begin_impersonation() (supabase/036):
   the caller must be a super admin, and the target must not be one.

   Mirrored, not enforced. The database refuses either case regardless of what
   this returns — a button that is merely hidden is not a permission check.
   This exists so the action is not offered where it could only fail, which is
   how an operator learns a rule exists at all. */
const huIsSuper = (me) => Number(me && me.user_level) === 0;
const huMayImpersonate = (me, row) =>
  !!me && Number(me.user_level) === 0 &&
  !!row && Number(row.level) !== 0 && Number(row.id) !== Number(me.id);

/* One hook for everything both the list and the editor need. Six reads, one
   verdict — six separate error panels would all say "you are signed out". */
const useHuData = () => {
  const users   = useHrsFetch(() => window.sb.list("networkUsers", { limit: 2000 }), []);
  /* Accounts whose 2FA is explicitly disabled. Fetched to tint the shield on
     rows where the role makes 2FA MANDATORY — 068's whole trade was visibility
     over refusal, and a decision that is only visible inside a modal is not
     visible. The tier list mirrors 068/072 (0,1,4,6,9 — the levels BESIDE the
     chain); if it drifts, the badge lies quietly, so it is named as a constant
     with its source. */
  const all2fa  = useHrsFetch(() => window.sb.list("user2fa", { limit: 2000 }), []);
  const subnets = useHrsFetch(() => window.sb.list("subnetBalances", { limit: 2000 }), []);
  const del     = useHrsFetch(() => window.sb.list("deletedUsers", { limit: 500 }), []);
  const skins   = useHrsFetch(() => window.sb.list("skins", { limit: 200 }), []);
  const profs   = useHrsFetch(() => window.sb.list("commissionProfiles", { limit: 500 }), []);
  const provs   = useHrsFetch(() => window.sb.list("providers", { limit: 1000 }), []);
  /* The signed-in operator's own row: the root of the visible tree, and the
     name recorded against anything done from this screen. */
  const [me, setMe] = useStateHU(null);
  useEffectHU(() => {
    let alive = true;
    Promise.resolve(window.sb.me()).then(r => { if (alive && r && r.ok) setMe(r.data); });
    return () => { alive = false; };
  }, []);
  const feeds = [users, subnets, del, skins, profs, provs];

  const rows = useMemoHU(() => {
    const mapped = (users.data || []).map(huUserRow);
    /* getSubnetBalance() is a per-row sum over descendants. Computing it in the
       browser means fetching every descendant to add them up — the exact
       exposure the RLS policies exist to prevent — so it is a view, joined here
       by user_id rather than recomputed. */
    const sub = {};
    (subnets.data || []).forEach(s => { sub[Number(s.user_id)] = s.subnet_balance == null ? null : huNum(s.subnet_balance); });
    mapped.forEach(r => { r.sub = r.lvl === 20 ? null : (sub[r.id] === undefined ? null : sub[r.id]); });
    return mapped;
  }, [users.data, subnets.data]);

  return {
    loading: feeds.some(f => f.loading),
    error: (feeds.find(f => f.error) || {}).error || null,
    retry: () => feeds.forEach(f => f.retry && f.retry()),
    me,
    rows,
    /* NOT in `feeds` above, deliberately: this read is decoration (the shield
       tint), and a failure of a badge query must never blank the Users screen
       the way a failure of `users` should. Empty maps on error. */
    twofaActiveIds: (all2fa.data || []).filter(x => x.enrolled_at && !x.disabled_at)
                                       .map(x => Number(x.user_id)),
    twofaDisabledIds: (all2fa.data || []).filter(x => x.disabled_at)
                                         .map(x => Number(x.user_id)),
    deleted: useMemoHU(() => (del.data || []).map(huDeletedRow), [del.data]),
    skins:   useMemoHU(() => (skins.data || []).map(s => ({
               id: Number(s.id), name: s.name, cur: s.currency || "", tz: s.timezone || "UTC",
             })), [skins.data]),
    profiles: useMemoHU(() => (profs.data || []).map(p => ({
                id: Number(p.id), name: p.name,
                /* NULL SURVIVES. `Number(null)` is 0, and 0 is Super admin —
                   a profile with no level would have been offered to exactly
                   one role and hidden from every other. A null user_level means
                   "any level", which is a different thing from "level zero". */
                level: p.user_level == null ? null : Number(p.user_level),
                skinId: Number(p.skin_id),
              })), [profs.data]),
    /* WAS a [name, integrationId] PAIR, which is all the read-only grid needed
       and not enough to write a row: set_user_provider_rate is keyed by
       provider ID, and the bulk fan-out is keyed by category. */
    providers: useMemoHU(() => (provs.data || []).map(p => ({
                 id: Number(p.id), name: p.name,
                 integrationId: Number(p.integration_id),
                 categoryId: p.category_id == null ? null : Number(p.category_id),
               })), [provs.data]),
  };
};

/* Per-user login feed — login_events, the table isystem calls users_login
   (GET /getLoginLogs/{id}, Logs tab). */
const useHuLoginLogs = (userId) => {
  const feed = useHrsFetch(
    () => window.sb.list("loginEvents", { limit: 100, filters: { user: userId } }),
    [userId]);
  return {
    ...feed,
    rows: useMemoHU(() => (feed.data || []).map(r => ({ ts: huTs(r.occurred_at), ip: r.ip || "—" })), [feed.data]),
  };
};

/* Mobile breakpoint hook — drives table→cards, hero-strip→Filters-sheet and
   full-screen modals (§11). The returned CSS media queries reinforce layout. */
const useHuMobile = () => {
  const [m, setM] = useStateHU(() => { try { return window.matchMedia("(max-width: 860px)").matches; } catch (_e) { return false; } });
  useEffectHU(() => {
    try {
      const mq = window.matchMedia("(max-width: 860px)");
      const fn = (e) => setM(e.matches);
      if (mq.addEventListener) mq.addEventListener("change", fn); else mq.addListener(fn);
      return () => { if (mq.removeEventListener) mq.removeEventListener("change", fn); else mq.removeListener(fn); };
    } catch (_e) { return undefined; }
  }, []);
  return m;
};

/* ---------------- small UI atoms ---------------- */
const HuRoleChip = ({ lvl }) => {
  const colors = { 0: "#181c32", 2: "#28387a", 8: "#4b1fb3", 9: "#3a4047", 10: "#0aa19a", 15: "#e6a82c", 20: "#1f9d57", 30: "#7a8194" };
  return (
    <span className="hu-rolechip" style={{ background: colors[lvl] || "#7a8194" }}>
      {huRoleName(lvl)}<i>{lvl}</i>
    </span>
  );
};

const HuGateChip = ({ children }) => <code className="hu-gate">{children}</code>;

/* Honest "not wired yet" control. The action genuinely exists on the real
   screen, so the button stays visible and keeps documenting it
   (docs/UIUX_ELEVATION_BRIEF.md §3 — real functionality must not vanish from the
   map), but it renders DISABLED and names the endpoint a backend engineer has to
   wire, instead of firing a toast that implies something happened.
   The tooltip sits on the wrapper span deliberately: browsers suppress `title`
   on a disabled control, so the hint needs an enabled ancestor. */
const HuNoBackend = ({ need, what, className = "", children, style, block }) => (
  <span
    className="hu-nobackend"
    style={{ display: block ? "block" : "inline-flex", cursor: "not-allowed" }}
    title={`${what ? what + " — " : ""}not wired in this prototype · requires backend: ${need}`}
  >
    <button type="button" className={className} disabled aria-disabled="true"
      style={{ opacity: .45, cursor: "not-allowed", pointerEvents: "none", ...(style || {}) }}>
      {children}
    </button>
  </span>
);

/* Readable one-liner under a disabled SAVE, so the reason needs no hover. */
const HuNoBackendNote = ({ children }) => (
  <div style={{ marginTop: 8, fontSize: 11.5, color: "var(--text-tertiary)", textAlign: "center" }}>{children}</div>
);

/* Cross-link into the prototype's own Deposit/Transfer screen (route key
   host-deposit → /deposit) using the pushState + PopStateEvent convention
   app.jsx listens on. The real row link is
   `/transfer/?from={caller|parent}&type=agent&to={id}`; that context rides along
   in the query string so the URL still names the target, but HostDeposit keeps
   its own account directory and does not read it — the button titles say so
   rather than implying a preselect. */
const huNavTransfer = (userId) => {
  try {
    const path = (window.pathForActive && window.pathForActive("host-deposit")) || "/deposit";
    window.history.pushState({ active: "host-deposit" }, "", `${path}?type=agent&to=${userId}`);
    window.dispatchEvent(new PopStateEvent("popstate"));
  } catch (_e) { /* no-op: nothing to fake if history is unavailable */ }
};

const HuPill = ({ label, onClear }) => (
  <span style={{ display: "inline-flex", alignItems: "center", gap: 6, background: "var(--p-50)", color: "var(--p-700)", border: "1px solid var(--p-100)", borderRadius: 999, padding: "3px 6px 3px 10px", fontSize: 11.5, fontWeight: 600 }}>
    {label}
    <button onClick={onClear} style={{ border: 0, background: "transparent", color: "inherit", cursor: "pointer", display: "grid", placeItems: "center", padding: 0 }} title="Clear"><Icon name="x" size={11} /></button>
  </span>
);

const HuHeroCard = ({ icon, label, tip, children, span2 }) => (
  <div className={`filter-hero__card${span2 ? " hu-span2" : ""}`}>
    <div className="filter-hero__label"><Icon name={icon} size={11} /> {label}{tip && <Tip>{tip}</Tip>}</div>
    {children}
  </div>
);

const HuModalShell = ({ title, sub, onClose, children, footer, wide }) => {
  const isMobile = useHuMobile();
  useEffectHU(() => {
    const onKey = (e) => { if (e.key === "Escape") onClose(); };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, []);
  return (
    <div className="hu-modal-scrim" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
      <div className={`hu-modal${wide ? " hu-modal--wide" : ""}${isMobile ? " hu-modal--full" : ""}`}>
        <div className="hu-modal__head">
          <div>
            <div className="hu-modal__title">{title}</div>
            {sub && <div className="hu-modal__sub">{sub}</div>}
          </div>
          <button className="hu-modal__x" onClick={onClose} title="Close"><Icon name="x" size={16} /></button>
        </div>
        <div className="hu-modal__body">{children}</div>
        {footer && <div className="hu-modal__foot">{footer}</div>}
      </div>
    </div>
  );
};

/* Block / unblock with the mandatory comment the real modals require
   (admin/infoblock/modals/{block,unblock}.blade.php → POST /setblock/{id}/ or
   POST /setcashblock/{id}/ with ?value=…). */
/* View-as. The reason field is required and is not pre-filled, because it is
   the only record of why a super admin was inside somebody else's account and a
   default would make every row in the audit trail say the same thing.

   The confirmation text is the read-only promise stated to the person about to
   rely on it: every screen will show the target's data and every write will
   refuse. Saying it here rather than only in the SQL comment means the operator
   who is about to try a write already knows why it will not work. */
/* CREDENTIALS. create_user() writes the application row and nothing else, so
   until 066 an account created here could never sign in. This is the control
   that closes that.

   Everything it offers is decided server-side by
   assert_may_administer_credentials(): second factor, no impersonation, subtree
   containment, and a role_creation pairing. The one rule mirrored here is the
   player restriction, and only to explain the absence — a "Set password" button
   that is always refused for players teaches nothing, whereas a sentence saying
   why does. The server is the boundary; this is manners, the same as the
   impersonate button below. */
/* 2FA — the per-user Security panel (spec §7).

   Everything it offers is decided server-side. Disable and Reset are super
   admin only, both refuse an empty reason IN THE DATABASE, and Reset refuses a
   super admin target outright because that path is a developer command with no
   screen by design. This component does not restate any of those rules; it
   shows the refusal it gets, because the messages in 069 were written to be
   read by an operator.

   The one thing it DOES compute locally is the badge: an account whose role
   makes 2FA mandatory but whose 2FA is disabled. 068 reports that as
   `disabled_despite_mandatory` precisely so this is a lookup rather than a
   re-derivation of the enforcement matrix in JSX. */
const HU_2FA_ACTIONS = [
  ["disable", "Disable", "Turns the requirement off for this account. The enrolment survives."],
  ["reset", "Reset", "Removes the enrolment entirely. They enrol again at their next login."],
];

const Hu2faModal = ({ row, me, onClose, initialAct = null }) => {
  const [act, setAct] = useStateHU(initialAct);
  const [note, setNote] = useStateHU("");
  const [busy, setBusy] = useStateHU(false);
  const [msg, setMsg] = useStateHU(null);
  const [err, setErr] = useStateHU(null);

  const st = useHrsFetch(() => window.sb.list("user2fa", { limit: 1, filters: { user: row.id } }),
                         [row.id, msg]);
  const pol = useHrsFetch(() => window.sb.twofaPolicy(Number(row.id)), [row.id, msg]);
  const rec = (st.data || [])[0] || null;
  const p = pol.data || null;

  const isSuper = Number(me && me.user_level) === 0;
  const targetSuper = Number(row.user_level) === 0;
  /* The two actions exist FOR an active enrolment: disabling 2FA that is not
     on is a no-op sold as an action, and resetting an enrolment that does not
     exist doubly so. Greyed with the reason in the tooltip, not hidden — a
     control that vanishes teaches nothing (owner rule, 2026-08-16). */
  const twofaActive = !!(rec && rec.enrolled_at && !rec.disabled_at);
  /* A preselected action (the editor's Disable…/Reset… buttons pass one) is
     dropped once the row loads and says there is nothing to act on — otherwise
     the note box sits open under two dead buttons. Below `rec`'s declaration
     on purpose: the dependency array evaluates during render, and tdzcheck
     caught the first version reading `rec` nine lines before it existed. */
  React.useEffect(() => { if (act && rec && !twofaActive) setAct(null); },
    [rec && rec.enrolled_at, rec && rec.disabled_at]);

  const run = async () => {
    if (busy || !note.trim()) return;
    setBusy(true); setErr(null); setMsg(null);
    const r = act === "reset"
      ? await window.sb.reset2fa(Number(row.id), note.trim())
      : await window.sb.disable2fa(Number(row.id), note.trim());
    setBusy(false);
    if (!r || !r.ok) { setErr((r && r.error && r.error.message) || "Refused."); return; }
    setMsg(act === "reset" ? "Enrolment removed." : "2FA disabled for this account.");
    setAct(null); setNote("");
  };

  const Row = ({ k, v }) => (
    <div style={{ display: "flex", gap: 10, fontSize: 13, padding: "3px 0" }}>
      <span style={{ color: "var(--n-600)", minWidth: 150 }}>{k}</span>
      <span style={{ fontWeight: 550 }}>{v}</span>
    </div>
  );

  return (
    <HuModalShell
      title={`Two-factor — ${row.username}`}
      sub="Google Authenticator · app_2fa_policy + twofa_admin_* · every action carries a reason"
      onClose={onClose}
      footer={<button className="rpt-btn rpt-btn--search" style={{ minWidth: 0, height: 40 }} onClick={onClose}>Close</button>}
    >
      <div style={{ display: "grid", gap: 12 }}>
        {p && p.required === false && p.source === "disabled_despite_mandatory" && (
          <div className="hu-errbox" style={{ alignItems: "flex-start", lineHeight: 1.55 }}>
            <Icon name="alert" size={14} style={{ flex: "0 0 auto", marginTop: 2 }} />
            <span>
              <b>2FA is disabled on an account whose role requires it.</b> This is a
              decision somebody made and signed, not a gap — the reason and the
              actor are below. Resetting returns the account to its policy.
            </span>
          </div>
        )}

        <div>
          {/* A pill, not a word. "Disabled" in body text reads like metadata;
              amber/green/grey at a glance is the difference the operator
              actually scans for. */}
          <Row k="Status" v={(() => {
            const st = rec && rec.disabled_at ? ["Disabled", "#b45309", "#fef3c7"]
                     : rec && rec.enrolled_at ? ["Active", "#15803d", "#dcfce7"]
                     : ["Not enrolled", "#475569", "#e2e8f0"];
            return <span style={{ display: "inline-block", padding: "2px 10px",
                                  borderRadius: 999, fontSize: 12, fontWeight: 700,
                                  color: st[1], background: st[2] }}>{st[0]}</span>;
          })()} />
          <Row k="Policy" v={p ? (p.required ? "Required" : "Not required") + (p.detail ? ` — ${p.detail}` : "") : "…"} />
          {rec && rec.enrolled_at && <Row k="Activated" v={hpDate(Date.parse(rec.enrolled_at))} />}
          {rec && rec.enrolled_device && <Row k="Device at enrolment" v={rec.enrolled_device} />}
          {rec && rec.enrolled_browser && <Row k="Browser at enrolment" v={rec.enrolled_browser} />}
          {rec && rec.last_action && (
            <Row k="Last action" v={`${rec.last_action}${rec.last_action_note ? ` — “${rec.last_action_note}”` : ""}`} />
          )}
          {rec && rec.twofa_locked_until && (
            <Row k="Locked until" v={hpDate(Date.parse(rec.twofa_locked_until), true)} />
          )}
        </div>

        {!isSuper ? (
          <div style={{ fontSize: 13, color: "var(--n-600)" }}>
            Only a super admin may disable or reset another account's 2FA (spec §7).
          </div>
        ) : (
          <div style={{ display: "grid", gap: 8 }}>
            <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
              {HU_2FA_ACTIONS.map(([id, label, why]) => {
                const danger = id === "reset";
                const sel = act === id;
                const dead = busy || (danger && targetSuper) || !twofaActive;
                return (
                  <button key={id}
                    disabled={dead}
                    title={!twofaActive ? "Two-factor is not active on this account — there is nothing to " + id
                          : danger && targetSuper ? "A super admin's 2FA is reset by a developer command, never from a screen" : why}
                    onClick={() => { setAct(sel ? null : id); setErr(null); setMsg(null); }}
                    style={{
                      textAlign: "left", padding: "10px 12px", borderRadius: 10, cursor: "pointer",
                      border: `2px solid ${sel ? (danger ? "var(--err, #dc2626)" : "var(--p-500)") : "var(--border-default)"}`,
                      background: sel ? (danger ? "var(--err-bg, #fee2e2)" : "var(--p-50)") : "#fff",
                      opacity: dead ? .45 : 1,
                    }}>
                    <span style={{ display: "flex", alignItems: "center", gap: 6, fontWeight: 700,
                                   fontSize: 13, color: danger ? "var(--err, #dc2626)" : "var(--p-700)" }}>
                      <Icon name={danger ? "alert" : "shield"} size={13} /> {label}
                    </span>
                    <span style={{ display: "block", fontSize: 11.5, color: "var(--n-600)", marginTop: 3 }}>{why}</span>
                  </button>
                );
              })}
            </div>
            {/* Stated rather than left as a disabled button with no explanation.
                The database refuses this too — the button is manners. */}
            {targetSuper && (
              <div style={{ fontSize: 12, color: "var(--n-600)" }}>
                A super admin's 2FA is <b>reset</b> by a developer command against the
                database, never from a screen. Disabling one here is permitted.
              </div>
            )}
            {act && (
              <div style={{ display: "grid", gap: 6 }}>
                <label style={{ fontSize: 12, color: "var(--n-600)" }}>
                  Reason — required, and stored with the action
                </label>
                <textarea className="input" rows={2} value={note}
                  placeholder="Why is this being done?"
                  onChange={(e) => setNote(e.target.value)} />
                <div>
                  <button
                    disabled={busy || !note.trim()}
                    onClick={run}
                    style={{ height: 38, padding: "0 18px", borderRadius: 9, border: 0,
                             cursor: note.trim() ? "pointer" : "not-allowed",
                             fontWeight: 700, fontSize: 13, color: "#fff",
                             background: act === "reset" ? "var(--err, #dc2626)" : "var(--p-500)",
                             opacity: busy || !note.trim() ? .5 : 1 }}>
                    {busy ? "Working…" : act === "reset" ? "Reset — remove the enrolment" : "Disable 2FA for this account"}
                  </button>
                </div>
              </div>
            )}
          </div>
        )}

        {msg && <div style={{ fontSize: 13, color: "var(--g-700, #15803d)" }}>{msg}</div>}
        {err && <div className="hu-errbox"><Icon name="alert" size={14} /> {err}</div>}
      </div>
    </HuModalShell>
  );
};

const HuCredsModal = ({ row, onClose }) => {
  const [busy, setBusy] = useStateHU(null);
  const [msg, setMsg] = useStateHU(null);
  const [err, setErr] = useStateHU(null);
  const [pw, setPw] = useStateHU("");

  const isPlayer = Number(row.user_level) >= 30;
  /* `row` is a snapshot from the list behind this modal, fetched once on page
     load — an invite performed here mints a real login, but reopening this
     modal (even after a full reload, if the list resource's own select was
     ever missing the column) or closing and reopening it within the same
     visit both replay the SAME stale snapshot. Rather than trust the prop,
     look the account up directly — the same idiom Hu2faModal already uses
     below for user2fa/policy — and refetch after every action (`msg` in the
     deps), so the modal is never more than one request behind the database
     it is about to act against. */
  const live = useHrsFetch(() => window.sb.list("networkUsers", { limit: 1, filters: { id: row.id } }),
                           [row.id, msg]);
  const liveRow = (live.data || [])[0] || null;
  const hasLogin = !!((liveRow || row).auth_user_id);

  const run = async (op, fn) => {
    if (busy) return;
    setBusy(op); setErr(null); setMsg(null);
    const r = await fn();
    setBusy(null);
    if (!r || !r.ok) {
      /* The refusals raised by 066 are written to be read by an operator — "a
         player's password is set by the player" — so they are shown as they
         arrive rather than flattened into "forbidden". */
      setErr((r && r.error && r.error.message) || "Refused.");
      return;
    }
    setMsg(op === "set" ? "Password set." : `Sent to ${(r.data && r.data.sent_to) || "the address on the account"}.`);
    if (op === "set") setPw("");
  };

  return (
    <HuModalShell
      title={`Login — ${row.username}`}
      sub="auth-admin · second factor required · every attempt recorded in credential_events"
      onClose={onClose}
      footer={<button className="rpt-btn rpt-btn--search" style={{ minWidth: 0, height: 40 }} onClick={onClose}>Close</button>}
    >
      <div style={{ display: "grid", gap: 12 }}>
        <div style={{ fontSize: 13, color: "var(--n-600)" }}>
          {hasLogin
            ? "This account has a login."
            : "This account has no login yet, so it cannot sign in. Invite creates one and emails a set-password link."}
        </div>

        <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
          <button className="rpt-btn rpt-btn--blue" style={{ minWidth: 0, height: 38 }}
            disabled={!!busy || hasLogin}
            title={hasLogin ? "Already has a login — use Send reset link" : "Create the login and email a set-password link"}
            onClick={() => run("invite", () => window.sb.invite(Number(row.id)))}>
            {busy === "invite" ? "Sending…" : "Send invite"}
          </button>
          <button className="rpt-btn rpt-btn--search" style={{ minWidth: 0, height: 38 }}
            disabled={!!busy || !hasLogin}
            title={hasLogin ? "Email a password reset link" : "No login to reset yet"}
            onClick={() => run("reset", () => window.sb.resetPassword(Number(row.id)))}>
            {busy === "reset" ? "Sending…" : "Send reset link"}
          </button>
        </div>

        {isPlayer ? (
          <div className="hu-errbox" style={{ background: "var(--p-50)", borderColor: "var(--p-100)", color: "var(--p-700)", alignItems: "flex-start", lineHeight: 1.55 }}>
            <Icon name="info" size={14} style={{ flex: "0 0 auto", marginTop: 2 }} />
            <span>
              A player's password is set by the player. An operator who could set it
              could sign in and transact as them, and the ledger entry names the
              account rather than the session — so nothing afterwards would tell the
              two apart. Invite and reset both send a link and reveal nothing here.
            </span>
          </div>
        ) : (
          <div style={{ display: "grid", gap: 6 }}>
            <label style={{ fontSize: 12, color: "var(--n-600)" }}>
              Set a password directly (operators only)
            </label>
            <div style={{ display: "flex", gap: 8 }}>
              <input className="input" type="password" value={pw} autoComplete="new-password"
                placeholder="at least 12 characters" style={{ flex: 1 }}
                onChange={(e) => setPw(e.target.value)} />
              <button className="rpt-btn rpt-btn--blue" style={{ minWidth: 0, height: 38 }}
                disabled={!!busy || !hasLogin || pw.length < 12}
                title={hasLogin ? "" : "Invite first — there is no login to set a password on"}
                onClick={() => run("set", () => window.sb.setPassword(Number(row.id), pw))}>
                {busy === "set" ? "Setting…" : "Set"}
              </button>
            </div>
          </div>
        )}

        {msg && <div style={{ fontSize: 13, color: "var(--g-700, #15803d)" }}>{msg}</div>}
        {err && <div className="hu-errbox"><Icon name="alert" size={14} /> {err}</div>}
      </div>
    </HuModalShell>
  );
};

/* The editor's 2FA card: live status pill + the two actions, super admin only.
   Opens the same Hu2faModal preselected — one implementation of the rules, two
   surfaces that reach it. */
const Hu2faEditCard = ({ user, me }) => {
  const [open2fa, setOpen2fa] = useStateHU(false);
  const st = useHrsFetch(() => window.sb.list("user2fa", { limit: 1, filters: { user: user.id } }),
                         [user.id, open2fa]);
  const rec = (st.data || [])[0] || null;
  const pill = rec && rec.disabled_at ? ["Disabled", "#b45309", "#fef3c7"]
             : rec && rec.enrolled_at ? ["Active", "#15803d", "#dcfce7"]
             : ["Not enrolled", "#475569", "#e2e8f0"];
  const mandatory = [0, 1, 4, 6, 9].includes(Number(user.lvl != null ? user.lvl : user.user_level)); // 068/072
  const row = { id: user.id, username: user.username, user_level: user.lvl != null ? user.lvl : user.user_level };
  return (
    <section className="hp-card">
      <div className="hp-card__title" style={{ display: "flex", alignItems: "center", gap: 8 }}>
        Two-factor
        <span style={{ padding: "1px 9px", borderRadius: 999, fontSize: 11, fontWeight: 700,
                       color: pill[1], background: pill[2] }}>{pill[0]}</span>
        {mandatory && rec && rec.disabled_at && (
          <span style={{ padding: "1px 9px", borderRadius: 999, fontSize: 11, fontWeight: 700,
                         color: "#fff", background: "var(--err, #dc2626)" }}>mandatory role</span>
        )}
      </div>
      {rec && rec.last_action && (
        <div style={{ fontSize: 12, color: "var(--n-600)", marginBottom: 8 }}>
          Last action: <b>{rec.last_action}</b>
          {rec.last_action_note ? <> — &ldquo;{rec.last_action_note}&rdquo;</> : null}
        </div>
      )}
      {/* THE SAME CONTROL AS THE ROW — same icon, same colours, same modal,
          same confirmations (owner ask, 2026-08-16). The card used to carry its
          own Details / Disable… / Reset… buttons, a second presentation of the
          same actions; now both surfaces are one shield status button and every
          rule lives in the one modal behind it. */}
      {(() => {
        const active = !!(rec && rec.enrolled_at && !rec.disabled_at);
        const disMand = mandatory && !!(rec && rec.disabled_at);
        return (
          <button className="hp-act"
            title={disMand ? "2FA is DISABLED on a mandatory-role account — somebody signed this; open for who and why"
                 : active ? "Two-factor is active — open to disable or reset"
                 : "Two-factor is not active on this account"}
            style={Object.assign({ width: "auto", height: 34, padding: "0 12px", gap: 6,
                     /* hp-act is display:grid for its icon-only row buttons — two
                        children there stack vertically, which is exactly the
                        broken look this line fixes. */
                     display: "inline-flex", alignItems: "center", justifyContent: "center",
                     fontWeight: 600, fontSize: 12.5 },
                   disMand ? { color: "#fff", background: "var(--err, #dc2626)" }
                 : active ? { color: "#fff", background: "var(--p-500)" } : {})}
            onClick={() => setOpen2fa(true)}>
            <Icon name="shield" size={13} /> Two-factor
          </button>
        );
      })()}
      {open2fa && <Hu2faModal row={row} me={me} onClose={() => setOpen2fa(false)} />}
    </section>
  );
};

const HuImpersonateModal = ({ row, onClose }) => {
  const [reason, setReason] = useStateHU("");
  const [busy, setBusy] = useStateHU(false);
  const [err, setErr] = useStateHU(null);

  const go = async () => {
    if (busy || !reason.trim()) return;
    setBusy(true); setErr(null);
    const r = await window.sb.beginImpersonation({ targetId: Number(row.id), reason: reason.trim() });
    setBusy(false);
    if (!r || !r.ok) { setErr((r && r.error && r.error.message) || "Could not start."); return; }
    /* Every screen behind this holds the caller's own rows in its own state,
       and they are all about to be somebody else's. Reloading is one place to
       get that right instead of 69. */
    try { window.location.reload(); } catch (_e) { onClose(); }
  };

  return (
    <HuModalShell
      title={`View as — ${row.username}`}
      sub="begin_impersonation() · super admin only · read only · recorded"
      onClose={onClose}
      footer={<React.Fragment>
        <button className="rpt-btn rpt-btn--search" style={{ minWidth: 0, height: 40 }} onClick={onClose}>Cancel</button>
        <button className="rpt-btn rpt-btn--blue" style={{ minWidth: 0, height: 40 }}
          disabled={busy || !reason.trim()} onClick={go}>
          <Icon name="eye" size={13} /> {busy ? "Starting…" : "Start"}
        </button>
      </React.Fragment>}>
      <p style={{ margin: "0 0 12px", fontSize: 13, color: "var(--text-secondary)", lineHeight: 1.6 }}>
        Every screen will show <b>{row.username}</b>&rsquo;s data instead of yours, and
        every write will refuse until you stop — a change made under somebody
        else&rsquo;s identity cannot be attributed to whoever actually made it.
        A bar across the top will say who you are viewing as.
      </p>
      <label className="form-label">Reason *</label>
      <input className="input" style={{ width: "100%", padding: 10 }} value={reason} autoFocus
        onChange={(e) => setReason(e.target.value)}
        onKeyDown={(e) => { if (e.key === "Enter") go(); }}
        placeholder="e.g. shop reports an empty coupon list" />
      <div style={{ marginTop: 6, fontSize: 12, color: "var(--text-secondary)" }}>
        Recorded against your account, with the start and end times.
      </div>
      {err && <div className="hu-errbox" role="alert" style={{ marginTop: 10, marginBottom: 0 }}>
        <Icon name="alert" size={13} />{err}
      </div>}
    </HuModalShell>
  );
};

const HuBlockModal = ({ row, kind, next, onConfirm, onClose }) => {
  const [note, setNote] = useStateHU("");
  const verb = next ? "Block" : "Unblock";
  const what = kind === "cash" ? "Cash block" : "User block";
  return (
    <HuModalShell
      title={`${verb} — ${row.username}`}
      sub={`${what} · ${kind === "cash" ? `POST /setcashblock/${row.id}/` : `POST /setblock/${row.id}/`}?value=${next ? 1 : 0}`}
      onClose={onClose}
      footer={<React.Fragment>
        <button className="rpt-btn rpt-btn--reset" style={{ minWidth: 0, height: 40 }} onClick={onClose}>Cancel</button>
        <button className="rpt-btn rpt-btn--blue" style={{ minWidth: 0, height: 40 }} disabled={!note.trim()} onClick={() => onConfirm(note.trim())}>
          <Icon name={next ? "lock" : "check"} size={13} /> {verb}
        </button>
      </React.Fragment>}>
      <p style={{ margin: "0 0 10px", fontSize: 13, color: "var(--text-secondary)" }}>
        A comment is mandatory — it is written to the user's block-note history (user_logs; log types cash block = 3, user block = 4) and shown via the info icon next to the toggle.
      </p>
      {!next && <p style={{ margin: "0 0 10px", fontSize: 12.5, color: "#b45309" }}>
        Unblocking is prevented on the live platform unless the <HuGateChip>enable_user_unblock</HuGateChip> skin setting is on, or the viewer is superadmin.
      </p>}
      <label className="form-label">Comment *</label>
      <textarea className="input" style={{ width: "100%", minHeight: 90, padding: 10, resize: "vertical" }} value={note} onChange={e => setNote(e.target.value)} placeholder={`Reason for the ${verb.toLowerCase()}…`} autoFocus />
    </HuModalShell>
  );
};

/* Block-note history — GET /infoblock/ (log types cash block 3 / user block 4). */
const HuBlockHistory = ({ row, log, onClose }) => {
  const feed = useHrsFetch(() => window.sb.list("userBlocks", { limit: 100, filters: { user: row.id } }), [row.id]);
  /* One user_blocks row carries BOTH ends, so it becomes up to two lines here:
     the block, and the unblock if it has happened. That is the shape this table
     has always rendered. */
  const all = useMemoHU(() => {
    const out = [];
    (feed.data || []).forEach(b => {
      out.push({ ts: huTs(b.blocked_at), type: "User block (4)", action: "Block",
                 by: b.blockedBy ? b.blockedBy.username : "—", note: b.reason || "" });
      if (b.unblocked_at) {
        out.push({ ts: huTs(b.unblocked_at), type: "User block (4)", action: "Unblock",
                   by: b.unblockedBy ? b.unblockedBy.username : "—", note: "" });
      }
    });
    /* Actions attempted in this session, which wrote nothing — marked so the
       list never reads as if they had. */
    (log || []).forEach(e => out.push({ ...e, note: e.attempted ? `${e.note || ""} (not saved — no write path yet)`.trim() : e.note }));
    return out.sort((a, b) => b.ts - a.ts);
  }, [feed.data, log]);
  return (
    <HuModalShell title={`Block notes — ${row.username}`} sub="GET /infoblock/ — audit trail behind the info icon" onClose={onClose} wide>
      <table className="data-table" style={{ width: "100%" }}>
        <thead><tr><th>Date</th><th>Type</th><th>Action</th><th>By</th><th style={{ textAlign: "left" }}>Comment</th></tr></thead>
        <tbody>
          {feed.loading && <tr><td colSpan={5} style={{ padding: 22, textAlign: "center", color: "var(--text-tertiary)" }}>Loading…</td></tr>}
          {!feed.loading && feed.error && <tr><td colSpan={5} style={{ padding: 22 }}><HrsError error={feed.error} onRetry={feed.retry} /></td></tr>}
          {!feed.loading && !feed.error && all.length === 0 && <tr><td colSpan={5} style={{ padding: 22, textAlign: "center", color: "var(--text-tertiary)" }}>No block notes for this user.</td></tr>}
          {all.map((e, i) => (
            <tr key={i}>
              <td style={{ whiteSpace: "nowrap" }}>{hpDate(e.ts)}</td>
              <td>{e.type}</td>
              <td><span style={{ fontWeight: 700, color: e.action === "Block" ? "#e9484a" : "#1f9d57" }}>{e.action}</span></td>
              <td>{e.by}</td>
              <td style={{ textAlign: "left" }}>{e.note}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </HuModalShell>
  );
};

/* ---------------- the six create-role forms ----------------
   Modal AJAX forms (admin/users/forms/new*.blade.php → saveNew* in
   UsersController). Required fields are driven by config/user_fields.php =
   ["username","password"] ONLY; every other personal field is rendered but
   optional. Shared inline validation on every saveNew*: username required +
   validateUsername charset + uniqueness; password ['confirmed','required',
   'string','max:30',Password::min(6)]; email format + uniqueness when provided;
   errors returned as ajaxError(html, {campierrati:[fields]}). Every create sets
   api_token (Str::random(60)) + api_key, records addedByUser, builds user_path
   post-insert, copies parent game permissions and writes a LogsController::
   saveLog audit line. */
const HU_CREATE_DEFS = [
  {
    key: "shop", lvl: 20, label: "New Shop", save: "POST /saveNewShop", form: "newShop.blade.php",
    parentLevels: [15, 10, 8, 6, 4, 2],
    notes: [
      "Parent select2 search offers levels 15/10/8/6/4/2 — forced to self when the creator is a Promoter (15).",
      "Commission profile is forced from the governing Master's master_settings[SHOP]; error `cant_create_shops` when absent.",
      "Currency, skin, timezone, custom group and primary language are inherited from the parent.",
      "A promoter_code is generated for the new Shop.",
    ],
  },
  {
    key: "promoter", lvl: 15, label: "New Promoter", save: "POST /saveNewPromoter", form: "newPromoter.blade.php",
    parentLevels: [10, 8, 6, 4, 2],
    notes: [
      "Parent search offers levels 10/8/6/4/2 — forced to self when the creator is an Agent (10).",
      "Gated by the governing Master's master_settings[PROMOTER] row.",
    ],
  },
  {
    key: "agent", lvl: 10, label: "New Agent", save: "POST /saveNewAgent", form: "newAgent.blade.php",
    parentLevels: [8, 6, 4, 2],
    notes: [
      "Parent search offers levels 8/6/4/2 — forced to self when the creator is a Master (8).",
      "Gated by the governing Master's master_settings[AGENT] row.",
    ],
  },
  {
    key: "master", lvl: 8, label: "New Master", save: "POST /saveNewMaster", form: "newMaster.blade.php",
    parentLevels: [2, 6, 4], masterExtras: true,
    notes: [
      "Parent forced to self when the creator is a skin admin.",
      "Commission profile is REQUIRED — including the -1 / -2 pseudo-profiles.",
      "Per-level \"can create\" + default commission profile rows (can_create[level] / prov_coupons[level]; Shop forced on) are written to master_settings.",
      "Skin is taken from the parent.",
    ],
  },
  {
    key: "admin", lvl: 2, label: "New Skin Access", save: "POST /saveNewAdmin", form: "newAdmin.blade.php",
    parentLevels: null, needsSkin: true,
    notes: [
      "No parent picker — parent is forced to 1 (root).",
      "skin_id is REQUIRED; currency, timezone and primary language derive from the chosen skin.",
    ],
  },
  {
    key: "regulation_user", lvl: 9, label: "New Regulation User", save: "POST /saveNewRegulationUser", form: "regulation_user.blade.php",
    parentLevels: null, needsSkin: true,
    notes: [
      "Mirror of saveNewAdmin with user_level 9 (read-only regulator viewer).",
      "The button JS name is gestione{slug} — the slug is deliberately `regulation_user`.",
    ],
  },
];

const HuField = ({ label, req, err, note, children, span2 }) => (
  <div className={`hu-field${span2 ? " hu-span2" : ""}${err ? " hu-field--err" : ""}`}>
    <label className="form-label">{label}{req && " *"}</label>
    {children}
    {err && <div className="hu-field__err">{err}</div>}
    {note && !err && <div className="hu-field__note">{note}</div>}
  </div>
);

/* Shared personal-data block (userPersonalData() — app/Helpers/user_forms.php:360).
   Italy gets province/city SELECTS (province_birth/city_birth); any other country
   swaps to free-text (province_text_birth/city_text_birth). fiscal_code renders for
   Italian residence only (validated server-side with the CodiceFiscale class). */
const HuPersonal = ({ v, set, errs }) => {
  const COUNTRIES = ["- Select -", "Argentina", "Bolivia", "Chile", "Italy", "Paraguay", "Spain"];
  const IT_PROV = ["- Select -", "Milano", "Roma", "Napoli", "Torino"];
  const days = Array.from({ length: 31 }, (_, i) => i + 1);
  const months = Array.from({ length: 12 }, (_, i) => i + 1);
  const years = Array.from({ length: 60 }, (_, i) => 2008 - i);
  const F = (k) => ({ value: v[k] || "", onChange: (e) => set(k, e.target.value) });
  return (
    <React.Fragment>
      <div className="hu-formsec">Personal data <span className="hu-formsec__opt">all optional — required fields are driven by config/user_fields.php (username + password only)</span></div>
      <div className="hu-form3">
        <HuField label="Name" err={errs.firstname}><input className="input" {...F("firstname")} /></HuField>
        <HuField label="Lastname"><input className="input" {...F("lastname")} /></HuField>
        <HuField label="Gender"><select className="select" {...F("sex")}><option value="">Select</option><option value="m">Male</option><option value="f">Female</option></select></HuField>
        {/* THE BIRTHPLACE TRIO IS GONE. 028 added ONE set of location columns
            and this form asked for two — birth and residence — so the second
            write would have silently overwritten the first. Residence is kept
            because that is the one the rest of the platform reads (the country
            index, the by-country report panels). Same removal as the player
            create form and both Home tabs. */}
        <HuField label="Birthday">
          <div style={{ display: "flex", gap: 6 }}>
            <select className="select" style={{ flex: 1 }} {...F("birth_day")}><option value="">D</option>{days.map(d => <option key={d}>{d}</option>)}</select>
            <select className="select" style={{ flex: 1 }} {...F("birth_month")}><option value="">M</option>{months.map(m => <option key={m}>{m}</option>)}</select>
            <select className="select" style={{ flex: 1.4 }} {...F("birth_year")}><option value="">Year</option>{years.map(y => <option key={y}>{y}</option>)}</select>
          </div>
        </HuField>
        <HuField label="Email" err={errs.email}><input className="input" type="email" {...F("email")} placeholder="name@mail.com" /></HuField>
        <HuField label="Mobile phone">
          <div style={{ display: "flex", gap: 6 }}>
            <select className="select" style={{ width: 86 }} {...F("mobile_prefix")}><option value="">+…</option><option>+54</option><option>+55</option><option>+56</option><option>+591</option><option>+595</option><option>+39</option></select>
            <input className="input" style={{ flex: 1 }} {...F("mobile")} />
          </div>
        </HuField>
      </div>
      <div className="hu-formsec">Residence &amp; documents</div>
      <div className="hu-form3">
        <HuField label="Address" span2>
          <div style={{ display: "flex", gap: 6 }}>
            <input className="input" style={{ flex: 1 }} placeholder="Street" {...F("address_residence")} />
            <input className="input" style={{ width: 110 }} placeholder="No." {...F("address_house_number")} />
          </div>
        </HuField>
        <HuField label="Zip"><input className="input" {...F("zip_residence")} /></HuField>
        {/* NAMES, and users.country is ISO 3166-1 alpha-2. With no lookup table
            between "Argentina" and "AR" this select's value is deliberately not
            written — the country is set from the Home tab's two-letter field
            after creating. Disabled and labelled rather than silently dropped. */}
        <HuField label="Country of residence" note="Not saved from this form: the column is a two-letter ISO code and this list holds names, with no lookup between them. Set it on the Home tab.">
          <select className="select" disabled {...F("country_residence")}>{COUNTRIES.map(c => <option key={c} value={c === "- Select -" ? "" : c}>{c}</option>)}</select>
        </HuField>
        {v.country_residence === "Italy" ? (
          <React.Fragment>
            <HuField label="Province"><select className="select" {...F("province_residence")}>{IT_PROV.map(p => <option key={p} value={p === "- Select -" ? "" : p}>{p}</option>)}</select></HuField>
            <HuField label="City"><select className="select" {...F("city_residence")}>{IT_PROV.map(p => <option key={p} value={p === "- Select -" ? "" : p}>{p}</option>)}</select></HuField>
            {/* Fiscal code has no column, so the box does not take a tax
                identifier and drop it. document_number is not a substitute —
                a codice fiscale is not the number on the document. */}
            <HuField label="Fiscal code" note="No column in this schema — the box would take a tax identifier and discard it.">
              <HuNoBackend block className="input" style={{ textAlign: "left" }}
                what="Fiscal code" need="a fiscal_code column on users">Not stored</HuNoBackend>
            </HuField>
          </React.Fragment>
        ) : (
          <React.Fragment>
            <HuField label="Province"><input className="input" {...F("province_residence")} /></HuField>
            <HuField label="City"><input className="input" {...F("city_residence")} /></HuField>
          </React.Fragment>
        )}
        <HuField label="Document type"><select className="select" {...F("document_type")}><option value="">Select</option><option>ID card</option><option>Passport</option><option>Driving licence</option></select></HuField>
        <HuField label="Document number"><input className="input" {...F("document_number")} /></HuField>
      </div>
    </React.Fragment>
  );
};

const HuCreateModal = ({ def, rows, skins, profiles, me, onCreate, onClose }) => {
  const [v, setV] = useStateHU({});
  const [errs, setErrs] = useStateHU({});
  const [busy, setBusy] = useStateHU(false);
  /* The database's own words when it refuses. Held separately from `errs`
     because it is not a field error and must not be cleared by re-validating:
     "an Agent may not create a Master here" is a rule, not a typo, and the
     operator needs it to stay on screen while they change the parent. */
  const [refusal, setRefusal] = useStateHU(null);
  // Master subnet-creation matrix — can_create[level] + prov_coupons[level]; Shop forced on.
  const [matrix, setMatrix] = useStateHU({ 10: { can: false, prof: "" }, 15: { can: false, prof: "" }, 20: { can: true, prof: "" } });
  const set = (k, val) => setV(s => ({ ...s, [k]: val }));
  const parents = def.parentLevels ? rows.filter(r => def.parentLevels.indexOf(r.lvl) >= 0) : [];
  // Only the clipboard action toasts from this modal, and it is a genuine local
  // action — so the message says that rather than "runs in the admin build".
  const toast = (m) => window.PAYBO && window.PAYBO.emitToast && window.PAYBO.emitToast({ id: `hu-new-${Date.now()}`, tx_id: m, amount: 0, currency: "HOST", player: "Users", reason: "Local clipboard action." });

  /* Copies the USERNAME. It used to copy "<username> / <password>" — a
     credential pair for an account that has no password, so whoever pasted it
     into a message to the new operator was sending them a login that cannot
     work. There is nothing to hand over until link_auth_user() attaches one. */
  const copyCreds = () => {
    const txt = String(v.username || "");
    if (!txt) return;
    // writeText is a promise — a rejection never reaches a synchronous catch, so
    // confirm only once the write really resolved.
    const fail = () => toast("Clipboard blocked — copy the username manually");
    try {
      const p = navigator.clipboard.writeText(txt);
      if (p && p.then) p.then(() => toast("Username copied"), fail); else toast("Username copied");
    } catch (_e) { fail(); }
  };

  const skinObj = def.needsSkin && v.skin ? skins.find(s => s.name === v.skin) : null;

  const submit = async () => {
    if (busy) return;
    // Mirrors the shared inline validation of every saveNew* (errors come back as
    // ajaxError(html, {campierrati:[fields]}) — fields highlighted + summary box).
    const e = {};
    const u = (v.username || "").trim();
    const parent = def.parentLevels ? rows.find(r => r.id === Number(v.parentId)) : null;
    /* Username is unique per SKIN (users has `unique (skin_id, username)`), not
       globally, so the collision test is scoped to the skin this account will
       land in — the parent's for the four chain roles, the chosen one for Skin
       Access and Regulation User. Testing every visible row instead would refuse
       a name that is free in the target tenant and taken in another, which is a
       valid create blocked by the client before the database ever sees it.
       Where the target skin is not yet known the test stays broad: refusing too
       much here is recoverable, and the real uniqueness check is the index. */
    const targetSkinId = def.needsSkin ? (skinObj ? skinObj.id : null)
                                       : (parent ? parent.skinId : null);
    const sameSkin = (r) => targetSkinId == null || r.skinId === targetSkinId;
    if (!u) e.username = "Username is required.";
    else if (!huValidUser(u)) e.username = "Only letters, digits, dot, dash, underscore (validateUsername charset — inferred).";
    else if (rows.some(r => sameSkin(r) && huNorm(r.username) === huNorm(u))) e.username = "Username already exists in this skin (users.unique(skin_id, username)).";
    /* No password rules left to enforce: the field is gone because create_user()
       takes no password. Validating a value that is never sent is a form
       refusing to submit over something that does not matter. */
    if (v.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v.email)) e.email = "Invalid email format.";
    else if (v.email && rows.some(r => huNorm(r.email) === huNorm(v.email))) e.email = "Email already in use.";
    if (def.parentLevels && !v.parentId) e.parentId = "Select a parent.";
    else if (def.parentLevels && !parent) e.parentId = "That parent is no longer in the list — reopen the form.";
    if (def.masterExtras && !v.profile) e.profile = "Commission profile is required for a Master.";
    if (def.needsSkin && !v.skin) e.skin = "Skin is required.";
    else if (def.needsSkin && !skinObj) e.skin = "That skin is not in the list — reopen the form.";
    setErrs(e);
    if (Object.keys(e).length) return;

    /* The call. The modal stays open on a refusal so the fields survive and the
       reason sits next to them — role_creation refusing an Agent who asked for a
       Master is an ordinary answer here, and closing the form over it would hide
       both the rule and the ten minutes of typing. */
    setBusy(true);
    setRefusal(null);
    const res = await onCreate({ def, values: v, parent, skin: skinObj, matrix: def.masterExtras ? matrix : null });
    setBusy(false);
    if (!res || !res.ok) {
      setRefusal((res && res.error && res.error.message) || "The create failed, and the server gave no reason.");
    }
    // Success closes the modal from the caller, which also refetches the list.
  };

  return (
    <HuModalShell
      title={def.label}
      sub={`${def.save} · admin/users/forms/${def.form} · user_level ${def.lvl}`}
      onClose={onClose} wide
      footer={<React.Fragment>
        <button className="rpt-btn rpt-btn--search" style={{ minWidth: 0, height: 40 }} onClick={copyCreds}><Icon name="copy" size={13} /> Copy username</button>
        <span style={{ flex: 1 }} />
        <button className="rpt-btn rpt-btn--reset" style={{ minWidth: 0, height: 40 }} onClick={onClose} disabled={busy}>Cancel</button>
        <button className="rpt-btn rpt-btn--blue" style={{ minWidth: 0, height: 40 }} onClick={submit} disabled={busy}>
          <Icon name="plus" size={13} /> {busy ? "Creating…" : "Create"}
        </button>
      </React.Fragment>}>

      <Explainer compact title={`How ${def.label} behaves on the live platform`} bullets={def.notes}>
        Creation also sets an api_token (Str::random(60)), records addedByUser, builds the new user_path after insert, copies the parent's game permissions and writes an audit log line.
      </Explainer>

      {/* WHAT THIS FORM COLLECTS AND THE DATABASE DOES NOT STORE.
          create_user() (supabase/017) writes the account and its place in the
          hierarchy. It does not take a password — credentials live in Supabase
          auth and are attached afterwards by link_auth_user() (supabase/032) —
          and `users` has no columns for the residence/document block. Saying so
          before the operator submits is the difference between a known gap and
          a form that quietly drops half of what was typed. */}
      <div className="hu-errbox" style={{ background: "var(--p-50)", borderColor: "var(--p-100)", color: "var(--p-700)", alignItems: "flex-start", lineHeight: 1.55 }}>
        <Icon name="info" size={14} style={{ flex: "0 0 auto", marginTop: 2 }} />
        <span>
          Stored: username, name, lastname, email, mobile, the parent and the level.
          No login is created here — use the key icon on the row afterwards to send an
          invite, which creates one and emails a set-password link.
          <b> Not stored yet</b>: the residence and document fields, the api_token
          {def.masterExtras ? ", the commission profile and the subnet-creation matrix" : ""}
          {def.key === "shop" ? " and the generated promoter code" : ""}.
        </span>
      </div>

      {Object.keys(errs).length > 0 && (
        <div className="hu-errbox">
          <Icon name="alert" size={14} /> Check the highlighted fields — the live endpoint would return ajaxError(…, {"{"}campierrati{"}"}).
        </div>
      )}

      {/* The database's refusal, unedited. create_user() names the rule that
          fired — role_creation, the subtree test, the second factor — and
          paraphrasing it here would lose the only thing that says what to change. */}
      {refusal && (
        <div className="hu-errbox" role="alert" style={{ alignItems: "flex-start", lineHeight: 1.55 }}>
          <Icon name="alert" size={14} style={{ flex: "0 0 auto", marginTop: 2 }} />
          <span>The database refused this create: <b>{refusal}</b></span>
        </div>
      )}

      <div className="hu-formsec">Network placement</div>
      <div className="hu-form3">
        {def.parentLevels ? (
          <HuField label="Parent" req err={errs.parentId} note={`select2 → route admin.users.search (/users2), levels ${def.parentLevels.join("/")}`}>
            <select className="select" value={v.parentId || ""} onChange={e => set("parentId", e.target.value)}>
              <option value="">- Select -</option>
              {parents.map(p => <option key={p.id} value={p.id}>{p.username} · {huRoleName(p.lvl)}</option>)}
            </select>
          </HuField>
        ) : (
          /* saveNewAdmin and saveNewRegulationUser force parent_id = 1. Here the
             parent is the SIGNED-IN operator instead, because create_user()
             requires the parent to sit inside the caller's own subtree: a super
             admin who is not literally user 1 would be refused by a hardcoded 1.
             The field names who it will actually be rather than showing a
             constant that may not be the row used. */
          <HuField label="Parent" note="No parent picker. Upstream forces user 1; here it is you — create_user() requires the parent to be inside your own subtree.">
            <input className="input" disabled
              value={me ? `${me.username} (id ${me.id})` : "— not signed in —"} />
          </HuField>
        )}
        {def.needsSkin && (
          <HuField label="Skin" req err={errs.skin}>
            <select className="select" value={v.skin || ""} onChange={e => set("skin", e.target.value)}>
              <option value="">- Select -</option>
              {skins.map(s => <option key={s.id}>{s.name}</option>)}
            </select>
          </HuField>
        )}
        {skinObj && (
          /* THIS BOX ONCE CLAIMED SOMETHING THE DATABASE DOES NOT DO, which is
             worse than the `undefined` it replaced.

             It was labelled "Derived from skin" and noted that "create_user()
             inherits the currency when none is given". create_user() does
             inherit a currency — from the PARENT, not from the skin
             (017_role_hierarchy.sql: `coalesce(p_currency, v_parent.currency)`)
             — and it does not set `timezone` at all, so the column default
             'UTC' applies whatever this box says.

             For Skin Access and Regulation User the parent is the signed-in
             operator, so the chosen skin is by construction NOT the parent's
             skin: a super admin on a BRL skin creating a Regulation User for an
             ARS skin saw "ARS · America/Argentina/Buenos_Aires" and got
             BRL · UTC. Both values were real — they came from a real `skins`
             row — which is exactly why it read as authoritative.

             So the box now says what the skin IS, and the note says separately
             what the new account will actually get. A field on a create form is
             a claim about the row that is about to exist. */
          <HuField label="Selected skin"
            note="the skin's own settings — the new account takes its CURRENCY from its parent (create_user inherits it) and its TIMEZONE from the column default, not from here">
            <input className="input" value={`${skinObj.cur} · ${skinObj.tz}`} disabled />
          </HuField>
        )}
        {def.masterExtras && (
          <HuField label="Commission profile" req err={errs.profile} note="Includes the -1 / -2 pseudo-profiles">
            <select className="select" value={v.profile || ""} onChange={e => set("profile", e.target.value)}>
              <option value="">- Select -</option>
              {HU_MASTER_PSEUDO.map(([val, lab]) => <option key={val} value={val}>{lab}</option>)}
              {profiles.map(p => <option key={p.id} value={p.name}>{p.name}</option>)}
            </select>
          </HuField>
        )}
        {def.key === "shop" && (
          <HuField label="Commission profile" note="Forced from the governing Master's master_settings[SHOP] — not editable here.">
            <input className="input" value="Inherited from Master settings" disabled />
          </HuField>
        )}
      </div>

      {def.masterExtras && (
        <React.Fragment>
          <div className="hu-formsec">Subnet creation rights <span className="hu-formsec__opt">can_create[level] + prov_coupons[level] → master_settings rows</span></div>
          <table className="data-table hu-matrix">
            <thead><tr><th style={{ textAlign: "left" }}>Level</th><th>Can create</th><th style={{ textAlign: "left" }}>Default commission profile</th></tr></thead>
            <tbody>
              {[[10, "Agent"], [15, "Promoter"], [20, "Shop"]].map(([lvl, name]) => (
                <tr key={lvl}>
                  <td style={{ textAlign: "left", fontWeight: 600 }}>{name} ({lvl}){lvl === 20 && <span style={{ marginLeft: 8, fontSize: 11, color: "var(--text-tertiary)" }}>forced on</span>}</td>
                  <td style={{ textAlign: "center" }}>
                    <Toggle size="sm" onLabel="" offLabel="" value={matrix[lvl].can} disabled={lvl === 20}
                      onChange={(next) => setMatrix(m => ({ ...m, [lvl]: { ...m[lvl], can: next } }))} />
                  </td>
                  <td style={{ textAlign: "left" }}>
                    <select className="select" style={{ width: "100%" }} value={matrix[lvl].prof} onChange={e => setMatrix(m => ({ ...m, [lvl]: { ...m[lvl], prof: e.target.value } }))}>
                      <option value="">- Select -</option>
                      {profiles.map(p => <option key={p.id}>{p.name}</option>)}
                    </select>
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        </React.Fragment>
      )}

      <div className="hu-formsec">Login data</div>
      <div className="hu-form3">
        <HuField label="Username" req err={errs.username}><input className="input" value={v.username || ""} onChange={e => set("username", e.target.value)} autoFocus /></HuField>
        {/* THE PASSWORD PAIR IS GONE, and its absence is the honest version.
            create_user() takes p_auth_user_id and no password at all, because
            credentials live in Supabase auth rather than in `users` — so these
            two boxes validated each other, agreed, and were discarded. */}
      </div>
      <HuNoBackendNote>
        No password here: this creates the ACCOUNT, not a login. Credentials belong to the
        auth provider, and <code>link_auth_user()</code> attaches one afterwards.
        {/* <!-- SUGGESTION: an operator-created account cannot sign in. Closing that needs a service-role step the browser cannot take — mint the auth user, then link_auth_user() it — so it belongs in an Edge Function wrapping both. --> */}
      </HuNoBackendNote>

      <HuPersonal v={v} set={set} errs={errs} />
    </HuModalShell>
  );
};

/* Empty transaction table shared by the editor's Transactions / Credit Transactions
   tabs (feeds: GET /getUserTransactions/{id}/ and GET /getUserCreditTransactions/{id}/
   — TransactionsController). */
/* THE OPERATOR'S OWN LEDGER. This rendered a hardcoded "No data available in
   table" and a hardcoded 0.00 / 0.00 footer for every operator on the platform,
   with a filter panel whose Search had no handler. Two claims in one component:
   that this account has no transactions, and that its totals are zero. Neither
   was ever checked.

   TWO WALLETS, ONE COMPONENT. The Transactions tab is the `real` wallet and
   Credit Transactions is `credits` — a real column on user_balances, moved by
   post_transfer's credit operations, not a label. The two tabs were identical
   except for a hidden column. */
const HuTxTable = ({ userId, credit }) => {
  /* Options come from transaction_types (useHpTxTypes lives in HostPlayers.jsx,
     an implicit global here — same load-order convention the rest of this file
     uses for hpDate/hpIp). The two hand-written arrays they replaced could
     drift from the table that defines the ids. */
  const txTypes = useHpTxTypes();
  const feed = useHrsFetch(
    () => window.sb.list("ledger", { limit: 500, filters: { user: userId, wallet: credit ? "credits" : "real" } }),
    [userId, credit]);
  const all = useMemoHU(() => (feed.data || []).map(hpLedgerRow), [feed.data]);
  const [draft, setDraft] = useStateHU(HP_TXF_EMPTY);
  const [applied, setApplied] = useStateHU(HP_TXF_EMPTY);
  const rows = useMemoHU(() => hpTxFilter(all, applied), [all, applied]);
  const set = (patch) => setDraft(d => Object.assign({}, d, patch));
  const inTotal = rows.filter(t => t.amount > 0).reduce((a, t) => a + t.amount, 0);
  const outTotal = rows.filter(t => t.amount < 0).reduce((a, t) => a - t.amount, 0);
  return (
  <div>
    <div className="rpt-filters hp-txfilters">
      <div className="rpt-field"><label>Transaction date</label><div className="rpt-daterow">
        <input className="input rpt-date" type="date" value={draft.from} onChange={e => set({ from: e.target.value })} />
        <input className="input rpt-date" type="date" value={draft.to} onChange={e => set({ to: e.target.value })} />
      </div></div>
      {/* Matched on the type ID. The label is translatable, so a filter keyed to
          it stops matching the day a translation lands — and returns nothing,
          which looks like an answer. */}
      <div className="rpt-field" style={{ flex: 1, minWidth: 240 }}><label>Transaction Type</label>
        <select className="select" style={{ width: "100%" }} value={draft.typeId}
                onChange={e => set({ typeId: e.target.value })}>
          <option value="ALL">-ALL-</option>
          {txTypes.all.map(t => <option key={t.id} value={t.id}>{t.label}</option>)}
        </select></div>
      {!credit && <div className="rpt-field"><label>Transaction ID</label>
        <input className="input" placeholder="Transaction ID" value={draft.ref}
               onChange={e => set({ ref: e.target.value })} /></div>}
      <div className="rpt-actions">
        <div className="rpt-actions-row" style={{ flexDirection: "column", gap: 8 }}>
          <button className="rpt-btn rpt-btn--reset" onClick={() => { setDraft(HP_TXF_EMPTY); setApplied(HP_TXF_EMPTY); }}><Icon name="x" size={14} /> Reset</button>
          <button className={`rpt-btn ${credit ? "rpt-btn--blue" : "rpt-btn--search"}`} onClick={() => setApplied(draft)}><Icon name="search" size={14} /> Search</button>
        </div>
      </div>
    </div>
    {feed.loading && <HrsSkeleton rows={6} cols={credit ? 7 : 8} />}
    {!feed.loading && feed.error && <HrsError error={feed.error} onRetry={feed.retry} />}
    {!feed.loading && !feed.error && (<>
    <div className="panel" style={{ overflow: "hidden" }}><div style={{ overflowX: "auto" }}>
      <table className="data-table hp-list">
        <thead><tr><th>ID</th><th>Typology</th><th>Description</th>{!credit && <th>Transaction ID</th>}<th>IN</th><th>OUT</th><th>Balance</th><th>Date</th></tr></thead>
        <tbody>
          {rows.length === 0 && <tr><td colSpan={credit ? 7 : 8} style={{ padding: "26px", textAlign: "center", color: "var(--text-tertiary)" }}>
            {all.length ? "No transactions match your filters." : "No data available in table"}</td></tr>}
          {rows.map(t => (
            <tr key={t.nid}>
              <td>{t.nid}</td>
              <td>{t.typology}</td>
              <td style={{ textAlign: "left" }}>{t.description || "—"}</td>
              {!credit && <td className="hp-mono">{t.reference || ""}</td>}
              <td className="hp-in">{t.amount > 0 ? hpMoneyPlain(t.amount) : ""}</td>
              <td className="hp-out">{t.amount < 0 ? hpMoneyPlain(-t.amount) : ""}</td>
              <td>{hpMoneyPlain(t.balance)}</td>
              <td>{hpDate(t.ts, true)}</td>
            </tr>
          ))}
        </tbody>
        {/* The footer sums the FILTERED rows. It used to print 0.00 twice. */}
        <tfoot><tr className="hp-total">
          <td colSpan={credit ? 3 : 4}></td>
          <td>{hpMoneyPlain(inTotal)}</td>
          <td>{hpMoneyPlain(outTotal)}</td>
          <td colSpan={2}></td>
        </tr></tfoot>
      </table>
    </div></div>
    <div className="hp-entries">
      {rows.length} of {all.length} entr{all.length === 1 ? "y" : "ies"}
      {all.length >= 500 && " — capped at the 500 most recent; older rows are not loaded"}
    </div>
    </>)}
  </div>
  );
};

/* ---------------- User editor — GET /users/{id}/ → showUserDetails ----------------
   Guards on the live platform: SUPERADMIN_LEVEL < user_level < PLAYER_LEVEL,
   checkParentPerm (user_path descendant check) + UserPolicy@view.
   Saves: POST /saveUser/{id}/{tab}/ → saveEditUser (blocked entirely by
   disable_subnet_crud). */
const HostUserEdit = ({ user, skins, profiles, providers, me, onBack }) => {
  // Each tab gets its own URL (e.g. /users/transactions) via useUrlTab
  // (src/routes.jsx) — same mechanism as the Players editor.
  const TABS = [["home", "Home", ""], ["transactions", "Transactions", "transactions"], ["credit", "Credit Transactions", "credit-transactions"], ["coupons", "Sport Coupon History", "sport-coupon-history"], ["permissions", "Permissions", "permissions"], ["providers", "Providers", "providers"], ["deposit", "Deposit", "deposit"], ["logs", "Logs", "logs"]];
  const [tab, setTab] = window.useUrlTab("/users", TABS, "home");
  const availability = (user.bal || 0) + (user.credits || 0);
  /* CONTROLLED, so SAVE has something to save. Every field below was an
     uncontrolled `defaultValue` and the button was disabled with
     `need="POST /saveUser/{id}/home"` — which read like a missing endpoint. It
     was a missing column list: 028 added these nine columns FOR this tab and
     never opened them, and supabase/051 does. */
  const [uf, setUf] = useStateHU(() => ({
    firstname: user.firstname, lastname: user.lastname, email: user.email,
    mobile: user.mobile, gender: user.gender, birthdate: user.birthdate,
    country: user.country, province: user.province, city: user.city,
    address: user.address, postcode: user.postcode,
    documentType: user.documentType, documentNumber: user.documentNumber,
    timezone: user.timezone || "UTC",
  }));
  const setU = (k, v) => setUf(x => Object.assign({}, x, { [k]: v }));
  const usave = useHrsSave([]);
  /* COMMISSION TERMS — one select, one RPC, and the value carries its own kind.
     `p12` is profile 12 and `m-1` is the at-cost sentinel; upstream keeps both
     in one integer column where -1 sits beside real profile ids, and 007 split
     the table precisely so a sentinel cannot masquerade as an id. Encoding the
     kind in the option value keeps that split all the way to the form — an
     empty string is "no assignment", not "profile 0". */
  const [cprof, setCprof] = useStateHU(() =>
    (user.profileId ? "p" + user.profileId
      : (user.specialMode != null ? "m" + user.specialMode : "")));
  const csave = useHrsSave([]);
  const saveCprof = () => {
    const isMode = cprof.charAt(0) === "m";
    csave.run(() => window.sb.rpc("set_commission_profile", {
      p_user_id: user.id,
      p_profile_id: cprof.charAt(0) === "p" ? Number(cprof.slice(1)) : null,
      p_special_mode: isMode ? Number(cprof.slice(1)) : null,
    }), {
      done: cprof ? `Commission terms set for ${user.username}` : `Commission terms cleared for ${user.username}`,
      fail: `Commission terms not changed for ${user.username}`,
    });
  };
  /* The block flags, held locally so a switch shows what was stored rather than
     what was clicked — the prop comes from the list feed and this editor cannot
     refetch it. */
  const [uflags, setUflags] = useStateHU(() => ({
    blocked: !!user.userBlock, cash_blocked: !!user.cashBlock, test_user: !!user.testUser,
  }));
  const ufsave = useHrsSave([]);
  const setUFlag = (col, next, label) => ufsave.run(
    () => window.sb.update("users", user.id, { [col]: next }), {
      done: `${label} ${next ? "on" : "off"} for ${user.username}`,
      fail: `${label} was not changed for ${user.username}`,
    }).then(async (res) => {
      if (!res || !res.ok) return;
      setUflags(f => Object.assign({}, f, { [col]: next }));
      /* The audit row covers the FULL block only. A cash block is a narrower
         flag with no history table, upstream or here — writing a user_blocks
         row for one would make the block history claim an account was blocked
         when it was not. */
      if (col !== "blocked") return;
      const me = meFeedU.data;
      if (next) {
        await window.sb.create("userBlocks", {
          user_id: user.id, blocked_by: me ? me.id : null,
          reason: "Blocked from the user's Home tab",
        });
      } else {
        const open = await window.sb.list("userBlocks", { limit: 1, filters: { user: user.id, open: "1" } });
        const b = open && open.ok && open.data && open.data[0];
        if (b) {
          await window.sb.update("userBlocks", b.id, {
            unblocked_by: me ? me.id : null, unblocked_at: new Date().toISOString(),
          });
        }
      }
    });
  /* PROVIDER COSTS. Three feeds, because the grid answers three questions:
     what the catalogue is, what THIS account is charged, and what its PARENT is
     charged — the last one is the "% cost parent" column, which was an editable
     box writing this user's own row a second time. */
  const catFeed = useHrsFetch(() => window.sb.list("gameCategories", { limit: 100 }), []);
  const rateFeed = useHrsFetch(
    () => window.sb.list("userProviders", { limit: 1000, filters: { user: user.id } }), [user.id]);
  const parentRateFeed = useHrsFetch(
    () => (user.parentId
      ? window.sb.list("userProviders", { limit: 1000, filters: { user: user.parentId } })
      /* No parent, no parent rate — and an empty OK rather than a skipped fetch,
         so the column renders "—" instead of staying in a loading state
         forever on a root account. */
      : Promise.resolve({ ok: true, data: [] })), [user.parentId]);
  const rateBy = useMemoHU(() => {
    const m = {};
    (rateFeed.data || []).forEach(r => { m[Number(r.provider_id)] = r; });
    return m;
  }, [rateFeed.data]);
  const parentRateBy = useMemoHU(() => {
    const m = {};
    (parentRateFeed.data || []).forEach(r => { m[Number(r.provider_id)] = r; });
    return m;
  }, [parentRateFeed.data]);
  const [pvDraft, setPvDraft] = useStateHU({});
  const [bulk, setBulk] = useStateHU({});
  const pvsave = useHrsSave([rateFeed]);
  /* An EMPTY BOX IS "LEAVE IT", a typed 0 is zero. The RPC takes null for the
     first and 0 for the second, which is why each cell is sent individually
     rather than as a patch object — a null arriving as 0 would wipe a fee the
     operator never touched. */
  const pvNum = (v) => (v === undefined || String(v).trim() === "" ? null : Number(v));
  const saveProviderRates = async () => {
    const ids = Object.keys(pvDraft);
    if (!ids.length) return;
    let failed = 0;
    await pvsave.run(async () => {
      let last = { ok: true };
      for (const pid of ids) {
        const d = pvDraft[pid] || {};
        const res = await window.sb.rpc("set_user_provider_rate", {
          p_user_id: user.id,
          p_provider_id: Number(pid),
          p_percentage: pvNum(d.percentage),
          p_hand_fee: pvNum(d.hand_fee),
          p_extra_fee: pvNum(d.extra_fee),
          p_hidden: null,
        });
        if (!res || !res.ok) { failed++; last = res; }
      }
      /* PARTIAL IS REPORTED AS PARTIAL. One call per edited row means some can
         land and some can be refused; claiming success because the last one
         worked is how a cost table comes to disagree with the screen. */
      if (failed) return last;
      return { ok: true, data: null };
    }, {
      done: `${ids.length - failed} provider rate${ids.length - failed === 1 ? "" : "s"} saved for ${user.username}`,
      fail: `${failed} of ${ids.length} provider rates were NOT saved`,
    });
    if (!failed) setPvDraft({});
  };
  const applyBulkCosts = async () => {
    const entries = Object.keys(bulk).filter(k => String(bulk[k]).trim() !== "");
    if (!entries.length) return;
    let failed = 0;
    await pvsave.run(async () => {
      let last = { ok: true };
      for (const cid of entries) {
        const res = await window.sb.rpc("set_user_provider_rates_by_category", {
          p_user_id: user.id, p_category_id: Number(cid), p_percentage: Number(bulk[cid]),
        });
        if (!res || !res.ok) { failed++; last = res; }
      }
      if (failed) return last;
      return { ok: true, data: null };
    }, {
      done: `Costs applied across ${entries.length} categor${entries.length === 1 ? "y" : "ies"}`,
      fail: `${failed} of ${entries.length} categories were NOT applied`,
    });
    if (!failed) { setBulk({}); setPvDraft({}); }
  };
  const [xfer, setXfer] = useStateHU({ op: "deposit", amount: "", reason: "" });
  const xsave = useHrsSave([]);
  const meFeedU = useHrsFetch(() => window.sb.me(), []);
  /* MONEY. One RPC, one transaction. Two post_transaction calls would be two
     transactions with a window between them, and an interruption there leaves
     money debited from one account and credited to none.

     THE OPERATOR IS THE OTHER SIDE, read from the session — never from a field.
     isystem's Transfer panel hides the payer side on this tab for the same
     reason; here the browser could not supply it even if the form asked,
     because post_transfer resolves the actor itself.

     THE IDEMPOTENCY KEY DESCRIBES THE TRANSFER, NOT THE MOMENT. A key with a
     clock in it makes a double-submit indistinguishable from two genuine
     transfers — on this screen that is paying an agent twice. */
  const doTransfer = () => {
    const me = meFeedU.data;
    const amt = Number(xfer.amount);
    if (!me || !amt) return;
    const toUser = xfer.op === "deposit" || xfer.op === "credit_deposit";
    const wallet = (xfer.op === "credit_deposit" || xfer.op === "credit_withdraw") ? "credits" : "real";
    xsave.run(() => window.sb.transfer({
      fromUserId: toUser ? me.id : user.id,
      toUserId: toUser ? user.id : me.id,
      amount: amt,
      /* Both wallets named, and the SAME one on each side: this moves an
         amount between two accounts, it does not convert between wallets.
         A credit deposit leaves the operator's credit line and lands on the
         user's; sending it to `real` would mint real money out of credit. */
      fromWallet: wallet,
      toWallet: wallet,
      key: `user-transfer:${me.id}:${xfer.op}:${user.id}:${amt}:${(xfer.reason || "").trim()}`,
      description: xfer.reason || null,
    }), {
      done: `${toUser ? "Deposited to" : "Withdrawn from"} ${user.username}`,
      fail: "The transfer did not go through",
    }).then(res => { if (res && res.ok) setXfer({ op: xfer.op, amount: "", reason: "" }); });
  };
  /* Empty box = NULL, not "". An empty string in `country` is a value that
     sorts, groups and exports as its own category — the By-country panel would
     grow a row named nothing. `birthdate` especially: '' is not a date and the
     insert would raise. */
  const nn = (v) => (String(v == null ? "" : v).trim() || null);
  const saveUser = () => usave.run(
    () => window.sb.update("users", user.id, {
      firstname: nn(uf.firstname), lastname: nn(uf.lastname),
      email: nn(uf.email), mobile: nn(uf.mobile),
      gender: nn(uf.gender), birthdate: nn(uf.birthdate),
      /* char(2), uppercased here so 'ar' and 'AR' cannot become two countries.
         The comment on the column says exactly this. */
      country: uf.country ? String(uf.country).trim().toUpperCase().slice(0, 2) : null,
      province: nn(uf.province), city: nn(uf.city),
      address: nn(uf.address), postcode: nn(uf.postcode),
      document_type: nn(uf.documentType), document_number: nn(uf.documentNumber),
      timezone: nn(uf.timezone) || "UTC",
    }), {
      done: `${user.username} saved`,
      fail: `${user.username} was not saved`,
    });
  // The only toast left in the editor is the affiliate-link clipboard copy — a
  // real local action, so the message says so instead of implying a save ran.
  const toast = (m) => window.PAYBO && window.PAYBO.emitToast && window.PAYBO.emitToast({ id: `ue-${user.id}-${Date.now()}`, tx_id: m, amount: 0, currency: "HOST", player: user.username, reason: "Local clipboard action." });
  const logFeed = useHuLoginLogs(user.id);
  const logs = logFeed.rows;
  /* WAS a Knuth hash of the row id — (user.id * 2654435761 >>> 8) in base 36 —
     rendered read-only as though persisted and copied to the clipboard inside a
     working referral URL. Nothing on screen said it was invented.

     In production this code is the join key of the acquisition funnel:
     registration resolves a new player's parent shop by promoter_code first
     (AuthService::register), falling back to the skin's online_shop_id and then
     to the first shop that has any code. A player registering with an unknown
     code does not get an error — they get somebody else's shop. So a fabricated
     code is not a cosmetic placeholder; it is a link that silently attributes
     players to the wrong parent.

     users.promoter_code now exists (supabase/023). Empty is a real state and is
     shown as one. */
  const promoCode = user.promoterCode || "";

  const FIN_ROWS = ["Master", "Agent", "Promoter", "Shop", "Player", "Default Created Player"];
  const FIN_COLS = ["Deposit", "Third party deposit", "Withdraw", "Credit Deposit", "Credit Withdraw"];
  const finCell = (row, col) => {
    if (col === "Third party deposit") return row === "Player" || row === "Default Created Player";
    if (col === "Credit Deposit" || col === "Credit Withdraw") return !(row === "Player" || row === "Default Created Player");
    return true;
  };

  return (
    <div className="page report-page host-players host-users">
      <div className="hp-edit-head">
        <button className="hp-back" onClick={onBack} title="Back to users"><Icon name="chevron_left" size={18} /></button>
        <div>
          <div className="page__title" style={{ color: "var(--p-700)", marginBottom: 2 }}>Edit {huRoleName(user.lvl)}</div>
          <div style={{ fontSize: 15, fontWeight: 600, color: "#3f4254", display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
            {user.username} <span style={{ color: "var(--text-tertiary)", fontWeight: 500 }}>(User ID: {user.id})</span>
            <HuRoleChip lvl={user.lvl} />
          </div>
        </div>
        {/* Detail header — Balance = balance + credits, plus Credits (template.blade.php L66-75). */}
        <div className="hu-headstats">
          <div className="stat-tri"><div className="stat-tri__label">Balance</div><div className="stat-tri__value">{huMoney(user.cur, availability)}</div></div>
          <div className="stat-tri"><div className="stat-tri__label">Credits</div><div className="stat-tri__value">{huMoney(user.cur, user.credits)}</div></div>
        </div>
      </div>

      <div className="hp-tabs hu-edittabs">{TABS.map(([id, lab]) => <button key={id} className={`hp-tab ${tab === id ? "active" : ""}`} onClick={() => setTab(id)}>{lab}</button>)}</div>
      <div className="hu-tabgates">
        Customer Care tab gates: Sport Coupon History <HuGateChip>support_sport_coupons</HuGateChip> · Permissions <HuGateChip>support_user_permissions</HuGateChip> · Deposit <HuGateChip>support_user_transactions_read_only</HuGateChip> · Providers hidden from Customer Care entirely.
      </div>

      {tab === "home" && (
        <div className="hp-home">
          <div className="hp-home__main">
            <section className="hp-card">
              <div className="hp-card__title">Login data</div>
              <div className="hp-form">
                <label className="form-label">Username *<Tip>On edit the username is rendered disabled (no name attribute) — it never posts back through saveEditUser.</Tip></label>
                <input className="input" defaultValue={user.username} disabled />
                {/* THE TWO PASSWORD BOXES ARE GONE. "Leave empty to keep" is a
                    promise about what happens when you DON'T leave it empty,
                    and nothing happened either way — the value was matched
                    against its confirmation and dropped. Credentials live in
                    Supabase auth, not in `users`. */}
              </div>
              <HuNoBackendNote>
                No password field. Credentials belong to the auth provider rather than to
                <code> users</code> — a reset is an auth-admin operation, not a column on this row.
                {/* <!-- SUGGESTION: an operator has no way to reset another operator's password. Closing it needs a service-role call the browser cannot make, so it belongs in an Edge Function wrapping Supabase auth's admin API. --> */}
              </HuNoBackendNote>
            </section>
            <div className="hp-two">
              <section className="hp-card">
                <div className="hp-card__title">Personal data<Tip>Inputs are disabled for a Customer Care viewer without the <b>support_user_personal_data</b> permission; maskPersonalDataIfNotAllowed() additionally masks values for scoped managers. Without it, saveEditUser discards all personal fields server-side.</Tip></div>
                <div className="hp-grid2">
                  <label className="form-label">Name</label>
                  <input className="input" value={uf.firstname} onChange={e => setU("firstname", e.target.value)} />
                  <label className="form-label">Lastname</label>
                  <input className="input" value={uf.lastname} onChange={e => setU("lastname", e.target.value)} />
                  {/* The three values the CHECK on users.gender allows, and the
                      stored code is what is sent — not the label. A translated
                      label would stop matching the constraint the day someone
                      localises this screen. */}
                  <label className="form-label">Gender</label>
                  <select className="select" value={uf.gender} onChange={e => setU("gender", e.target.value)}>
                    <option value="">Not recorded</option>
                    <option value="m">Male</option>
                    <option value="f">Female</option>
                    <option value="x">Other / not stated</option>
                  </select>
                  {/* ONE `country` COLUMN, NOT TWO. isystem's form asks for a
                      country/province/city OF BIRTH separately from residence;
                      this schema has a single set (028), so offering both would
                      mean two boxes writing one column — the second silently
                      overwriting the first. Birth-place is recorded as absent
                      rather than faked into the residence fields. */}
                  <label className="form-label">Birthday</label>
                  <input className="input" type="date" value={uf.birthdate} onChange={e => setU("birthdate", e.target.value)} />
                  <label className="form-label">Email</label>
                  <input className="input" value={uf.email} onChange={e => setU("email", e.target.value)} />
                  <label className="form-label">Mobile phone</label>
                  <input className="input" value={uf.mobile} onChange={e => setU("mobile", e.target.value)} />
                </div>
                <HuNoBackendNote>
                  Country, province and city of birth have no column in this schema — 028 added one
                  set of location columns, used below for residence. Two boxes writing one column
                  would mean the second silently overwriting the first.
                  {/* <!-- SUGGESTION: if birthplace is needed for compliance, it needs its own columns (birth_country / birth_province / birth_city). It is a different fact from where somebody lives now, and KYC asks for both. --> */}
                </HuNoBackendNote>
              </section>
              <div style={{ display: "flex", flexDirection: "column", gap: 18 }}>
                <section className="hp-card"><div className="hp-card__title">Residence data</div>
                  <div className="hp-grid2">
                    {/* ONE address box. It was Street + House number writing
                        nothing; `users.address` is one text column, so two
                        boxes would need a join rule nobody wrote — and the
                        second would overwrite the first. */}
                    <label className="form-label">Address</label>
                    <input className="input" placeholder="Street and number" value={uf.address} onChange={e => setU("address", e.target.value)} />
                    <label className="form-label">Zip</label>
                    <input className="input" value={uf.postcode} onChange={e => setU("postcode", e.target.value)} />
                    {/* A TEXT BOX, NOT A SELECT WITH TWO COUNTRIES IN IT. The
                        column is ISO 3166-1 alpha-2 and there is no country
                        table in this schema; a two-entry dropdown is a list of
                        the only countries an operator may claim to live in.
                        Uppercased on save so 'ar' and 'AR' cannot become two. */}
                    <label className="form-label">Country<Tip>ISO 3166-1 alpha-2, two letters — "AR", "IT". Stored uppercase; a free-text country name would give reports one row per spelling.</Tip></label>
                    <input className="input" maxLength={2} placeholder="AR" style={{ textTransform: "uppercase" }}
                      value={uf.country} onChange={e => setU("country", e.target.value)} />
                    <label className="form-label">Province</label>
                    <input className="input" value={uf.province} onChange={e => setU("province", e.target.value)} />
                    <label className="form-label">City</label>
                    <input className="input" value={uf.city} onChange={e => setU("city", e.target.value)} />
                  </div>
                </section>
                <section className="hp-card"><div className="hp-card__title">Documents</div>
                  <div className="hp-grid2">
                    {/* Free text, deliberately. `document_type` has no CHECK and
                        no lookup table, so a two-option select would be this
                        screen inventing the platform's list of accepted
                        documents. The SUGGESTION below asks for the table. */}
                    <label className="form-label">Document type<Tip>Free text — this schema has no lookup table of accepted document types, so the options are not the screen's to invent.</Tip></label>
                    <input className="input" placeholder="ID card, Passport, …" value={uf.documentType} onChange={e => setU("documentType", e.target.value)} />
                    <label className="form-label">Document number</label>
                    <input className="input" value={uf.documentNumber} onChange={e => setU("documentNumber", e.target.value)} />
                  </div>
                  {/* <!-- SUGGESTION: give document_type a lookup table. It is free text on both users and user_kyc_documents, so "ID card", "id_card" and "IDCard" are three types and no report can group them. --> */}
                </section>
              </div>
            </div>
          </div>
          <aside className="hp-home__side">
            <section className="hp-card hp-info"><div className="hp-card__title">Info</div>
              {[["Role", `${huRoleName(user.lvl)} (level ${user.lvl})`], ["Skin", user.skin], ["Parent", user.parent], ["Last access", user.last ? hpDate(user.last) : "-"], ["IP", logs.length ? logs[0].ip : (logFeed.loading ? "…" : "-")], ["Registration date", hpDate(user.reg)]].map(([k, v]) => <div className="hp-info__row" key={k}><span className="k">{k}</span><span className="v">{v}</span></div>)}
            </section>
            <section className="hp-card"><div className="hp-card__title">Saldi</div>
              <div className="hp-bal"><span className="dot" style={{ background: "#1f9d57" }} /> Credits <b>{huMoney(user.cur, user.credits)}</b></div>
              <div className="hp-bal"><span className="dot" style={{ background: "#e9484a" }} /> Balance <b>{huMoney(user.cur, user.bal)}</b></div>
              <div className="hp-bal"><span className="dot" style={{ background: "#e6a82c" }} /> Availability <b>{huMoney(user.cur, availability)}</b></div>
            </section>
            {user.lvl === 20 && (
              <section className="hp-card"><div className="hp-card__title">Affiliate link<Tip>Shop rows get a promoter/affiliate link on the Home tab; a promoter_code is auto-generated if missing (user.blade.php L48-77 — a special URL shape exists for skin_id 60).</Tip></div>
                <label className="form-label">Promoter code</label>
                <div style={{ display: "flex", gap: 8, marginBottom: 8 }}>
                  <input className="input" style={{ flex: 1, fontWeight: 700 }} value={promoCode}
                    placeholder="Not set" readOnly />
                  <button className="hp-eye" style={{ position: "static", borderRadius: 6, width: 42 }}
                    title={promoCode ? "Copy link" : "No promoter code on this shop yet"}
                    disabled={!promoCode}
                    onClick={() => {
                    // Confirm only on a write that actually resolved.
                    const fail = () => toast("Clipboard blocked — copy the link manually");
                    try {
                      const p = navigator.clipboard.writeText(`https://${String(user.skin).toLowerCase()}.com/?ref=${promoCode}`);
                      if (p && p.then) p.then(() => toast("Affiliate link copied"), fail); else toast("Affiliate link copied");
                    } catch (_e) { fail(); }
                  }}><Icon name="copy" size={14} /></button>
                </div>
                {promoCode
                  ? <div style={{ fontSize: 11.5, color: "var(--text-tertiary)", wordBreak: "break-all" }}>https://{String(user.skin).toLowerCase()}.com/?ref={promoCode}</div>
                  /* No code, no link. Rendering ?ref= with nothing after it
                     produces a URL that looks copyable and resolves to the
                     fallback shop — which is the failure this whole change is
                     about, one step further along. */
                  : <div style={{ fontSize: 11.5, color: "var(--text-tertiary)" }}>
                      No promoter code set, so this shop has no referral link. Players who register
                      without one are attributed to the skin&rsquo;s default shop.
                    </div>}
              </section>
            )}
            {/* TWO-FACTOR — inside the editor as asked, not only on the list.
                The same modal does the work (the note is mandatory, so a bare
                click must never act); this card is status + the two entries. */}
            <Hu2faEditCard user={user} me={me} />
            <section className="hp-card"><div className="hp-card__title">User settings</div>
              {/* Blocked and Cash block write IMMEDIATELY, not on the Home tab's
                  SAVE. Blocking somebody is an action with a consequence, not a
                  field edit waiting for a form submit — and the list screen's
                  block flow already works this way, so folding these into SAVE
                  would give the same account two different block semantics
                  depending on which screen you used.

                  The switch shows what was STORED, not what was clicked: held
                  locally and advanced only after the write returns ok, so a
                  refused block leaves it where it was. */}
              <div className="hp-setting"><span>Blocked<Tip>Writes users.blocked and opens or closes the user_blocks audit row. Upstream also gates unblock on the enable_user_unblock skin setting; that setting has no column here.</Tip></span>
                <Toggle value={!!uflags.blocked} disabled={ufsave.busy} onLabel="" offLabel="" size="sm"
                        onChange={(v) => setUFlag("blocked", v, "Blocked")} /></div>
              {/* SUBNET BLOCK HAS NO COLUMN, and it is not a near-miss for
                  `blocked`: upstream it propagates down the whole user_path
                  subtree, so treating it as this row's own flag would block one
                  account and report that a network was stopped. */}
              <div className="hp-setting"><span>Subnet block<Tip>No column in this schema. Upstream it propagates to every non-support user below this one via user_path — a subtree operation, not a flag on this row.</Tip></span>
                <span style={{ fontSize: 11, color: "var(--text-tertiary)" }}>not stored</span></div>
              <div className="hp-setting"><span>Cash block<Tip>Writes users.cash_blocked. A narrower flag with no history table, upstream or here.</Tip></span>
                <Toggle value={!!uflags.cash_blocked} disabled={ufsave.busy} onLabel="" offLabel="" size="sm"
                        onChange={(v) => setUFlag("cash_blocked", v, "Cash block")} /></div>
              <div className="hp-setting"><span>Test user<Tip>Excluded from reports. Upstream renders this for super admin only.</Tip></span>
                <Toggle value={!!uflags.test_user} disabled={ufsave.busy} onLabel="" offLabel="" size="sm"
                        onChange={(v) => setUFlag("test_user", v, "Test user")} /></div>
              <div style={{ marginTop: 10 }}><label className="form-label">Subnet group id<Tip>No column in this schema.</Tip></label>
                <HuNoBackend block className="input" style={{ textAlign: "left" }}
                  what="Subnet group id" need="a subnet_group_id column on users">Not stored</HuNoBackend></div>
              {/* TIMEZONE IS A REAL COLUMN and is in the write allowlist, so it
                  is a real select — it used to render the SKIN's timezone as the
                  only option, which showed the brand's setting in a field that
                  edits the user's. */}
              <div style={{ marginTop: 10 }}><label className="form-label">Timezone<Tip>users.timezone — the account's own, not the brand's. Saved by the Home tab's SAVE.</Tip></label>
                <select className="select" style={{ width: "100%" }} value={uf.timezone}
                        onChange={e => setU("timezone", e.target.value)}>
                  {(HU_TIMEZONES.indexOf(uf.timezone) >= 0 ? HU_TIMEZONES : [uf.timezone].concat(HU_TIMEZONES))
                    .map(tz => <option key={tz} value={tz}>{tz}</option>)}
                </select></div>
              {/* Currency stays read-only: it is in the allowlist, but changing
                  the denomination of an account that already holds a balance
                  re-labels money without converting it. Upstream restricts it to
                  super admin; here it needs a conversion path nobody has built. */}
              <div style={{ marginTop: 10 }}><label className="form-label">Currency<Tip>Read-only here. The column is writable, but changing it on an account holding a balance re-labels money without converting it — that needs a conversion path, not a select.</Tip></label>
                <select className="select" style={{ width: "100%" }} disabled><option>{user.cur}</option></select></div>
            </section>
            <section className="hp-card"><div className="hp-card__title">Payment Methods<Tip>Per-payment-method fee overrides (payment_fields + customize_methods) are stored in user_payment_settings keyed by user_path (saveEditUser L4122-4166).</Tip></div>
              <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 10, fontWeight: 600, color: "#3f4254" }}>Transferencia Bancaria <label style={{ fontWeight: 400, fontSize: 12.5, color: "var(--text-tertiary)" }}><input type="checkbox" /> Customize payment method</label></div>
              <table className="data-table"><thead><tr><th>Field</th><th>Value</th></tr></thead>
                <tbody><tr><td>Recipient Name</td><td><input className="input" style={{ width: "100%" }} disabled /></td></tr><tr><td>Recipient Address</td><td><input className="input" style={{ width: "100%" }} disabled /></td></tr></tbody>
              </table>
            </section>
          </aside>
        </div>
      )}

      {tab === "transactions" && <HuTxTable userId={user.id} />}
      {tab === "credit" && <HuTxTable userId={user.id} credit />}
      {tab === "coupons" && <HostSportCoupons wrap={false} seed={"u" + user.id} />}

      {tab === "permissions" && (
        <div>
          <div className="panel" style={{ overflow: "hidden", marginBottom: 18 }}>
            <table className="data-table hp-list">
              <thead><tr><th>Permission</th><th>Status</th></tr></thead>
              <tbody>
                {/* SIX SWITCHES OVER NOTHING. Per-user product enablement has no
                    table here, so these showed "off" for every operator on the
                    platform and discarded the flip — a switch that is both a
                    false reading and an inert control. Rendered as text, because
                    a disabled switch still shows a POSITION and that position
                    reads as a fact about the account. */}
                {["Enable Sport", "Enable Casino", "Enable Casino Live", "Enable Poker", "Enable Virtual", "Enable Lottery"].map(p => (
                  <tr key={p}><td>{p}</td>
                    <td><span style={{ fontSize: 11, color: "var(--text-tertiary)" }}>not stored</span></td></tr>
                ))}
                <tr>
                  <td style={{ textAlign: "left" }}>Tawk Chat (example: https://embed.tawk.to/chatId/widgetId)<Tip>No column in this schema. Upstream it is users.tawk_chat_code, editable by superadmin / skin admin only.</Tip></td>
                  {/* Took a URL and dropped it. A support-widget address that
                      looks saved and is not means the operator believes chat is
                      live on that account. */}
                  <td><HuNoBackend block className="input" style={{ width: "90%", textAlign: "left" }}
                        what="Tawk chat code" need="a tawk_chat_code column on users">Not stored</HuNoBackend></td>
                </tr>
                <tr>
                  <td style={{ textAlign: "left" }}>Commissions profile (profilo_provvigionale)<Tip>Written by set_commission_profile() (supabase/052), which carries the guards an allowlist entry would have switched off: no self-dealing, subtree containment, the profile's level must match, and nothing may be assigned beneath a −1 master.</Tip></td>
                  <td style={{ display: "flex", gap: 8, alignItems: "center", justifyContent: "flex-end" }}>
                    {/* THE OPTIONS ARE FILTERED THE WAY UPSTREAM FILTERS THEM —
                        commissionProfilesList(skin, target user_level) — so a
                        profile built for Agents is not offered for a Shop, and
                        another brand's profiles are not offered at all. This is
                        manners, not the check: set_commission_profile refuses
                        both regardless of what the select contained. */}
                    <select className="select" style={{ flex: 1, minWidth: 0 }} value={cprof}
                      onChange={e => setCprof(e.target.value)}>
                      <option value="">- Select -</option>
                      {/* The sentinels belong to a Master. On any other level
                          the value would store and then be ignored by every
                          commission run, which is a rate that looks set. */}
                      {user.lvl === 8 && HU_MASTER_PSEUDO.map(([val, lab]) => <option key={val} value={`m${val}`}>{lab}</option>)}
                      {profiles.filter(p => p.skinId === user.skinId && (p.level == null || p.level === user.lvl))
                               .map(p => <option key={p.id} value={`p${p.id}`}>{p.name}</option>)}
                    </select>
                    <button className="rpt-btn rpt-btn--blue hpx-mini-btn" onClick={saveCprof} disabled={csave.busy}>
                      {csave.busy ? "SAVING…" : "Apply"}
                    </button>
                  </td>
                </tr>
              </tbody>
            </table>
          </div>
          {user.lvl === 8 && (
            <div className="panel" style={{ overflow: "hidden", marginBottom: 18 }}>
              {/* MASTER rows re-sync can_create / prov_coupons into MasterSetting on this
                  tab's save. A missing default profile raises the hardcoded Italian error
                  "Seleziona profilo provvigionale per …" on the live platform. */}
              <table className="data-table hp-list hu-matrix">
                <thead><tr><th style={{ textAlign: "left" }}>SUBNET CREATION (master_settings)</th><th>Can create</th><th>Default commission profile</th></tr></thead>
                <tbody>
                  {/* `master_settings` has no table in this schema, so neither
                      the Can-create switch nor the default-profile select has
                      anywhere to write. Both were live controls: the select
                      accepted a commission profile — the thing that decides how
                      a whole subnet is paid — and dropped it. */}
                  {[[10, "Agent"], [15, "Promoter"], [20, "Shop"]].map(([lvl, name]) => (
                    <tr key={lvl}>
                      <td style={{ textAlign: "left", fontWeight: 600 }}>{name} ({lvl}){lvl === 20 && <span style={{ marginLeft: 8, fontSize: 11, color: "var(--text-tertiary)" }}>forced on</span>}</td>
                      <td><span style={{ fontSize: 11, color: "var(--text-tertiary)" }}>not stored</span></td>
                      <td><span style={{ fontSize: 11, color: "var(--text-tertiary)" }}>not stored</span></td>
                    </tr>
                  ))}
                </tbody>
              </table>
            </div>
          )}
          <div className="panel" style={{ overflow: "hidden" }}>
            <table className="data-table hp-list hu-fin">
              <thead><tr><th>FINANCIAL</th>{FIN_COLS.map(c => <th key={c}>{c}</th>)}</tr></thead>
              <tbody>
                {/* THE WORST OF THE FOUR. Every cell rendered a switch already
                    ON — `<Toggle defaultValue …>` with no value — so the matrix
                    asserted that this account may take deposits, withdrawals and
                    credit in every category, for every operator, without asking
                    anything. There is no permissions/vpermissions table here;
                    what exists is the permission matrix 040 built, which the
                    Support users screen writes through set_user_permission. */}
                {FIN_ROWS.map(r => (
                  <tr key={r}><td>{r}</td>{FIN_COLS.map(c => (
                    <td key={c}>{finCell(r, c)
                      ? <span style={{ fontSize: 11, color: "var(--text-tertiary)" }}>—</span>
                      : ""}</td>
                  ))}</tr>
                ))}
              </tbody>
            </table>
          </div>
          {/* Every control on this tab is an uncontrolled defaultValue — there is
              nothing to persist and no endpoint to persist it to, so SAVE is
              disabled rather than toasting a save that never ran. */}
          {/* NO TAB-WIDE SAVE, and that is the honest shape rather than a
              missing feature. The commission select writes itself through
              set_commission_profile() the moment Apply is pressed — one RPC
              with its own rules, which is why it cannot be folded into a
              "save everything on this tab" button that would have to decide
              what to do when one half succeeds. Everything else here has no
              table at all; a SAVE above them would be a button whose only
              honest label is "save the one control that already saved". */}
          <HuNoBackendNote>
            The commission profile above saves itself. Nothing else on this tab has a table in
            this schema: per-user product toggles (Enable Sport / Casino / …), the tawk chat
            code, <code>master_settings</code> and the FINANCIAL matrix have no columns
            anywhere. Each now reads &ldquo;not stored&rdquo; rather than rendering a switch —
            a switch shows a POSITION, and the FINANCIAL matrix was rendering every cell
            already ON, which asserted that the account may take deposits, withdrawals and
            credit in every category. The real permission matrix is 040&rsquo;s, written
            through <code>set_user_permission</code> from the Support users screen.
            {/* <!-- SUGGESTION: model per-user product enablement, master_settings and the financial permission matrix. isystem stores them as users columns, MasterSetting rows and permissions/vpermissions; none exists here, so four blocks of this tab can only ever be decoration. --> */}
          </HuNoBackendNote>
        </div>
      )}

      {tab === "providers" && (
        <div>
          {/* THE FOUR BULK BOXES ARE NAMED FROM game_categories, not typed out.
              They used to be four hardcoded labels — Casino / Sport / Virtual /
              Casino live — which is a list of the categories this platform had
              on the day somebody wrote them, in a form that fans a cost across
              whatever is in each. A category added later would silently have no
              box, and a renamed one would keep the old label above the new
              set. */}
          <div className="rpt-filters" style={{ alignItems: "flex-end" }}>
            {(catFeed.data || []).map(c => (
              <div className="rpt-field" style={{ flex: 1, minWidth: 150 }} key={c.id}>
                <label>Set costs {c.name}</label>
                <input className="input" style={{ width: "100%" }} placeholder="% cost"
                  value={bulk[c.id] || ""}
                  onChange={e => setBulk(b => Object.assign({}, b, { [c.id]: e.target.value }))} />
              </div>
            ))}
          </div>
          <div style={{ marginBottom: 14, display: "flex", gap: 10, flexWrap: "wrap" }}>
            {/* ONE CALL PER CATEGORY, and each is one statement server-side.
                Doing this row-by-row from the browser would be N round trips
                with no transaction between them — an interruption leaves half
                the catalogue on the new rate and half on the old, which on a
                cost table is an invoice nobody can reconstruct. */}
            <button className="rpt-btn rpt-btn--blue" style={{ width: 320, maxWidth: "100%" }}
              disabled={pvsave.busy || !Object.keys(bulk).some(k => String(bulk[k]).trim() !== "")}
              onClick={applyBulkCosts}>
              {pvsave.busy ? "APPLYING…" : "Set costs for all categories"}
            </button>
          </div>
          {catFeed.error && <HrsError error={catFeed.error} onRetry={catFeed.retry} />}

          <HrsAsync state={rateFeed} skeletonRows={8} skeletonCols={6}>
            {() => (
          <div className="panel" style={{ overflow: "hidden" }}><div style={{ overflowX: "auto", maxHeight: "calc(100vh - 380px)" }}>
            <table className="data-table hp-list">
              <thead><tr><th>Provider</th><th>Integration</th><th>% cost parent</th><th>% cost</th><th>Extra Fee %</th><th>Hand Fee</th></tr></thead>
              <tbody>
                {providers.length === 0 && <tr><td colSpan={6} style={{ padding: "26px", textAlign: "center", color: "var(--text-tertiary)" }}>No providers in the catalogue.</td></tr>}
                {providers.map(p => {
                  const row = rateBy[p.id] || {};
                  const parentRow = parentRateBy[p.id];
                  const cell = (field) => (
                    <input className="input" style={{ width: 120 }}
                      value={pvDraft[p.id] && pvDraft[p.id][field] !== undefined
                        ? pvDraft[p.id][field]
                        : (row[field] == null ? "" : String(row[field]))}
                      onChange={e => setPvDraft(d => Object.assign({}, d, {
                        [p.id]: Object.assign({}, d[p.id], { [field]: e.target.value }),
                      }))} />
                  );
                  return (
                    <tr key={p.id}>
                      <td style={{ fontWeight: 700, color: "var(--p-700)" }}>{p.name}</td>
                      <td><span className="hu-integ">{p.integrationId}</span></td>
                      {/* THE PARENT'S RATE, READ-ONLY AND REALLY THE PARENT'S.
                          It is what this account is charged from above, so it
                          is the floor a sensible cost sits over — an editable
                          box here would write this user's own row twice. A dash
                          means the parent has no rate on file, which is a real
                          state and not zero. */}
                      <td style={{ color: "var(--text-tertiary)" }}>
                        {parentRow && parentRow.percentage != null ? Number(parentRow.percentage).toFixed(2) : "—"}
                      </td>
                      <td>{cell("percentage")}</td>
                      <td>{cell("extra_fee")}</td>
                      <td>{cell("hand_fee")}</td>
                    </tr>
                  );
                })}
              </tbody>
            </table>
          </div></div>
            )}
          </HrsAsync>
          <button className="hp-save" onClick={saveProviderRates} disabled={pvsave.busy || !Object.keys(pvDraft).length}>
            {pvsave.busy ? "SAVING…" : `SAVE${Object.keys(pvDraft).length ? ` (${Object.keys(pvDraft).length} changed)` : ""}`}
          </button>
          <HuNoBackendNote>
            Saves only the rows you edited, one <code>set_user_provider_rate()</code> call each —
            an empty cell means &ldquo;leave it&rdquo;, not zero, so a fee you never touched is not
            wiped. The RPC carries the guard an allowlist entry would have switched off:
            007&rsquo;s <code>me.id &lt;&gt; u.id</code>, which stops an account raising its own
            revenue share and which upstream does not have at all.
          </HuNoBackendNote>
        </div>
      )}

      {/* Deposit tab. The real /users/{id}/deposit re-includes the shared
          Transfer panel (TransferController::index) with the payer side hidden.
          The prototype's shared panel is the Deposit screen (route host-deposit
          → /deposit), but it runs its own account directory and cannot be pinned
          to this user — embedding it would offer a transfer against the WRONG
          account. So: the stub form's submit is disabled with the endpoint it
          needs, and a real cross-link opens the shared screen. */}
      {tab === "deposit" && (
        <div className="panel" style={{ padding: 20 }}>
          <div className="page__title" style={{ color: "var(--p-700)", fontSize: 22, marginBottom: 16 }}>Transfer</div>
          <div className="hu-tabgates" style={{ marginBottom: 14 }}>
            This tab is a stub of the shared Transfer panel — no user-type / target picker, and it cannot post a transfer. The prototype's working Transfer screen is a separate page:
            <button className="rpt-btn rpt-btn--search" style={{ minWidth: 0, height: 32, fontSize: 12, marginLeft: 8, padding: "0 10px" }} onClick={() => huNavTransfer(user.id)}
              title="Opens the prototype's Transfer screen (/deposit). It keeps its own account directory, so this user is not preselected there.">
              <Icon name="wallet" size={12} /> Open Transfer screen
            </button>
          </div>
          <div className="hp-transfer">
            <div className="hp-transfer__bal">
              <div><span>Balance</span><b>{huMoney(user.cur, user.bal)}</b></div>
              <div><span>Credits</span><b>{huMoney(user.cur, user.credits)}</b></div>
              <div style={{ gridColumn: "1 / -1" }}><span>Availability</span><b>{huMoney(user.cur, availability)} <Icon name="refresh" size={12} /></b></div>
            </div>
            <div className="hp-transfer__form">
              {/* All four operations are one RPC with two arguments changed:
                  which way the money goes, and which wallet it comes out of.
                  `credits` is a real wallet in user_balances, not a label. */}
              <label className="form-label">Operation Type</label>
              <select className="select" style={{ width: "100%" }} value={xfer.op}
                onChange={e => setXfer(x => Object.assign({}, x, { op: e.target.value }))}>
                <option value="deposit">Deposit</option>
                <option value="withdraw">Withdraw</option>
                <option value="credit_deposit">Credit Deposit</option>
                <option value="credit_withdraw">Credit Withdraw</option>
              </select>
              <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16, marginTop: 12 }}>
                <div><label className="form-label">Amount</label>
                  <input className="input" placeholder="Amount" style={{ width: "100%" }} value={xfer.amount}
                    onChange={e => setXfer(x => Object.assign({}, x, { amount: e.target.value }))} /></div>
                <div><label className="form-label">Reason</label>
                  <input className="input" placeholder="Reason" style={{ width: "100%" }} value={xfer.reason}
                    onChange={e => setXfer(x => Object.assign({}, x, { reason: e.target.value }))} /></div>
              </div>
              <button className="rpt-btn rpt-btn--blue" style={{ width: "100%", marginTop: 16 }}
                disabled={xsave.busy || !Number(xfer.amount)}
                onClick={doTransfer}>
                {xsave.busy ? "TRANSFERRING…" : "TRANSFER"}
              </button>
              <HuNoBackendNote>
                One call to <code>post_transfer()</code>: the debit, the credit and both ledger
                entries land in one transaction, with the two balance rows locked in ascending
                user id so opposing transfers queue instead of deadlocking. The idempotency key
                describes the transfer itself, so a double-click is one payment.
              </HuNoBackendNote>
            </div>
          </div>
        </div>
      )}

      {tab === "logs" && (
        <div className="panel" style={{ overflow: "hidden" }}>
          {/* login_events records successful logins only — nothing upstream
              writes a failed attempt, so an empty list means "has not signed in
              since the table started recording", not "no failures". */}
          <HrsAsync state={logFeed} skeletonRows={6} skeletonCols={2}
                    empty="No logins recorded for this user.">
            {() => (
          <table className="data-table hp-list"><thead><tr><th>Date</th><th>IP</th></tr></thead>
            <tbody>
              {logs.map((l, i) => <tr key={i}><td>{hpDate(l.ts)}</td><td style={{ fontFamily: "ui-monospace, Menlo, Consolas, monospace", fontSize: 12 }}>{l.ip}</td></tr>)}
            </tbody>
          </table>
            )}
          </HrsAsync>
        </div>
      )}

      {tab === "home" && (
        <div>
          <button className="hp-save" onClick={saveUser} disabled={usave.busy}>
            {usave.busy ? "SAVING…" : "SAVE"}
          </button>
          {/* What this saves, said plainly — the same note the Players editor
              carries, and for the same reason: the tab holds more controls than
              the button writes, and silence about which is which is how a
              screen starts implying it saved something it did not. */}
          <HuNoBackendNote>
            Saves name, lastname, email, mobile, gender, birthday, country, province, city,
            address, zip, the document fields and the timezone. NOT saved by this button:
            the Blocked / Cash block / Test user switches, which write immediately and
            (for Blocked) open or close the audit row — blocking somebody is an action, not
            a field edit waiting for a submit. Currency is read-only, the subnet group and
            subnet block have no column, and the password lives with the auth provider
            rather than in <code>users</code>.
          </HuNoBackendNote>
        </div>
      )}
    </div>
  );
};

/* ---------------- filter fields (shared: hero strip ⇄ mobile sheet) ---------------- */
const HuFilterFields = ({ f, setFilter, deleted, rows, skins, profiles, results, total }) => {
  const parentOpts = rows.filter(r => [2, 8, 10, 15, 20].indexOf(r.lvl) >= 0);
  return (
    <React.Fragment>
      <HuHeroCard icon="search" label="ID" tip="Exact match on users.id (server applies `=`, not LIKE).">
        <input className="filter-hero__input" value={f.id} onChange={e => setFilter({ id: e.target.value })} placeholder="Exact ID" />
      </HuHeroCard>
      <HuHeroCard icon="user" label="Username" tip="LIKE %…% on users.username.">
        <input className="filter-hero__input" value={f.username} onChange={e => setFilter({ username: e.target.value })} placeholder="Contains…" />
      </HuHeroCard>
      <HuHeroCard icon="user" label="Name" tip="LIKE %…% on users.firstname. The Name / Lastname / Email columns exist in the data feed but ship hidden by default (ajax.js) — they stay searchable here.">
        <input className="filter-hero__input" value={f.name} onChange={e => setFilter({ name: e.target.value })} placeholder="Contains…" />
      </HuHeroCard>
      <HuHeroCard icon="user" label="Lastname">
        <input className="filter-hero__input" value={f.lastname} onChange={e => setFilter({ lastname: e.target.value })} placeholder="Contains…" />
      </HuHeroCard>
      <HuHeroCard icon="mail" label="Email">
        <input className="filter-hero__input" value={f.email} onChange={e => setFilter({ email: e.target.value })} placeholder="Contains…" />
      </HuHeroCard>
      <HuHeroCard icon="grid" label="Skin" tip="Rendered only for superadmin / Customer Care viewers; options come from $user->getSkins().">
        <select className="filter-hero__select" value={f.skin} onChange={e => setFilter({ skin: e.target.value })}>
          <option value="ALL">Select</option>
          {skins.map(s => <option key={s.id}>{s.name}</option>)}
        </select>
      </HuHeroCard>
      <HuHeroCard icon="users" label="User Type" tip={`usersLevels() minus Customer Care(4) / Administration(6) / Affiliate(1). Player(30) appears only in the deleted view. A Customer Care viewer also loses levels below their own.${deleted ? " (deleted view: Player included)" : ""}`}>
        <select className="filter-hero__select" value={f.role} onChange={e => setFilter({ role: e.target.value })}>
          <option value="ALL">- Select -</option>
          {HU_FILTER_ROLES(deleted).map(r => <option key={r.lvl} value={r.lvl}>{r.name} ({r.lvl})</option>)}
        </select>
      </HuHeroCard>
      <HuHeroCard icon="percent" label="Commissions profile" tip="Filters the hidden profilo_provvigionale column (rendered with visible:false — a filter-target only).">
        <select className="filter-hero__select" value={f.profile} onChange={e => setFilter({ profile: e.target.value })}>
          <option value="ALL">- Select -</option>
          {profiles.map(p => <option key={p.id}>{p.name}</option>)}
        </select>
      </HuHeroCard>
      <HuHeroCard icon="users" label="Parent" tip="select2 AJAX feed → route admin.users.search (/users2), user_types [0,2,8,10,15,20], scoped hierarchy-safe via Auth::user()->getChilds(true).">
        <select className="filter-hero__select" value={f.parent} onChange={e => setFilter({ parent: e.target.value })}>
          <option value="ALL">- Select -</option>
          <option value="1">admin</option>
          {parentOpts.map(p => <option key={p.id} value={p.id}>{p.username}</option>)}
        </select>
      </HuHeroCard>
      <HuHeroCard icon="calendar" label="Last access" span2 tip="Single date-range picker feeding two hidden inputs (from|to) → last_login BETWEEN / >= / <=. The server also accepts a data_creazione (addedTime) range this page has no UI for.">
        <div style={{ display: "flex", gap: 6, alignItems: "center" }}>
          <input className="filter-hero__input" type="date" value={f.lastFrom} onChange={e => setFilter({ lastFrom: e.target.value })} />
          <span style={{ color: "var(--text-tertiary)", fontSize: 11 }}>—</span>
          <input className="filter-hero__input" type="date" value={f.lastTo} onChange={e => setFilter({ lastTo: e.target.value })} />
        </div>
      </HuHeroCard>
      {deleted && (
        <HuHeroCard icon="trash" label="Deleted by" tip="Deleted view + superadmin only — same select2 source as Parent; filters deleted_users.deleted_by.">
          <select className="filter-hero__select" value={f.deletedBy} onChange={e => setFilter({ deletedBy: e.target.value })}>
            <option value="ALL">- Select -</option>
            {["admin", "jugayganaadmin", "win24hsadmin", "tucasinoAdmin"].map(u => <option key={u}>{u}</option>)}
          </select>
        </HuHeroCard>
      )}
      <div className="filter-hero__card filter-hero__card--result">
        <div className="filter-hero__label"><Icon name="chart" size={11} /> Results
          <Tip>With ZERO filters set, only direct children of the current parent are listed; setting ANY filter searches the whole subtree via user_path LIKE (getUsersList L948-967). Fixed constraints always apply: levels 4/6/1 excluded, id &gt; 1, deleted = 0{deleted ? "" : ", user_level < 30"}.</Tip>
        </div>
        <div className="filter-hero__value">{results.toLocaleString()}<span className="filter-hero__value-sub">of {total.toLocaleString()}</span></div>
      </div>
    </React.Fragment>
  );
};

/* ---------------- Users list ---------------- */
const HU_EMPTY_FILTERS = { id: "", name: "", lastname: "", email: "", username: "", skin: "ALL", role: "ALL", profile: "ALL", parent: "ALL", lastFrom: "", lastTo: "", deletedBy: "ALL" };

const HostUsers = ({ brand }) => {
  window.useLocale && window.useLocale();
  const data = useHuData();
  const save = useHrsSave(data);
  const base = data.rows;
  const baseDeleted = data.deleted;
  const isMobile = useHuMobile();

  const [mode, setMode] = useStateHU("active"); // "active" = GET /users · "deleted" = GET /deleted_users
  /* The root of the tree is whoever is signed in — RLS shows nothing above
     them, so a hardcoded "admin at /1" root would be an empty first page for
     every operator who is not that user. */
  const [crumbs, setCrumbs] = useStateHU([]);
  useEffectHU(() => {
    if (!data.me) return;
    setCrumbs(c => (c.length ? c : [{ id: data.me.id, username: data.me.username, path: data.me.path }]));
  }, [data.me]);
  const [f, setF] = useStateHU(HU_EMPTY_FILTERS);
  const [sort, setSort] = useStateHU({ col: "id", dir: "desc" }); // default order: ID desc (ajax.js L91)
  const [pageLen, setPageLen] = useStateHU(50);                    // lengthMenu [5,10,25,50], default 50
  const [page, setPage] = useStateHU(0);
  const [selected, setSelected] = useStateHU(null);
  const [blockLog, setBlockLog] = useStateHU({});         // id -> attempted block actions, this session only
  const [modal, setModal] = useStateHU(null);
  const [sheetOpen, setSheetOpen] = useStateHU(false);
  const [expanded, setExpanded] = useStateHU(null);       // mobile card expand

  const allRows = base;
  const byId = useMemoHU(() => { const m = {}; allRows.forEach(r => { m[r.id] = r; }); return m; }, [allRows]);

  /* No local override layer any more: users.blocked / users.cash_blocked are
     the answer, and until the write path exists a toggle cannot change them. */
  const blockOf = (r) => ({ cash: r.cashBlock, user: r.userBlock });
  // Subnet block — an ancestor with an active user block disables the row's own
  // toggle and shows the red "Subnet block" badge (checkUserBlock, L1152-1165).
  // Only ancestors VISIBLE to this operator can be checked: RLS stops the list
  // at the caller's own subtree, so an ancestor above that root is not in byId
  // and its block cannot be seen from here. That is a limit of the view, not a
  // silent "not blocked" — the real check runs server-side on every request.
  const huSubnetBlocked = (r) => {
    const ids = huPathIds(r.path).slice(0, -1);
    return ids.some(id => { const a = byId[id]; return a && blockOf(a).user; });
  };

  const setFilter = (p) => { setF(s => ({ ...s, ...p })); setPage(0); };
  const clearFilters = () => { setF(HU_EMPTY_FILTERS); setPage(0); };
  const deleted = mode === "deleted";
  /* Until sb.me() answers, there is no root and therefore no "direct children
     of the current parent" to list. A placeholder crumb would list nothing and
     look like an empty network. */
  const crumb = crumbs[crumbs.length - 1] || { id: null, username: "", path: "" };

  const anyFilter = f.id || f.name || f.lastname || f.email || f.username || f.skin !== "ALL" || f.role !== "ALL" || f.profile !== "ALL" || f.parent !== "ALL" || f.lastFrom || f.lastTo || (deleted && f.deletedBy !== "ALL");

  const matches = (r) => {
    if (f.id && String(r.id) !== f.id.trim()) return false;                                   // exact `=`
    if (f.username && huNorm(r.username).indexOf(huNorm(f.username)) < 0) return false;       // LIKE
    if (f.name && huNorm(r.firstname).indexOf(huNorm(f.name)) < 0) return false;
    if (f.lastname && huNorm(r.lastname).indexOf(huNorm(f.lastname)) < 0) return false;
    if (f.email && huNorm(r.email).indexOf(huNorm(f.email)) < 0) return false;
    if (f.skin !== "ALL" && r.skin !== f.skin) return false;
    if (f.role !== "ALL" && r.lvl !== Number(f.role)) return false;
    if (f.profile !== "ALL" && r.profile !== f.profile) return false;
    if (f.parent !== "ALL") {
      const pname = f.parent === "1" ? "admin" : (byId[Number(f.parent)] ? byId[Number(f.parent)].username : f.parent);
      if (deleted ? r.parent !== pname : r.parentId !== Number(f.parent)) return false;
    }
    if (f.lastFrom && (!r.last || r.last < huParse(f.lastFrom + " 00:00"))) return false;
    if (f.lastTo && (!r.last || r.last > huParse(f.lastTo + " 23:59"))) return false;
    if (deleted && f.deletedBy !== "ALL" && r.deletedBy !== f.deletedBy) return false;
    return true;
  };

  const pool = useMemoHU(() => deleted ? baseDeleted : allRows, [deleted, baseDeleted, allRows]);
  const filtered = useMemoHU(() => {
    if (deleted) return pool.filter(matches);
    // Scope rule (getUsersList L948-967): zero filters → direct children of the
    // current parent only; any filter → whole subtree via user_path LIKE.
    if (!anyFilter) return pool.filter(r => r.parentId === crumb.id);
    return pool.filter(r => huIsDescendantOf(r.path, crumb.path) && matches(r));
  }, [pool, deleted, anyFilter, crumb, f]);

  // Server honors ORDER BY for id / credits / bonus / username / last_login only
  // (L893-937) — credits & bonus sort cases target columns this table no longer
  // shows. Client disables ordering on actions/lastname/firstname/email.
  const sorted = useMemoHU(() => {
    const dir = sort.dir === "asc" ? 1 : -1;
    const s = [...filtered];
    s.sort((a, b) => {
      if (sort.col === "username") return a.username.localeCompare(b.username) * dir;
      if (sort.col === "last") return (((a.last || 0) - (b.last || 0)) || (a.id - b.id)) * dir;
      return (a.id - b.id) * dir;
    });
    return s;
  }, [filtered, sort]);

  const pages = Math.max(1, Math.ceil(sorted.length / pageLen));
  const pageSafe = Math.min(page, pages - 1);
  const pageRows = sorted.slice(pageSafe * pageLen, pageSafe * pageLen + pageLen);

  // Every remaining caller confirms something that genuinely happened (a row
  // blocked, deleted, created, a CSV downloaded) and supplies its own detail —
  // the fallback stays neutral so no future caller inherits a claim.
  const toast = (m, extra) => window.PAYBO && window.PAYBO.emitToast && window.PAYBO.emitToast({ id: `u-${Date.now()}-${Math.random()}`, tx_id: m, amount: 0, currency: "HOST", player: "Users", reason: extra || "Prototype state only." });

  const drill = (r) => {
    // Subnet drill-down (GET /users/?parent_id={id}) — not for SHOP rows,
    // affiliates, or the deleted view (L1010-1016).
    if (deleted || r.lvl === 20) return;
    setCrumbs(c => [...c, { id: r.id, username: r.username, path: r.path }]);
    setPage(0); setExpanded(null);
  };

  /* A user block is TWO facts: the flag on users, and the audited row in
     user_blocks with who did it and why. isystem writes only the flag on
     unblock and leaves nothing behind, so "who reopened this account" has no
     answer there. Both are written here, flag first: if the audit row fails the
     account is still blocked, which is the safe direction to fail in.
     <!-- SUGGESTION: make this one RPC so the flag and the audit row cannot diverge. Two writes from a browser can always be interrupted between them. --> */
  const applyBlock = async (row, kind, next, note) => {
    setModal(null);
    const column = kind === "cash" ? "cash_blocked" : "blocked";
    const res = await save.run(() => window.sb.update("users", row.id, { [column]: next }), {
      done: `${kind === "cash" ? "Cash" : "User"} ${next ? "block" : "unblock"} saved for ${row.username}`,
      fail: `${row.username} was NOT ${next ? "blocked" : "unblocked"}`,
    });
    if (!res || !res.ok) return;
    /* The audit row only covers the full user block; a cash block is a
       narrower flag with no history table upstream or here. */
    if (kind !== "user") return;
    const me = data.me;
    if (next) {
      await save.run(() => window.sb.create("userBlocks", {
        user_id: row.id, blocked_by: me ? me.id : null, reason: note || null,
      }), { done: "Block recorded", fail: "The account is blocked, but the audit row was not written" });
    } else {
      const open = await window.sb.list("userBlocks", { limit: 1, filters: { user: row.id, open: "1" } });
      const b = open && open.ok && open.data && open.data[0];
      if (b) {
        await save.run(() => window.sb.update("userBlocks", b.id, {
          unblocked_by: me ? me.id : null, unblocked_at: new Date().toISOString(),
        }), { done: "Unblock recorded", fail: "The account is unblocked, but the audit row was not closed" });
      }
    }
    setBlockLog(l => ({ ...l, [row.id]: [] }));
  };

  const doDelete = (row) => {
    // GET /users/delete/{id}/ → User::boot() deleting hook: the full row is copied
    // into deleted_users (old_user_id + deleted_by), balance history is purged and
    // the users row is HARD-deleted. No restore exists anywhere ("no invented
    // actions" — none is offered here either).
    setModal(null);
    /* A soft delete: users carries deleted_at, so the row stays and every
       foreign key that points at it keeps resolving. isystem copies the row
       into a 100-column parallel table and hard-deletes the original, which is
       why its two schemas drift and its export returns live users. */
    save.run(() => window.sb.remove("users", row.id),
      { done: `${row.username} deleted`, fail: `${row.username} was NOT deleted` });
  };

  /* CREATING A USER IS A HIERARCHY OPERATION, NOT A FIELD EDIT.
     `path` is derived from the parent's and `id` comes from an identity column,
     so neither can be supplied from here — 013's trigger refuses any later
     attempt to change them, and `path` is NOT NULL, so a plain insert cannot
     work at all. That is why `users` carries no `insert` route on this screen.

     This used to stop at that sentence and raise a notice saying a server-side
     create was needed. The notice was stale, not the schema: create_user()
     (supabase/015, rule rewritten in 017) is exactly that create. It takes the
     id from the sequence first and writes `path` correct in the single statement
     that inserts the row, and before it does, it asserts a verified second
     factor, refuses an impersonating session, requires the parent to be inside
     the caller's subtree, and asks role_creation whether an actor at this level
     may create that level in this skin at all.

     WHAT THIS CALL DOES NOT CARRY, listed because the form collects it:
       · password — `users` has no credential column. Logins live in Supabase
         auth and are attached afterwards with link_auth_user() (032), which is
         why create_user() takes p_auth_user_id and not a password. The account
         is real and inside the hierarchy; it cannot sign in yet.
       · the residence/document block — first name, last name, email and mobile
         are the only personal columns `users` has.
       · the Master subnet matrix and its commission profile — master_settings
         has no write path in this prototype, so those rows are not written.
       · api_token, and the promoter_code upstream generates for a new Shop.
     The modal states all of it before the operator submits, so nothing is
     silently dropped.
     <!-- SUGGESTION: an operator-created account cannot sign in. Closing that needs a service-role step create_user() cannot do from the browser — mint the auth user, then link_auth_user() it — so it belongs in an Edge Function that wraps both in one call. Until it exists, "create a Shop" and "give the Shop a login" are two jobs and only one of them is on this screen. -->
     <!-- SUGGESTION: a new Shop gets no promoter_code, and promoter_code is what resolves a player's parent shop at registration (supabase/023). Generate one inside create_user() when p_user_level = 20, where the id it should be derived from already exists. -->
     <!-- SUGGESTION: give master_settings a write path so the New Master form's commission profile and subnet-creation matrix are stored with the account instead of being collected and dropped. --> */
  const doCreate = async ({ def, values, parent, skin }) => {
    /* Upstream forces parent_id = 1 for Skin Access and Regulation User. The
       signed-in operator is used instead: create_user() requires the parent to
       be inside the caller's subtree, and a super admin whose own path is not
       `1` would be refused by a hardcoded 1. `me` is also the root of everything
       this screen can see, so it is the same row the breadcrumb starts at.
       A divergence, and deliberate — the field in the modal says so too. */
    const p = def.parentLevels ? parent : data.me;
    if (!p || !p.id) {
      return { ok: false, error: { kind: "auth", retriable: false, message:
        "No parent to create under: the signed-in operator has not resolved yet, and create_user() derives the new row's path from the parent." } };
    }

    /* The form splits the phone into a prefix select and a number; the column is
       one text field, so they are rejoined rather than one half being dropped. */
    const mobPre = String(values.mobile_prefix || "").trim();
    const mobNum = String(values.mobile || "").trim();
    const mobile = mobNum ? (mobPre ? `${mobPre} ${mobNum}` : mobNum) : null;
    const username = String(values.username || "").trim();

    const res = await save.run(() => window.sb.createUser({
      parentId: p.id,
      userLevel: def.lvl,
      username,
      email: (values.email || "").trim() || null,
      firstname: (values.firstname || "").trim() || null,
      lastname: (values.lastname || "").trim() || null,
      mobile,
      /* No currency is sent. The form has no currency field, and create_user()
         inherits the parent's — sending "" or a guess would be this screen
         choosing what money the account is denominated in. */
      currency: null,
      /* Only the two forms that ask for a skin name one; everywhere else null
         means "inherit the parent's tenant", which is what upstream does too. */
      skinId: skin ? skin.id : null,
    }), {
      done: `${username} created — ${huRoleName(def.lvl)} under ${p.username} · no login yet`,
      fail: `${username} was NOT created`,
    });

    /* save.run has already refetched the list, so the new row is on screen
       before the modal closes over it. Nothing is pushed into local state: a
       row invented here would carry a path this screen guessed, which is the
       whole reason the insert is an RPC. */
    if (!res || !res.ok) return res;
    setModal(null);

    /* THE SECOND WRITE, AND IT IS REPORTED HONESTLY. create_user() takes
       identity and tree position only; the residence and document fields this
       form collects live in 028's columns, opened by 051. They were gathered
       and dropped — the form validated an address and threw it away.

       A partial is possible: the account exists and the address did not land.
       Said out loud, because re-submitting a half-succeeded form makes a SECOND
       operator, and the fix is to open the row and save. */
    const nid = res.data && (res.data.id || (res.data[0] && res.data[0].id));
    const nz = (x) => (String(x == null ? "" : x).trim() || null);
    const profile = {
      /* Street and house number are two boxes and one column. */
      address: [nz(values.address_residence), nz(values.address_house_number)].filter(Boolean).join(" ") || null,
      postcode: nz(values.zip_residence),
      province: nz(values.province_residence) || nz(values.province_text_residence),
      city: nz(values.city_residence) || nz(values.city_text_residence),
      document_number: nz(values.document_number),
      document_type: nz(values.document_type),
    };
    /* NOT SENT: province_birth / city_birth (one set of location columns, and
       residence already claims it — writing both would have the second silently
       overwrite the first) and fiscal_code (no column; document_number is the
       number ON the document, a different fact). Both are marked on the form. */
    if (!nid || !Object.keys(profile).some(k => profile[k] != null)) return res;
    const p2 = await window.sb.update("users", nid, profile);
    if (!p2 || !p2.ok) {
      toast(`${username}: created, address and document NOT saved`,
            "The account exists — do not submit this form again or there will be two. Open the user and save the Home tab to store the rest.");
    }
    return res;
  };

  const excelExport = () => {
    // XLSX → POST /users/excel (admin.users.export) → User::exportList (Spout,
    // mysql_ro, ≤ EXPORT_WEB_LIMIT rows direct download else queued GenericExport
    // + email link). Prototype ships CSV of the FULL filtered set.
    // Known bug #1 (deleted view): excelExport() in ajax.js never sends the
    // `deleted` flag, so the real deleted screen exports LIVE users. Evident
    // intent implemented: this exports the rows on screen (deleted rows included).
    // <!-- SUGGESTION: pass the deleted flag through excelExport() so POST /users/excel exports deleted_users rows on the deleted screen. -->
    // Known bug #2: the real export reuses users.balance for the "Subnet Balance"
    // column (no real subnet calc). Evident intent implemented: the computed
    // subnet balance is exported.
    // <!-- SUGGESTION: compute getSubnetBalance() (or drop the column) in User::exportList instead of duplicating users.balance. -->
    const head = deleted
      ? ["ID", "Old user id", "Username", "Role", "Deleted by", "Deleted at", "Skin", "Parent", "Balance", "Last access", "Cash block", "User block"]
      : ["ID", "Username", "Role", "Skin", "Parent", "Balance", "Subnet Balance", "Last access", "Cash block", "User block"];
    const lines = [head.join(",")];
    sorted.forEach(r => {
      const eff = deleted ? { cash: r.cashBlock, user: r.userBlock } : blockOf(r);
      const cells = deleted
        ? [r.id, r.oldId, r.username, huRoleName(r.lvl), r.deletedBy, hpDate(r.deletedAt), r.skin, r.parent, r.bal, r.last ? hpDate(r.last) : "-", eff.cash ? "Yes" : "No", eff.user ? "Yes" : "No"]
        : [r.id, r.username, huRoleName(r.lvl), r.skin, r.parent, r.bal, r.sub === null ? "" : r.sub, r.last ? hpDate(r.last) : "-", eff.cash ? "Yes" : "No", eff.user ? "Yes" : "No"];
      lines.push(cells.map(c => `"${String(c).replace(/"/g, '""')}"`).join(","));
    });
    try {
      const blob = new Blob([lines.join("\n")], { type: "text/csv" });
      const a = document.createElement("a");
      a.href = URL.createObjectURL(blob);
      a.download = deleted ? "deleted_users_export.csv" : "users_export.csv";
      a.click();
      URL.revokeObjectURL(a.href);
    } catch (_e) {}
    toast("POST /users/excel", `${sorted.length} filtered rows exported (server caps at EXPORT_WEB_LIMIT, then queues GenericExport + email link).`);
  };

  // PDF is the real screen's client-side DataTables/pdfmake button (block toggles
  // export as Yes/No, last column skipped). No PDF generator is bundled in this
  // prototype, so the button documents itself instead of announcing a file that
  // was never produced. Excel, beside it, genuinely downloads a CSV.
  // Real behavior covers only the CURRENT PAGE — a marked bug; evident intent is
  // the filtered set, which is what a wired implementation should feed it.
  // <!-- SUGGESTION: feed the pdfmake button the full filtered result set (or server-render the PDF) instead of the current DataTables page. -->

  if (selected) return <HostUserEdit user={selected} skins={data.skins} profiles={data.profiles} providers={data.providers} me={data.me} onBack={() => {
    try { if (window.location.pathname !== "/users") window.history.pushState(null, "", "/users"); } catch (_e) {}
    setSelected(null);
  }} />;

  const activePills = [];
  if (f.id) activePills.push(["ID = " + f.id, () => setFilter({ id: "" })]);
  if (f.username) activePills.push([`Username ~ "${f.username}"`, () => setFilter({ username: "" })]);
  if (f.name) activePills.push([`Name ~ "${f.name}"`, () => setFilter({ name: "" })]);
  if (f.lastname) activePills.push([`Lastname ~ "${f.lastname}"`, () => setFilter({ lastname: "" })]);
  if (f.email) activePills.push([`Email ~ "${f.email}"`, () => setFilter({ email: "" })]);
  if (f.skin !== "ALL") activePills.push(["Skin: " + f.skin, () => setFilter({ skin: "ALL" })]);
  if (f.role !== "ALL") activePills.push(["Type: " + huRoleName(Number(f.role)), () => setFilter({ role: "ALL" })]);
  if (f.profile !== "ALL") activePills.push(["Profile: " + f.profile, () => setFilter({ profile: "ALL" })]);
  if (f.parent !== "ALL") activePills.push(["Parent: " + (byId[Number(f.parent)] ? byId[Number(f.parent)].username : "admin"), () => setFilter({ parent: "ALL" })]);
  if (f.lastFrom || f.lastTo) activePills.push([`Last access ${f.lastFrom || "…"} — ${f.lastTo || "…"}`, () => setFilter({ lastFrom: "", lastTo: "" })]);
  if (deleted && f.deletedBy !== "ALL") activePills.push(["Deleted by: " + f.deletedBy, () => setFilter({ deletedBy: "ALL" })]);

  const sortIcon = (col) => sort.col === col ? <Icon name={sort.dir === "asc" ? "arrow_up" : "arrow_down"} size={10} /> : <Icon name="sort" size={10} style={{ opacity: .4 }} />;
  const clickSort = (col) => { setSort(s => ({ col, dir: s.col === col && s.dir === "desc" ? "asc" : "desc" })); setPage(0); };

  const renderBlockCell = (r, kind) => {
    if (deleted) {
      // Deleted view — the real screen still renders LIVE toggles here, wired to the
      // deleted_users PK, which collides with a different live user's id if clicked
      // (a marked bug). Evident intent: a read-only state chip.
      // <!-- SUGGESTION: render block states read-only on /deleted_users (or wire them to old_user_id and guard against reuse). -->
      const on = kind === "cash" ? r.cashBlock : r.userBlock;
      return <span className={`chip ${on ? "chip--err" : "chip--neutral"}`}>{on ? "Blocked" : "—"}</span>;
    }
    const eff = blockOf(r);
    const on = kind === "cash" ? eff.cash : eff.user;
    const subBlocked = kind === "user" && huSubnetBlocked(r);
    return (
      <span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
        <Toggle size="sm" onLabel="" offLabel="" value={on} disabled={subBlocked}
          onChange={(next) => setModal({ type: "block", row: r, kind, next })} />
        {subBlocked && <span className="hu-subblock" title="An ancestor holds an active user block — this row's toggle is disabled (checkUserBlock).">Subnet block</span>}
        <button className="hu-iconbtn" title="Block-note history (GET /infoblock/)" onClick={() => setModal({ type: "history", row: r })}><Icon name="info" size={12} /></button>
      </span>
    );
  };

  const HU_2FA_MANDATORY_LEVELS = [0, 1, 4, 6, 9]; // source of truth: 068/072
  /* From the shared hook, not a local feed — the first version fetched in
     useHuData and read `dis2fa` here, one component down, and every route
     crashed on a ReferenceError the moment rows rendered. Smoke pass 2 caught
     it; the signed-out pass cannot, because no rows ever render there. */
  const dis2faSet = new Set(data.twofaDisabledIds || []);
  const renderActions = (r) => deleted ? null : (
    <div className="hp-list-actions">
      <button className="hp-act hp-act--edit" title={`Edit (GET /users/${r.id}/)`} onClick={() => { const eff = blockOf(r); setSelected({ ...r, cashBlock: eff.cash, userBlock: eff.user }); }}><Icon name="edit" size={13} /></button>
      {/* View as. Rendered only for a super admin looking at somebody who is
          not one — the same two conditions begin_impersonation() enforces
          server-side, mirrored here so the button is not offered where it
          would only ever fail. The server is the boundary; this is manners. */}
      {huMayImpersonate(data.me, r) && (
        <button className="hp-act" title="View the platform as this user (read only, recorded)"
          onClick={() => setModal({ type: "impersonate", row: r })}><Icon name="eye" size={13} /></button>
      )}
      {/* Offered for everyone: whether this caller may administer THIS account's
          credentials is a subtree-and-role_creation question the server answers,
          and duplicating it here would be a second copy of the rule that could
          drift from the one that runs. The modal shows the refusal it gets. */}
      <button className="hp-act" title="Login — invite, reset, or set a password"
        onClick={() => setModal({ type: "creds", row: r })}><Icon name="lock" size={13} /></button>
      {/* ONE 2FA button (owner ask — three was two too many). A STATUS that
          opens the panel: blue when 2FA is active, light grey when not, red
          kept for the one state that must shout — disabled on a role that
          mandates it. Disable and Reset live inside the panel and are
          clickable only while 2FA is active. */}
      {(() => {
        const active = (data.twofaActiveIds || []).includes(Number(r.id));
        const disMand = dis2faSet.has(Number(r.id)) && HU_2FA_MANDATORY_LEVELS.includes(Number(r.user_level));
        return (
          <button className="hp-act"
            title={disMand ? "2FA is DISABLED on a mandatory-role account — somebody signed this; open for who and why"
                 : active ? "Two-factor is active — open to disable or reset"
                 : "Two-factor is not active on this account"}
            style={disMand ? { color: "#fff", background: "var(--err, #dc2626)" }
                 : active ? { color: "#fff", background: "var(--p-500)" }
                 : undefined}
            onClick={() => setModal({ type: "twofa", row: r })}><Icon name="shield" size={13} /></button>
        );
      })()}
      <button className="hp-act hp-act--danger" title={`Delete (GET /users/delete/${r.id}/) — rendered only for superadmin; server accepts isadmin() || isSkinAdmin()`} onClick={() => setModal({ type: "delete", row: r })}><Icon name="trash" size={13} /></button>
    </div>
  );

  const filtersBlock = <HuFilterFields f={f} setFilter={setFilter} deleted={deleted} rows={allRows} skins={data.skins} profiles={data.profiles} results={sorted.length} total={pool.length} />;

  return (
    <div className="page report-page host-players host-users">
      <div className="page__header" style={{ justifyContent: "space-between", width: "100%", flexWrap: "wrap", gap: 10 }}>
        <div style={{ display: "flex", alignItems: "center", gap: 14, flexWrap: "wrap" }}>
          <div className="page__title" style={{ color: "var(--p-700)" }}>{deleted ? "Deleted users" : "Users"}</div>
          <div className="hu-modeswitch">
            <button className={!deleted ? "active" : ""} onClick={() => { setMode("active"); setPage(0); clearFilters(); }}>Users</button>
            <button className={deleted ? "active" : ""} onClick={() => { setMode("deleted"); setPage(0); clearFilters(); }}>Deleted users</button>
            <Tip>The deleted view is the real GET /deleted_users (admin.users.deleted → deletedIndex() re-runs index() with $deleted = true). Superadmin only — the data endpoint returns "not allowed" for anyone else. It lives in the sidebar under Settings ▾. Labels for backend.old_user_id / backend.deleted_by / backend.deleted_at resolve only to raw keys in the committed lang files — operator-facing labels inferred here.</Tip>
          </div>
        </div>
        {!deleted && (
          <div className="hu-newbtns">
            {/* Header create buttons — hidden for Customer Care entirely; the loop skips
                CC/Administration/Player/Superadmin/Affiliate levels, levels ≤ own, levels
                absent from the governing Master's master_settings (unless superadmin /
                skin admin), and everything when disable_subnet_crud (index.blade L31-65). */}
            {HU_CREATE_DEFS.map(d => (
              <button key={d.key} className="rpt-btn rpt-btn--blue" style={{ minWidth: 0, height: 38, fontSize: 12.5, padding: "0 12px" }} onClick={() => setModal({ type: "create", def: d })}>
                <Icon name="plus" size={12} /> {d.label}
              </button>
            ))}
          </div>
        )}
      </div>

      <Explainer compact title="The network below you — who can see and touch it" bullets={[
        <React.Fragment key="a">Access: sidebar + controller gate <HuGateChip>support_users</HuGateChip> (checked only for Affiliate / Customer Care / Administration levels — everyone else auto-passes); hidden for Shops, regulators, and Customer Care users parented to a Shop.</React.Fragment>,
        <React.Fragment key="b">Skin flags: <HuGateChip>disable_subnet_crud</HuGateChip> kills block toggles and every create/save/edit for non-superadmin; <HuGateChip>enable_export</HuGateChip> gates the Excel button (Customer Care also needs <HuGateChip>support_player_export</HuGateChip>); <HuGateChip>enable_user_unblock</HuGateChip> gates unblocking; <HuGateChip>custom_shop_name</HuGateChip> etc. relabel roles per skin.</React.Fragment>,
        <React.Fragment key="c">A second sidebar entry, `backend.cost`, is shown only to Affiliates and links to this same route — but index() 403s any affiliate unconditionally, so it is a dead link (a leftover of a former subnet-cost view). Represented as this note, not built.</React.Fragment>,
      ]}>
        Every user sits in a tree (user_path). Drill into a subnet by clicking a username; with no filters the list shows only direct children of the current parent — any filter widens the search to the entire subtree.
      </Explainer>

      {isMobile ? (
        <div style={{ display: "flex", gap: 8, marginBottom: 10 }}>
          <button className="hu-filters-btn" onClick={() => setSheetOpen(true)}>
            <Icon name="filter" size={13} /> Filters{activePills.length > 0 && <span className="hu-filters-btn__n">{activePills.length}</span>}
          </button>
          <div className="filter-hero__card filter-hero__card--result" style={{ flex: 1 }}>
            <div className="filter-hero__label"><Icon name="chart" size={11} /> Results</div>
            <div className="filter-hero__value">{sorted.length.toLocaleString()}<span className="filter-hero__value-sub">of {pool.length.toLocaleString()}</span></div>
          </div>
        </div>
      ) : (
        <div className="hu-hero">{filtersBlock}</div>
      )}

      {activePills.length > 0 && (
        <div style={{ display: "flex", gap: 6, flexWrap: "wrap", alignItems: "center", margin: "0 0 10px" }}>
          <span style={{ fontSize: 11, color: "var(--text-tertiary)", textTransform: "uppercase", letterSpacing: ".05em", fontWeight: 600 }}>Active:</span>
          {activePills.map(([label, clear], i) => <HuPill key={i} label={label} onClear={clear} />)}
          <button onClick={clearFilters} style={{ fontSize: 11.5, color: "var(--p-600)", background: "none", border: "none", cursor: "pointer", fontWeight: 600, padding: "4px 8px" }}>Clear all</button>
          {!deleted && <span style={{ fontSize: 11.5, color: "var(--text-tertiary)" }}>· searching the whole subtree of {crumb.username}</span>}
        </div>
      )}

      <div className="hu-toolbar">
        {!deleted ? (
          <div className="hu-crumbs">
            <Icon name="grid" size={13} />
            {crumbs.map((c, i) => (
              <React.Fragment key={c.id}>
                {i > 0 && <Icon name="chevron_right" size={11} style={{ opacity: .5 }} />}
                <button className={i === crumbs.length - 1 ? "active" : ""} disabled={i === crumbs.length - 1}
                  onClick={() => { setCrumbs(cs => cs.slice(0, i + 1)); setPage(0); }}>
                  {c.username}
                </button>
              </React.Fragment>
            ))}
            <Tip>The parent breadcrumb — GET /getUsers/{"{parent_id}"}/. Usernames of non-Shop rows drill one level down (/users/?parent_id={"{id}"}). A Customer Care viewer requesting their own id is silently re-rooted to their skin admin.</Tip>
          </div>
        ) : <div className="hu-crumbs"><Icon name="trash" size={13} /> deleted_users <span style={{ color: "var(--text-tertiary)", fontWeight: 400 }}>— all skins/networks, user_path scoping skipped · no restore exists</span></div>}
        <div style={{ display: "flex", gap: 8 }}>
          <button className="rpt-btn rpt-btn--export" style={{ minWidth: 0, height: 36, fontSize: 12.5 }} onClick={excelExport}>
            <Icon name="download" size={13} /> Excel
          </button>
          <Tip>POST /users/excel (admin.users.export) — gated by the <b>enable_export</b> skin flag; Customer Care also needs <b>support_player_export</b>. Over EXPORT_WEB_LIMIT rows the export is queued (GenericExport) and emailed.</Tip>
          <HuNoBackend className="btn btn--ghost btn--sm" what="PDF export" need="DataTables pdfmake bundle (client-side button on the real screen)">PDF</HuNoBackend>
        </div>
      </div>

      {/* Loading and error are answered before the table, not inside it: an
          empty table under a "No users match your filters" line is the wrong
          answer to both, and the second one costs an afternoon. */}
      {data.loading ? <HrsSkeleton rows={8} cols={9} />
       : data.error ? <HrsError error={data.error} onRetry={data.retry} />
       : isMobile ? (
        <div className="hu-cards">
          {pageRows.length === 0 && <div className="panel" style={{ padding: 26, textAlign: "center", color: "var(--text-tertiary)" }}>No users match your filters.</div>}
          {pageRows.map(r => {
            const eff = deleted ? { cash: r.cashBlock, user: r.userBlock } : blockOf(r);
            const open = expanded === r.id;
            return (
              <div className="hu-card" key={(deleted ? "d" : "u") + r.id}>
                <button className="hu-card__head" onClick={() => setExpanded(open ? null : r.id)}>
                  <div style={{ minWidth: 0 }}>
                    <div className="hu-card__user">{r.username}</div>
                    <div style={{ marginTop: 3 }}><HuRoleChip lvl={r.lvl} /></div>
                  </div>
                  <div style={{ textAlign: "right" }}>
                    <div className={`hu-card__bal${r.bal < 0 ? " hu-neg" : ""}`}>{huMoney(r.cur, r.bal)}</div>
                    <div className="hu-card__last">{r.last ? hpDate(r.last) : "no access yet"}</div>
                  </div>
                  <Icon name={open ? "chevron_down" : "chevron_right"} size={14} style={{ flexShrink: 0, opacity: .6 }} />
                </button>
                {(eff.cash || eff.user || (!deleted && huSubnetBlocked(r))) && (
                  <div className="hu-card__chips">
                    {eff.user && <span className="chip chip--err">User block</span>}
                    {eff.cash && <span className="chip chip--warn">Cash block</span>}
                    {!deleted && huSubnetBlocked(r) && <span className="hu-subblock">Subnet block</span>}
                  </div>
                )}
                {open && (
                  <div className="hu-card__body">
                    {[
                      ["ID", r.id],
                      deleted ? ["Old user id", r.oldId] : null,
                      ["Skin", r.skin], ["Parent", r.parent],
                      ["Fido", huMoney(r.cur, r.credits)],
                      !deleted ? ["Subnet balance", r.lvl === 20 ? "—" : huMoney(r.cur, r.sub)] : ["Subnet balance", "—"],
                      deleted ? ["Deleted by", r.deletedBy] : null,
                      deleted ? ["Deleted at", hpDate(r.deletedAt)] : null,
                    ].filter(Boolean).map(([k, v]) => <div className="hu-card__row" key={k}><span>{k}</span><b>{v}</b></div>)}
                    {!deleted && (
                      <div className="hu-card__acts">
                        {r.lvl !== 20 && <button className="rpt-btn rpt-btn--search" onClick={() => drill(r)}><Icon name="users" size={12} /> Subnet</button>}
                        <button className="rpt-btn rpt-btn--blue" onClick={() => setSelected({ ...r, cashBlock: eff.cash, userBlock: eff.user })}><Icon name="edit" size={12} /> Edit</button>
                        <button className="rpt-btn rpt-btn--reset" onClick={() => setModal({ type: "delete", row: r })}><Icon name="trash" size={12} /> Delete</button>
                      </div>
                    )}
                  </div>
                )}
              </div>
            );
          })}
        </div>
      ) : (
        <div className="panel hu-table-wrap" style={{ overflow: "hidden" }}><div style={{ overflowX: "auto" }}>
          <table className="data-table hp-list">
            <thead>
              <tr>
                <th className="hu-sort" onClick={() => clickSort("id")}>ID {sortIcon("id")}</th>
                {deleted && <th>Old user id</th>}
                <th className="hu-sort" onClick={() => clickSort("username")}>Username {sortIcon("username")}</th>
                <th>Role</th>
                {/* Email / Name / Lastname columns ship hidden by default (ajax.js
                    L179-202) — omitted here, still searchable via the filters. */}
                {deleted && <th>Deleted by</th>}
                {deleted && <th>Deleted at</th>}
                <th>Skin</th>
                <th>Parent</th>
                <th>Balance</th>
                <th>Subnet Balance</th>
                <th className="hu-sort" onClick={() => clickSort("last")}>Last access {sortIcon("last")}</th>
                <th>Cash block</th>
                <th>User block</th>
                <th>Actions</th>
              </tr>
            </thead>
            <tbody>
              {pageRows.length === 0 && <tr><td colSpan={deleted ? 13 : 10} style={{ padding: "36px", textAlign: "center", color: "var(--text-tertiary)" }}>No users match your filters.</td></tr>}
              {pageRows.map(r => (
                <tr key={(deleted ? "d" : "u") + r.id}>
                  <td>{r.id}</td>
                  {deleted && <td>{r.oldId}</td>}
                  <td style={{ textAlign: "left" }}>
                    {deleted ? (
                      // Plain text in deleted mode — no drill-down; the impersonate icon
                      // never renders (UserPolicy@impersonate resolves deny for DeletedUser).
                      <span style={{ fontWeight: 700, color: "#3f4254" }}>{r.username}</span>
                    ) : (
                      <span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
                        {/* The SAME control as the eye in the actions column,
                            and gated on the same two conditions
                            begin_impersonation() enforces server-side. Rendered
                            only where it could succeed; the server is still the
                            boundary. It was disabled here while its twin two
                            columns over already worked. */}
                        {huMayImpersonate(data.me, r) && (
                          <button className="hu-iconbtn" title={`View the platform as ${r.username} (read only, recorded)`}
                            onClick={() => setModal({ type: "impersonate", row: r })}><Icon name="user" size={12} /></button>
                        )}
                        {r.lvl === 20
                          ? <span style={{ fontWeight: 700, color: "#3f4254" }}>{r.username}</span>
                          : <button className="rpt-user" onClick={() => drill(r)} title={`Open subnet — /users/?parent_id=${r.id}`}>{r.username} <Icon name="chevron_right" size={12} className="chev" /></button>}
                      </span>
                    )}
                  </td>
                  <td><HuRoleChip lvl={r.lvl} /></td>
                  {deleted && <td>{r.deletedBy}</td>}
                  {deleted && <td>{hpDate(r.deletedAt)}</td>}
                  <td>{r.skin}</td>
                  <td>{r.parent}</td>
                  <td style={{ textAlign: "left", minWidth: 210 }}>
                    <div className="hp-balcell">
                      <div className="hp-balcell__lines">
                        <div><b className={r.bal < 0 ? "hu-neg" : "ok"}>{huMoney(r.cur, r.bal)}</b></div>
                        {/* "Fido:" — hardcoded Italian on the live platform (getUsersList L1029-1043). */}
                        <div style={{ fontSize: 11 }}>Fido: {Number(r.credits).toFixed(2)}</div>
                      </div>
                      {!deleted && (
                        <div className="hp-balcell__btns">
                          {/* Recalculate RE-READS, it does not recompute. The
                              balance is `user_balances`, maintained by the
                              money paths; nothing here should ever add up a
                              ledger and write the answer back — that is how the
                              two disagree. So this refetches the feed, which is
                              exactly what the button means when the number on
                              screen is stale rather than wrong.

                              It refreshes EVERY row, not this one, because the
                              feed is one request — a per-row spinner would be a
                              lie about what is being fetched. */}
                          <button title={`Re-read balances from user_balances · ${r.username} (Customer Care needs support_user_transactions_read_only)`}
                            disabled={data.loading}
                            onClick={() => data.retry()}
                            style={{ width: 34, height: 34, border: 0, display: "grid", placeItems: "center", color: "#fff", background: "#3b82f6", cursor: data.loading ? "default" : "pointer", opacity: data.loading ? .6 : 1 }}><Icon name="refresh" size={13} /></button>
                          <button title={`Transfer funds — opens the Transfer screen (/deposit). Real link: /transfer/?from=admin&type=agent&to=${r.id}; same gate as Recalculate`} onClick={() => huNavTransfer(r.id)}><Icon name="wallet" size={13} /></button>
                        </div>
                      )}
                    </div>
                  </td>
                  <td style={{ textAlign: "left", minWidth: 130 }}>
                    {deleted
                      // Deleted view — the real cell computes getSubnetBalance() against the
                      // LIVE users tree using the deleted_users PK (meaningless; a marked bug).
                      // Evident intent: no subnet figure for deleted rows.
                      // <!-- SUGGESTION: skip the getSubnetBalance() call in deleted mode instead of resolving the deleted_users PK against live users. -->
                      ? <span style={{ color: "var(--text-tertiary)" }}>—</span>
                      : (r.lvl === 20 ? "" : <b>{huMoney(r.cur, r.sub)}</b>)}
                  </td>
                  <td>{r.last ? hpDate(r.last) : "-"}</td>
                  <td>{renderBlockCell(r, "cash")}</td>
                  <td>{renderBlockCell(r, "user")}</td>
                  {/* Deleted view: server sets disable_actions=true and the JS renderer
                      returns '' — the Actions column stays, permanently empty. */}
                  <td>{renderActions(r)}</td>
                </tr>
              ))}
            </tbody>
          </table>
        </div></div>
      )}

      <div className="hp-pager">
        <div className="hp-pager__show">
          Show
          <select className="select" value={pageLen} onChange={e => { setPageLen(Number(e.target.value)); setPage(0); }}>
            {[5, 10, 25, 50].map(n => <option key={n} value={n}>{n}</option>)}
          </select>
          entries
          <span className="hp-pager__range">{sorted.length === 0 ? "0" : `${pageSafe * pageLen + 1}–${Math.min(sorted.length, (pageSafe + 1) * pageLen)}`} of {sorted.length}</span>
        </div>
        <div className="hp-pager__pages">
          <button className="hp-pg-arrow" disabled={pageSafe === 0} onClick={() => setPage(p => Math.max(0, p - 1))}><Icon name="chevron_left" size={13} /></button>
          {Array.from({ length: pages }, (_, i) => i).filter(i => pages <= 7 || i === 0 || i === pages - 1 || Math.abs(i - pageSafe) <= 1).reduce((acc, i, idx, arr) => {
            if (idx > 0 && i - arr[idx - 1] > 1) acc.push("…");
            acc.push(i);
            return acc;
          }, []).map((i, idx) => i === "…"
            ? <span key={"e" + idx} className="hp-pg-ellipsis">…</span>
            : <button key={i} className={`hp-pg-num${i === pageSafe ? " active" : ""}`} onClick={() => setPage(i)}>{i + 1}</button>)}
          <button className="hp-pg-arrow" disabled={pageSafe >= pages - 1} onClick={() => setPage(p => Math.min(pages - 1, p + 1))}><Icon name="chevron_right" size={13} /></button>
        </div>
      </div>

      {/* ---- mobile filter sheet ---- */}
      {isMobile && sheetOpen && (
        <div className="hu-modal-scrim" onMouseDown={(e) => { if (e.target === e.currentTarget) setSheetOpen(false); }}>
          <div className="hu-sheet">
            <div className="hu-sheet__head">
              <span><Icon name="filter" size={14} /> Filters</span>
              <button className="hu-modal__x" onClick={() => setSheetOpen(false)}><Icon name="x" size={16} /></button>
            </div>
            <div className="hu-sheet__body">{filtersBlock}</div>
            <div className="hu-sheet__foot">
              <button className="rpt-btn rpt-btn--reset" style={{ minWidth: 0, height: 40, flex: 1 }} onClick={clearFilters}>Clear all</button>
              <button className="rpt-btn rpt-btn--blue" style={{ minWidth: 0, height: 40, flex: 2 }} onClick={() => setSheetOpen(false)}>Show {sorted.length} results</button>
            </div>
          </div>
        </div>
      )}

      {/* ---- modals ---- */}
      {modal && modal.type === "create" && <HuCreateModal def={modal.def} rows={allRows} skins={data.skins} profiles={data.profiles} me={data.me} onCreate={doCreate} onClose={() => setModal(null)} />}
      {modal && modal.type === "block" && <HuBlockModal row={modal.row} kind={modal.kind} next={modal.next} onConfirm={(note) => applyBlock(modal.row, modal.kind, modal.next, note)} onClose={() => setModal(null)} />}
      {modal && modal.type === "history" && <HuBlockHistory row={modal.row} log={blockLog[modal.row.id]} onClose={() => setModal(null)} />}
      {modal && modal.type === "creds" && <HuCredsModal row={modal.row} onClose={() => setModal(null)} />}
      {modal && modal.type === "twofa" && <Hu2faModal row={modal.row} me={data.me} initialAct={modal.act || null} onClose={() => setModal(null)} />}
      {modal && modal.type === "impersonate" && (
        <HuImpersonateModal row={modal.row} onClose={() => setModal(null)} />
      )}
      {modal && modal.type === "delete" && (
        <HuModalShell title={`Delete — ${modal.row.username}`} sub={`GET /users/delete/${modal.row.id}/ · superadmin (server also accepts skin admin)`} onClose={() => setModal(null)}
          footer={<React.Fragment>
            <button className="rpt-btn rpt-btn--search" style={{ minWidth: 0, height: 40 }} onClick={() => setModal(null)}>Cancel</button>
            <button className="rpt-btn rpt-btn--reset" style={{ minWidth: 0, height: 40 }} onClick={() => doDelete(modal.row)}><Icon name="trash" size={13} /> Delete permanently</button>
          </React.Fragment>}>
          <p style={{ margin: 0, fontSize: 13, color: "var(--text-secondary)", lineHeight: 1.6 }}>
            The full row is copied into <b>deleted_users</b> (with old_user_id = {modal.row.id} and your id as deleted_by), the balance history is purged, and the users row is <b>hard-deleted</b>. Deletion is one-way — <b>no restore exists anywhere</b> in the backoffice.
          </p>
        </HuModalShell>
      )}
    </div>
  );
};

window.HostUsers = HostUsers;
