// 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: admin.reports.credit.transactions.index · ReportsController — see docs/ISYSTEM_REFERENCE.md §Batch 2 "Credit transactions"
/* ====================================================================
   CREDIT TRANSACTIONS (Report ▾) — Hrs* shell rebuild of the live screen.

   Real surface
   - Page:  GET /reports/credittransactions/            → ReportsController::credit (:7126)
   - Data:  GET /reports/credittransactions/getCreditTransactions
            → ReportsController::getCreditTransactionsReport (:7154, DataTables
            JSON + `action=excel` branch → excelExportTransactionsReport(...,
            $credittransactions = true) → credit_transactions_report.xlsx)
   - View:  admin/reports/credit_transactions/index.blade.php (+ table_settings
            partial); JS driver public/js/pages/transactions/credittransactions.js
   - Gates: Customer Care only — `support_report` + `support_report_credit_transactions`
            (403 via the deliberate authorize('asdasdas') hack); export UI further
            gated `support_export`. Known copy-paste leak: the data endpoint also
            checks `support_report_daily_report` (ReportsController:7210).

   Ledger semantics: MySQL `transactions` restricted to type IN
   ('add_credits','out_credits') — every back-office credit movement writes a
   PAIRED row set (Credit Deposit on the receiving account, Credit Withdraw on
   the paying account; same amount/timestamp, adjacent IDs). `Balance` is
   `transactions.ref_new_credits` — the row's reference user's credit balance
   right after the movement (red when negative, blank when empty/zero).
   IN/OUT totals boxes under the table sum the WHOLE filtered set (real:
   TransactionsController::getSumDeposits under READ UNCOMMITTED, restricted
   `system IN ('bo','creditcard','commissions')`; all prototype rows are 'bo').
   The real JS also writes `#total_profit` from a payload key that does not
   exist into a DOM node that does not exist (credittransactions.js:51) — dead
   both ends, so no NET box here either (faithful to the real UI).

   Dead surface represented honestly, NOT rebuilt:
   - Legacy /reports/credit/ route set (routes/admin.php:1473-1485): its index
     URL accidentally aliases THIS screen (same ReportsController@credit); its
     two data routes (getCreditReport / getCreditReportSubLevel) point at
     methods that do not exist anywhere in app/ → fatal 500; its Excel route
     (excelExportCreditReport) formats client-scraped arrays with zero DB
     queries; its Blade/JS pair (credit.blade.php + reports/credit.js) is
     rendered by no controller. Footnote in the UI, nothing more.
     <!-- SUGGESTION: delete the /reports/credit/ route group and its orphaned
     view/JS/export writer — two of its routes are guaranteed 500s and the
     export endpoint trusts unauthenticated-shaped client arrays. -->

   Known bugs → evident intent (per docs/ISYSTEM_REFERENCE.md + CLAUDE.md policy):
   - Export scoping mismatch: credittransactions.js sends `search_user` =
     the Username filter value (not the Parent) and omits skin_id /
     filtered_search_user / only_direct_txs / include_test_users, so the real
     XLSX can cover a different dataset than the on-screen grid. Prototype
     exports exactly the on-screen filtered set (Export-scope filter honored).
     <!-- SUGGESTION: make the export request send the same parameter set as
     the grid draw so the file always matches what the operator is seeing. -->
   - Export-scope select markup carries `selected` on BOTH options ("Current
     Page" and "All Pages"); the browser lands on the last one. Prototype ships
     a single default: All Pages (the effective real-world behavior).
     <!-- SUGGESTION: keep exactly one `selected` attribute on the intended
     default option. -->
   - Real column-settings modal persists to localStorage key
     `credit_transactions_report_table_settings`; the report-shell contract is
     presentation-only, so visibility state here is session-local (it still
     drops hidden columns from the export, mirroring `hidden_cols`).

   Quirks mirrored by omission (comment-only, no UI):
   - `wallet` param sent by the JS with no #wallet element and never read;
     `transaction_method` validated server-side (bo/creditcard/commissions,
     bare die() on bad value) but never sent by this UI — both leftovers from
     the sibling Transactions report; no filter rendered, as on the real page.
   - Controller order-by branches for from/to/in/out (:7240-7290) are dead
     copies from the Transactions report — only ID and Date sort (default
     id DESC; controller fallback id ASC).
   - Footer datepicker double-init targets nonexistent #datadal_val/#dataal_val
     (index.blade.php:306) — dead selector, nothing to represent.

   UNCLEAR (unresolvable from the reference):
   - Whether the initial DataTables draw is deferred: the batch-wide "empty
     until Search" convention is not asserted for this screen and DataTables
     auto-draws by default. Prototype auto-draws — moot in practice, because
     the shipped defaults (Username preselected to the Parent + "Only direct
     txs" forced ON) make the strictly-between-the-two branch match nothing,
     so the first draw is empty either way (matches the legacy stub's observed
     empty grid). Explained in the empty state.
   - Generated description interior: the reference documents
     "<receiver> - Credit deposit <descr>"; the "(Withdraw from: …)" /
     "(Deposit to: …)" interior is inferred from the sibling Transactions
     report's documented generateTransactionDescription add/out patterns.
   - Skin-filter clause (OR across the three user joins) and the Username
     lookup's subtree-vs-direct-children scope are documented for the sibling
     Transactions report only; mirrored here and marked inferred.

   Labels: "Only direct txs" / "Include test users" resolve only to raw
   backend.* keys in the committed lang files → labels inferred (marked).
   All new top-level names are hrct/HRCT/Hrct-prefixed (grep-verified unique);
   `RCreditTransactions` intentionally shadows the HostReports.jsx stub
   (this file loads later, so it wins). No page CSS needed (hrs-* + inline).
   ==================================================================== */

const { useState: hrctUseState, useMemo: hrctUseMemo } = React;

/* ---------- deterministic PRNG (mulberry32, same family as rRng/brRng) ---------- */
/* The deterministic PRNG lived here and is gone with the generator. */
const hrctPad2 = (n) => String(n).padStart(2, "0");
const HRCT_TODAY = (() => { const t = new Date(); return `${t.getFullYear()}-${hrctPad2(t.getMonth() + 1)}-${hrctPad2(t.getDate())}`; })();

/* transactions.type → label (transactionTypes(), TransactionsController.php:31 —
   the view loops the full map but `continue`s everything except these two) */
const HRCT_TYPES = { add_credits: "Credit Deposit", out_credits: "Credit Withdraw" };

/* User-type options: User::getLevel() minus Customer Care(4)/Administration(6)/
   Affiliate(1)/Player(30); 8/10/15/20 names are per-skin overridable
   (custom_*_name) for non-admins — defaults shown. */
const HRCT_LEVELS = { 0: "Super Admin", 2: "Skin Access", 8: "Agent", 10: "Promoter", 15: "Shop", 20: "Cashier" };

/* Deterministic network slice (ids/names consistent with HostUsers.jsx +
   the legacy HostReports stubs). `path` = users.user_path ancestor chain. */
/* WAS eleven invented operators (`HRCT_USERS`, `HRCT_BY_ID`), four invented
   skin names (`HRCT_SKINS`) and twelve hardcoded hierarchy edges
   (`HRCT_EDGES`) that the generator walked to mint credit transfers. All three
   now come from the database: operators and their paths from `networkUsers`,
   skins from `skins`, and the transfers from `ledger_entries` themselves —
   where the hierarchy is not a list of edges but the ltree path on each row. */

/* WAS `hrctLedger()`: a deterministic 40-day paired credit ledger built from
   twelve invented hierarchy edges, five amount bands per level and a mulberry32
   seeded on a constant — roughly 300 fabricated credit transfers with running
   balances that reconciled against each other perfectly, because the generator
   maintained them. A credit line is money owed between operators; this was a
   complete invented set of it.

   The real source is `ledger_entries` where `wallet = 'credits'` — the same
   predicate `report_credit_daily` uses (022), which is what makes this grid and
   that rollup agree by construction rather than by coincidence.

   THE SIGN CARRIES THE DIRECTION, and that is the whole mapping. `post_transfer`
   writes one row per side, so a transfer already exists here as the pair this
   grid renders: the payer's row is negative, the receiver's positive. Which
   means `type` is not a stored column to look up — it is `amount > 0`, and
   getting it backwards would relabel every Credit Deposit as a Credit Withdraw
   while every total stayed correct.

   `ref` — whose balance the Balance column shows — is always the row's OWN
   user, which is exactly what `balance_after` already is. Upstream describes it
   as "ref_new_credits = payer's balance" on the out row and the receiver's on
   the add row; those are the same statement once the rows are per-side. */
const hrctRow = (e) => {
  const amt = Number(e.amount) || 0;
  const inbound = amt > 0;
  /* path and lvl come off the embed because the filters scope by subtree and by
     role. `path` is an ltree, dotted — the filter below compares it as a string
     prefix, so the separator has to match what the database writes. */
  const side = (u, fallbackId) => ({
    id: (u && u.id) != null ? u.id : fallbackId,
    u: (u && u.username) || (fallbackId == null ? "—" : String(fallbackId)),
    lvl: u ? Number(u.user_level) : null,
    path: (u && u.path) || "",
    skin: u ? u.skin_id : e.skin_id,
  });
  const self  = side(e.user, e.user_id);
  const other = side(e.counterparty, e.counterparty_id);
  const ts = e.created_at ? Date.parse(e.created_at) : 0;
  const d  = new Date(ts);
  return {
    id: e.id,
    type: inbound ? "add_credits" : "out_credits",
    payeer:   inbound ? other : self,
    receiver: inbound ? self  : other,
    ref: self,
    amt: Math.abs(amt),
    bal: Number(e.balance_after) || 0,
    d: e.created_at ? `${d.getFullYear()}-${hrctPad2(d.getMonth() + 1)}-${hrctPad2(d.getDate())}` : "",
    h: d.getHours(), mi: d.getMinutes(), ts,
    /* The stored description already reads "<name> - Credit deposit (...)" when
       post_transfer wrote it; hrctDesc below rebuilds that shape for the grid,
       so what is kept here is only the operator's own note. */
    rawDesc: e.description || "",
    system: "bo",
  };
};

/* Description = generateTransactionDescription(type, payeer, receiver, descr).
   Documented pattern: "<receiver> - Credit deposit <descr>"; parenthetical
   interior inferred from the sibling Transactions report's add/out patterns. */
const hrctDesc = (r) => r.type === "add_credits"
  ? `${r.receiver.u} - Credit deposit (Withdraw from: ${r.payeer.u})${r.rawDesc ? " " + r.rawDesc : ""}`
  : `${r.payeer.u} - Credit withdraw (Deposit to: ${r.receiver.u})${r.rawDesc ? " " + r.rawDesc : ""}`;

/* transactions.addedTime rendered d/m/Y G:i (hour without leading zero). */
const hrctDate = (r) => { const [y, m, d] = r.d.split("-"); return `${d}/${m}/${y} ${r.h}:${hrctPad2(r.mi)}`; };

/* Server-side filter semantics of getCreditTransactionsReport, applied to the
   mock ledger. Order/meaning per docs/ISYSTEM_REFERENCE.md §Credit Transactions. */
const hrctFilterRows = (all, f) => {
  const from = (f.range && f.range.from) || "";
  const to = (f.range && f.range.to) || "";
  const q = (f.desc || "").trim().toLowerCase();
  /* No default parent any more. The old code fell back to HRCT_BY_ID["1"] —
     the invented root — so an unset Parent silently scoped the grid to that
     account's subtree. With nothing selected the correct scope is everything
     the caller may see, which RLS has already applied. */
  const parent = f.parent ? (f.byId || {})[f.parent] : null;
  const uname = f.username ? (f.byId || {})[f.username] : null;
  return all.filter(r => {
    /* Base whereIn('add_credits','out_credits'); Transaction Type narrows to one. */
    if (f.type ? r.type !== f.type : (r.type !== "add_credits" && r.type !== "out_credits")) return false;
    /* Dates bounded 00:00:00–23:59:59 server-side (sistemadata()); no time inputs on this screen. */
    if (from && r.d < from) return false;
    if (to && r.d > to) return false;
    /* transactions.description LIKE %…% — the raw operator note, not the generated grid string. */
    if (q && !r.rawDesc.toLowerCase().includes(q)) return false;
    /* Skin: OR across the three user joins — inferred from the sibling Transactions report (UNCLEAR for this screen). */
    /* The option value is a skin ID now, not a name; compared as strings so a
       numeric id from the select never fails a === against a bigint. */
    if (f.skin && ![r.ref.skin, r.payeer.skin, r.receiver.skin]
          .some(x => String(x) === String(f.skin))) return false;
    /* Parent scoping: ref_user.user_path under the searched Parent's path (descendant-or-self).
       The additional payeer-or-receiver fence against the auth user's own path is trivially
       satisfied here (prototype operator = admin, path "/1"). */
    if (parent && parent.path && !(r.ref.path === parent.path
        || String(r.ref.path).startsWith(parent.path + "."))) return false;
    /* User Type filters ref_user.user_level — note '0' (Super Admin) must still filter. */
    if (f.usertype !== "" && f.usertype != null && r.ref.lvl !== Number(f.usertype)) return false;
    /* Username (filtered_search_user) filters ref_user.id. */
    if (uname && r.ref.id !== uname.id) return false;
    /* Only direct txs (default forced ON by the real JS): with a Username picked, rows strictly
       between Parent and Username (either direction); otherwise payeer-or-receiver = Parent.
       With the shipped defaults (Username preselected to the Parent) this branch can never
       match — the real screen opens empty. */
    if (f.direct) {
      if (uname) {
        if (!((r.payeer.id === parent.id && r.receiver.id === uname.id) ||
              (r.payeer.id === uname.id && r.receiver.id === parent.id))) return false;
      } else if (r.payeer.id !== parent.id && r.receiver.id !== parent.id) return false;
    }
    /* Include test users OFF → u_receiver.test_user = 0. */
    if (!f.test && r.receiver.test) return false;
    return true;
  });
};

const hrctDefaults = () => ({
  range: { from: HRCT_TODAY, to: HRCT_TODAY, fromTime: "", toTime: "" },
  /* Parent and Username default to EMPTY. They were "1" — the invented
     root's id — so an unset filter silently scoped the grid to that account.
     A real database guarantees no id 1, and "no filter" means whatever RLS
     already allows. */
    type: "", desc: "", exportScope: "all", skin: "", parent: "", usertype: "", username: "",
  direct: true, test: false,
});

/* "Setting" — column show/hide (real: table_settings.blade.php modal persisting
   to localStorage `credit_transactions_report_table_settings`; session-only
   here per the report-shell presentation-only contract). Hidden columns are
   also dropped from the export, mirroring `hidden_cols`. */
const HrctColsBtn = ({ cols, hidden, onToggle }) => {
  const [open, setOpen] = hrctUseState(false);
  return (
    <div style={{ position: "relative", display: "inline-block" }}>
      <button className="hrs-btn hrs-btn--filters" onClick={() => setOpen(o => !o)}>
        <Icon name="settings" size={14} /> Setting
      </button>
      {open && (
        <>
          <div style={{ position: "fixed", inset: 0, zIndex: 70 }} onClick={() => setOpen(false)} />
          <div style={{ position: "absolute", right: 0, top: "calc(100% + 6px)", zIndex: 71, background: "#fff", border: "1px solid var(--border-default)", borderRadius: 8, boxShadow: "0 8px 24px rgba(15,20,32,.16)", padding: "10px 12px", minWidth: 210 }}>
            <div style={{ fontSize: 11, fontWeight: 700, textTransform: "uppercase", letterSpacing: ".05em", color: "var(--text-tertiary)", marginBottom: 6 }}>Visible columns</div>
            {cols.map(c => (
              <label key={c.key} style={{ display: "flex", alignItems: "center", gap: 8, padding: "4px 2px", fontSize: 13, cursor: "pointer" }}>
                <input type="checkbox" checked={!hidden[c.key]} onChange={() => onToggle(c.key)} /> {c.label}
              </label>
            ))}
            <div style={{ fontSize: 11, color: "var(--text-tertiary)", marginTop: 6, maxWidth: 230 }}>
              Hidden columns are also removed from the export (real <code>hidden_cols</code> behavior). Session-only in the prototype; the real modal persists to localStorage.
            </div>
          </div>
        </>
      )}
    </div>
  );
};

/* ==================================================================
   Page component — intentionally shadows the HostReports.jsx stub.
   ================================================================== */
const RCreditTransactions = () => {
  /* wallet = 'credits' is the whole filter: the credit line is a separate
     wallet, and a credit movement summed into real money inflates turnover by
     the entire float while looking completely plausible (022 says the same
     thing about the daily view). */
  const feed = useHrsFetch(() => window.sb.list("ledger", {
    limit: 2000, filters: { wallet: "credits" },
  }), []);
  const opts = useHrsFetch(() => window.sb.list("networkUsers", { limit: 200 }), []);
  const skinsFeed = useHrsFetch(() => window.sb.list("skins", { limit: 200 }), []);
  const skinOpts = ((skinsFeed.data || [])).map(k => ({ value: String(k.id), label: k.name }));
  const all = hrctUseMemo(() => (feed.data || []).map(hrctRow), [feed.data]);
  const [draft, setDraft] = hrctUseState(hrctDefaults);
  /* DataTables auto-draws on init (UNCLEAR whether the real page defers it) —
     with the shipped defaults the first draw is empty either way, see above. */
  const [applied, setApplied] = hrctUseState(hrctDefaults);
  const [sort, setSort] = hrctUseState({ key: "id", dir: "desc" });
  const [page, setPage] = hrctUseState(0);
  const [pageSize, setPageSize] = hrctUseState(50);           /* real pageLength: 50 */
  const [hidden, setHidden] = hrctUseState({});

  /* MOVED ABOVE THE MEMOS, AND THAT IS THE WHOLE FIX FOR A SILENT SCOPE FAILURE.
     ------------------------------------------------------------------------
     `filtered` below is a hrctUseMemo whose factory runs DURING this render and
     passes `byId: opById`. These two declarations sat forty lines beneath it, so
     on the render that mattered `opById` was `undefined` — not a ReferenceError,
     because in-browser Babel compiles `const` loosely, but plain `undefined`.

     hrctFilterRows reads it as `(f.byId || {})[f.parent]`, so the Parent
     resolved to undefined and the subtree fence at line ~217 was skipped
     entirely: A SELECTED PARENT FILTERED NOTHING, and the grid showed every
     credit movement in the network under the heading of one operator's subtree.
     With "Only direct txs" on — which the real screen forces — the same
     undefined reached `parent.id` and threw instead.

     Found by tools/tdzcheck.js after it learned that a useState/useMemo factory
     is called during render rather than later; it had been skipping every
     function boundary, which is right for a handler and wrong for these two.

     WAS `HRCT_USERS`: eleven invented operators with hand-written paths and
     opening credit balances, which both the Parent/Username pickers and the
     subtree filter read. Real operators now, from `networkUsers`. */
  const opUsers = ((opts.data || [])).map(u => ({
    id: u.id, u: u.username, lvl: Number(u.user_level), path: String(u.path || ""), skin: u.skin_id,
  }));
  const opById = {}; opUsers.forEach(u => { opById[String(u.id)] = u; });

  const filtered = hrctUseMemo(() => hrctFilterRows(all, { ...applied, byId: opById }), [all, applied, opts.data]);
  const sorted = hrctUseMemo(() => {
    const dir = sort.dir === "asc" ? 1 : -1;
    /* Only ID and Date sort on the real grid (everything else bSortable:false);
       the from/to/in/out order branches in the controller are dead copies. */
    return [...filtered].sort((a, b) => (sort.key === "date" ? (a.ts - b.ts || a.id - b.id) : (a.id - b.id)) * dir);
  }, [filtered, sort]);
  const paged = sorted.slice(page * pageSize, (page + 1) * pageSize);

  /* IN/OUT totals over the WHOLE filtered set (getSumDeposits clone, not the page).
     The type sets follow the Transaction Type filter (both / add-only / out-only). */
  const inSum = filtered.reduce((a, r) => a + (r.type === "add_credits" ? r.amt : 0), 0);
  const outSum = filtered.reduce((a, r) => a + (r.type === "out_credits" ? r.amt : 0), 0);

  const parentOpts = opUsers.filter(u => [0, 2, 8, 10, 15].includes(u.lvl))
    .map(u => ({ value: String(u.id), label: `${u.u} (${HRCT_LEVELS[u.lvl] || "Level " + u.lvl})` }));
  const parentU = opById[draft.parent] || null;
  /* Username lookup: select2 on searchUsers2 with user_types = chosen User Type
     or [0,2,8,10,15,20] and parent_id = Parent; subtree scope inferred (UNCLEAR). */
  const usernameOpts = opUsers
    .filter(u => (draft.usertype !== "" ? u.lvl === Number(draft.usertype) : [0, 2, 8, 10, 15, 20].includes(u.lvl)))
    .filter(u => !parentU || !parentU.path || u.path === parentU.path || String(u.path).startsWith(parentU.path + "."))
    .map(u => ({ value: String(u.id), label: `${u.u} (${HRCT_LEVELS[u.lvl] || "Level " + u.lvl})` }));

  /* Filter form, real display order. */
  const FIELDS = [
    { key: "range", label: "Date", type: "daterange", icon: "calendar",
      defaultValue: { from: HRCT_TODAY, to: HRCT_TODAY, fromTime: "", toTime: "" },
      tip: <>dd/mm/yyyy pair on the real page, defaulting to today/today; the server bounds the days at 00:00:00–23:59:59 — this screen has no time inputs.</> },
    { key: "type", label: "Transaction Type", type: "select", placeholder: "-ALL-",
      options: [{ value: "add_credits", label: "Credit Deposit" }, { value: "out_credits", label: "Credit Withdraw" }],
      tip: <>The Blade loops the full <code>transactionTypes()</code> map but skips everything except the two credit types. The IN/OUT totals boxes follow this filter too.</> },
    { key: "desc", label: "Description", type: "text", placeholder: "Description", grow: true,
      tip: <>LIKE match on the raw <code>transactions.description</code> note — not on the generated description string shown in the grid.</> },
    { key: "exportScope", label: "Export", type: "select", defaultValue: "all",
      options: [{ value: "current", label: "Current Page" }, { value: "all", label: "All Pages" }],
      tip: <>Scope of the export file. The real markup marks <em>both</em> options <code>selected</code>, so the browser lands on All Pages — implemented as the single default here. Rendered only for operators holding <code>support_export</code>.</> },
    { key: "skin", label: "Skin", type: "select", icon: "flag", placeholder: "- Select -", options: skinOpts,
      tip: <>From <code>Auth::user()-&gt;getSkins()</code>; rendered only for admins and Customer Care on the real page.</> },
    { key: "parent", label: "Parent", type: "select", defaultValue: "1", options: parentOpts,
      tip: <>select2 user search (levels 0/2/8/10/15) on the real page; defaults to your own account (Customer Care / Administration get their parent). Disabled for Affiliate accounts. Scopes rows to the reference user's subtree.</> },
    { key: "usertype", label: "User Type", type: "select", placeholder: "- Select -",
      options: Object.entries(HRCT_LEVELS).map(([v, l]) => ({ value: v, label: l })),
      tip: <>Filters the row's reference user's level. Agent/Promoter/Shop/Cashier names are per-skin overridable; defaults shown.</> },
    { key: "username", label: "Username", type: "select", placeholder: "-ALL-", defaultValue: "1", options: usernameOpts,
      tip: <>Sent as <code>filtered_search_user</code>; filters the reference user (<code>ref_user_id</code>). The real page preselects the Parent account here by default.</> },
    { key: "direct", label: "Only direct txs", type: "toggle", defaultValue: true,
      tip: <>Label inferred — <code>backend.only_direct_txs</code> resolves nowhere in the committed language files. Forced ON by the real page's JS. Limits rows to movements the Parent is a side of (or strictly between Parent and Username when one is picked).</> },
    { key: "test", label: "Include test users", type: "toggle", defaultValue: false,
      tip: <>Label inferred — <code>backend.include_test_users</code> resolves nowhere in the committed language files. Off hides rows whose receiving account is flagged <code>test_user</code>.</> },
  ];

  const COLS = [
    { key: "id", label: "ID", sortable: true, width: 92, hidden: !!hidden.id, firstDir: "desc" },
    { key: "typ", label: "Typology", hidden: !!hidden.typ, render: r => HRCT_TYPES[r.type] },
    { key: "desc", label: "Description", hidden: !!hidden.desc, render: r => hrctDesc(r) },
    { key: "in", label: "IN", align: "right", hidden: !!hidden.in,
      render: r => r.type === "add_credits" ? hrsMoney(r.amt) : "",
      cellClass: r => r.type === "add_credits" ? "hrs-pos" : "" },
    { key: "out", label: "OUT", align: "right", hidden: !!hidden.out,
      render: r => r.type === "out_credits" ? hrsMoney(r.amt) : "",
      cellClass: r => r.type === "out_credits" ? "hrs-neg" : "" },
    /* Balance = ref_new_credits: red when negative, blank when empty/zero. */
    { key: "bal", label: "Balance", align: "right", hidden: !!hidden.bal,
      render: r => r.bal ? hrsMoney(r.bal) : "",
      cellClass: r => r.bal < 0 ? "hrs-neg" : "" },
    { key: "date", label: "Date", sortable: true, hidden: !!hidden.date, render: r => hrctDate(r), firstDir: "desc" },
  ];

  const doExport = () => {
    const scopeRows = applied.exportScope === "current" ? paged : sorted;
    const eIn = scopeRows.reduce((a, r) => a + (r.type === "add_credits" ? r.amt : 0), 0);
    const eOut = scopeRows.reduce((a, r) => a + (r.type === "out_credits" ? r.amt : 0), 0);
    const flat = scopeRows.map(r => ({
      id: r.id, typ: HRCT_TYPES[r.type], desc: hrctDesc(r),
      in: r.type === "add_credits" ? r.amt.toFixed(2) : "",
      out: r.type === "out_credits" ? r.amt.toFixed(2) : "",
      bal: r.bal ? r.bal.toFixed(2) : "", date: hrctDate(r),
    }));
    /* Real XLSX ends in a colored totals row; CSV keeps the row, drops the color. */
    flat.push({ id: "", typ: "Totals", desc: "", in: eIn.toFixed(2), out: eOut.toFixed(2), bal: "", date: "" });
    const headers = COLS.filter(c => !c.hidden).map(c => ({ key: c.key, label: c.label }));
    /* Real file is credit_transactions_report.xlsx (PhpSpreadsheet, filter-summary
       header row); prototype ships CSV. Exports the on-screen filtered set —
       the real JS's mismatched param set is a documented bug (see header). */
    hrsCsv(flat, headers, "credit_transactions_report.csv");
  };
  const exportCount = (applied.exportScope === "current" ? paged : sorted).length;

  /* Empty-state honesty: the shipped defaults are provably self-excluding. */
  const degenerate = applied.direct && applied.username && applied.username === applied.parent;
  const emptyNode = (
    <>
      <div>No data available in table</div>
      {degenerate && (
        <div style={{ fontSize: 12, color: "var(--text-tertiary)", maxWidth: 480, margin: "6px auto 0", lineHeight: 1.5 }}>
          The page opens exactly like the real one: Username preselected to the Parent account and
          "Only direct txs" on — a combination that can never match a row (it asks for movements
          strictly between an account and itself). Set Username to -ALL-, pick a child account, or
          switch off Only direct txs, then Search.
        </div>
      )}
    </>
  );

  return (
    <HrsShell
      title="Credit Transactions"
      gate={["support_report", "support_report_credit_transactions"]}
      gateNote={<>
        {" "}Gates bind Customer Care only — every other role passes (the <code>authorize('asdasdas')</code> 403 hack).
        Sidebar entry additionally requires user level below Shop (20). Known copy-paste leak: the data endpoint
        also checks <code>support_report_daily_report</code>, so the Daily-report permission bleeds into this
        screen for Customer Care users.
      </>}
      explainer={{
        bullets: [
          <>Back-office <b>credit ledger</b>: every credit movement between network accounts writes a paired row — a <b>Credit Deposit</b> on the receiving account and a <b>Credit Withdraw</b> on the paying account, same amount and timestamp, adjacent IDs.</>,
          <><b>Balance</b> is the row's reference account's credit balance right after the movement — red when negative (the super admin mints credits, so it runs negative), blank at exactly zero.</>,
          <>The <b>IN</b> / <b>OUT</b> boxes under the table total the <b>whole filtered set</b>, not just the visible page, and follow the Transaction Type filter.</>,
          <>Defaults mirror the real page: today's dates, your own account as Parent <i>and</i> Username, "Only direct txs" on — which is why the grid opens empty until you widen a filter.</>,
        ],
      }}
      actions={<HrctColsBtn cols={COLS} hidden={hidden} onToggle={(k) => setHidden(h => ({ ...h, [k]: !h[k] }))} />}
    >
      <HrsFilters
        fields={FIELDS}
        values={draft}
        onChange={(k, v) => setDraft(d => ({ ...d, [k]: v }))}
        onSearch={(v) => { setApplied({ ...v }); setPage(0); }}
        onReset={() => { const d = hrctDefaults(); setDraft(d); setApplied(d); setPage(0); }}
        resultLabel={`${hrsInt(sorted.length)} rows`}
      />

      {/* Zero rows is the same picture whether the credit line was quiet or the
          request failed, and the In/Out totals below would report a confident
          pair of zeros either way. */}
      {feed.error && <HrsError error={feed.error} onRetry={feed.retry} />}
      {opts.error && <HrsError error={opts.error} onRetry={opts.retry} />}
      {feed.loading && <HrsSkeleton rows={8} cols={7} />}

      <HrsTable
        columns={COLS}
        rows={feed.loading || feed.error ? [] : paged}
        sort={sort}
        onSort={setSort}
        maxHeight="62vh"
        dense
        empty={emptyNode}
        renderCard={r => (
          <>
            <div className="hrs-card__top">
              <b>{HRCT_TYPES[r.type]}</b>
              <span className={r.type === "add_credits" ? "hrs-pos" : "hrs-neg"} style={{ padding: "1px 7px", borderRadius: 4 }}>{hrsMoney(r.amt)}</span>
            </div>
            <div className="hrs-card__grid">
              <span>ID</span><b>{r.id}</b>
              <span>Date</span><b>{hrctDate(r)}</b>
              <span>Balance</span><b className={r.bal < 0 ? "hrs-neg" : ""}>{r.bal ? hrsMoney(r.bal) : "—"}</b>
              <span>Description</span><b style={{ fontWeight: 500 }}>{hrctDesc(r)}</b>
            </div>
          </>
        )}
      />

      <HrsPager page={page} pageSize={pageSize} total={sorted.length} onPage={setPage}
        onPageSize={(n) => { setPageSize(n); setPage(0); }} sizes={[5, 10, 25, 50]} />

      {/* IN/OUT boxes under the table, as on the real page. No NET box: the real
          JS writes #total_profit into a DOM node that doesn't exist from a payload
          key that doesn't exist — dead on both ends, honestly omitted. */}
      <HrsBars items={[
        { label: "IN", value: hrsMoney(inSum), tone: "in", tip: <>Sum of Credit Deposit amounts over the whole filtered set (real: <code>getSumDeposits()</code> under READ UNCOMMITTED, restricted to <code>system IN ('bo','creditcard','commissions')</code>).</> },
        { label: "OUT", value: hrsMoney(outSum), tone: "out", tip: <>Sum of Credit Withdraw amounts over the whole filtered set — same query, out-type set.</> },
      ]} />

      <HrsExport
        count={exportCount}
        filename="credit_transactions_report.csv"
        gate="support_export"
        note={applied.exportScope === "current" ? "Scope: current page (Export filter)" : "Scope: all pages (Export filter)"}
        onCsv={doExport}
      />

      <div style={{ fontSize: 11.5, color: "var(--text-tertiary)", padding: "2px 4px", lineHeight: 1.5 }}>
        Honesty note: the legacy <code>/reports/credit/</code> route set is dead on the real platform — its index URL
        accidentally serves this very screen, its two data endpoints point at controller methods that no longer exist
        (fatal 500 if called), and its orphaned Excel writer formats client-supplied arrays without a single query.
        It is represented by this note only, not rebuilt.
        <Tip size={12}>routes/admin.php:1473-1485; see docs/ISYSTEM_REFERENCE.md §Batch 2 "Credit Transactions" Notes for the full autopsy.</Tip>
      </div>
    </HrsShell>
  );
};

window.RCreditTransactions = RCreditTransactions;
