// 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.transactions.index · ReportsController/TransactionsController — see docs/ISYSTEM_REFERENCE.md §Batch 2 "Transactions report"
/* Report ▾ → Transactions — the host money-movement ledger over the MySQL
   `transactions` table (NOT PayBO's protected Transactions.jsx, and not the
   player game history, which lives in `transaction_history` / Mongo).

   Real surface: GET /reports/transactions/ (ReportsController::transactions L7550)
   + GET /reports/transactions/getTransactionsReport (::getTransactionsReport
   L7589 — DataTables JSON; same endpoint with action=excel →
   excelExportTransactionsReport L7431). TransactionsController is used only as a
   static helper library here (transactionTypes(), generateTransactionDescription(),
   getSumDeposits2()). Blade: admin/reports/transactions/index.blade.php +
   table_settings.blade.php; JS driver public/js/pages/transactions/transactions.js.

   Gates: support_report + support_report_transactions — they bind Customer Care
   ONLY (every other role passes); the controller forces the 403 by authorizing
   the deliberately nonexistent ability 'asdasdas'. Export UI additionally gated
   for CC by support_export.

   Backend-only findings recorded here (nothing to reproduce client-side):
   - Injection surface: getTransactionsReport concatenates raw request input into
     whereRaw() — skin_id (L7741), search_usertype (L7744), filtered_search_user
     (L7747, L7766) are unquoted/unvalidated SQL on this admin endpoint.
     <!-- SUGGESTION: replace those whereRaw string concatenations with parameter
     bindings (and validate skin_id/usertype against the allowed sets). -->
   - Reads run under SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED.
   - `transaction_method` (bo/creditcard/commissions) is validated and honored
     server-side (transactions.system) but this page's UI has no input for it —
     leftover shared with the admin Transactions page; not rendered here either.
   - The per-row Details checkbox column + player-history modal are dead UI
     (column commented out in controller L7566 and JS; $('#btn_details').hide()
     on init) — omitted, not rebuilt. That dead modal is the only place this
     screen would touch the numeric transaction_history type enum.
   - The wire params keep the platform's "serach" typo (serach_date_start/…);
     a UI-less wire detail, not mirrored.
   - Second permission block at L7648-7649 is dead code (condition identical to
     the 403 check already run at L7591).

   Known-bug divergences (evident intent implemented per build policy — see the
   inline comments): out_bonus IN/OUT misclassification, dead include_test_users
   filter, export time-parameter bug. Label policy: backend.only_direct_txs,
   backend.include_test_users and backend.financial_difference resolve nowhere in
   committed lang files → sensible labels, marked "label inferred".

   Loads after src/pages/HostReports.jsx, so this RTransactions definition
   overwrites the legacy one (implicit-window-global load-order convention). */

const { useState: hrtxUseState, useMemo: hrtxUseMemo } = React;

/* ---------------- formatting helpers ---------------- */
/* The deterministic PRNG lived here and is gone with the generator. */
const hrtxP2 = (n) => String(n).padStart(2, "0");
const hrtxNum = (n) => Number(n || 0).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
/* transactions.addedTime (unix int) rendered d/m/Y G:i — hour WITHOUT leading zero. */
const hrtxDate = (ts) => { const d = new Date(ts); return `${hrtxP2(d.getDate())}/${hrtxP2(d.getMonth() + 1)}/${d.getFullYear()} ${d.getHours()}:${hrtxP2(d.getMinutes())}`; };
const hrtxIsoToday = () => { const d = new Date(); return `${d.getFullYear()}-${hrtxP2(d.getMonth() + 1)}-${hrtxP2(d.getDate())}`; };
const hrtxTs = (iso, time) => { if (!iso) return null; const [y, m, d] = iso.split("-").map(Number); const [hh, mm, ss] = (time || "0:0:0").split(":").map(Number); return new Date(y, m - 1, d, hh || 0, mm || 0, ss || 0).getTime(); };

/* ---------------- real enums (transactions.type — string, not the numeric
   transaction_history enum, which belongs to the dead Details modal only) ------ */
/* TransactionsController::transactionTypes() L31-51. `revert` shares the
   "Withdraw" label with `out`; add_jackpot/out_jackpot/void_jackpot are
   commented out in the real map. add_bonus_cashback / out_bonus_cashback are
   recognised by the totals (getSumDeposits2) but have NO label in the map. */
const HRTX_TYPE_LABELS = {
  add: "Deposit", out: "Withdraw",
  add_credits: "Credit Deposit", out_credits: "Credit Withdraw",
  add_bonus: "Bonus Deposit", out_bonus: "Bonus Withdraw",
  revert: "Withdraw",
};
/* getSumDeposits2 (TransactionsController L882-897) IN/OUT type sets. */
const HRTX_IN_TYPES = ["add", "add_bonus", "add_bonus_cashback"];
const HRTX_OUT_TYPES = ["out", "out_bonus", "out_bonus_cashback"];
/* Row IN/OUT classification. REAL-PLATFORM BUG (ReportsController L7833-7840):
   the switch($row->type) sets $type only for add / add_bonus / out — `out_bonus`
   (or anything else) inherits $type from the PREVIOUS loop iteration (undefined-
   variable notice on the first row), so bonus-wallet withdrawals can land in the
   IN column depending on row order. Evident intent implemented instead: add /
   add_bonus → IN, everything else → OUT. */
// <!-- SUGGESTION: add an explicit `case 'out_bonus':` (or a default) to the switch at ReportsController.php:7833 so $type never leaks across iterations. -->
const hrtxIsIn = (type) => HRTX_IN_TYPES.includes(type);

/* User Type filter — User::getLevel() minus CUSTOMER_CARE(4)/ADMINISTRATION(6)/
   AFFILIATE(1); unlike the sibling Credit Transactions report, PLAYER(30) is NOT
   excluded here (index.blade.php L136). '0' (Super Admin) is explicitly handled
   by the controller (`$search_usertype || $search_usertype == '0'`, L7743).
   8/10/15/20 names come from backend.usertype_*_new keys, per-skin overridable. */
const HRTX_LEVELS = [
  ["0", "Super Admin"], ["2", "Skin Access"], ["8", "Agent"], ["10", "Promoter"],
  ["15", "Shop"], ["20", "Cashier"], ["30", "Player"],
];
const hrtxLevelName = (lvl) => { const hit = HRTX_LEVELS.find(x => x[0] === String(lvl)); return hit ? hit[1] : `Level ${lvl}`; };

/* Skin select — Auth::user()->getSkins(): super admin sees all skins. Same skin
   roster as the Users screen for cross-page consistency. */
/* `HRTX_SKINS` was eleven invented brand names. Skins come from the database,
   and the filter now carries a skin ID rather than a display name — a name is
   not a key, and two brands may share one. */

/* ---------------- deterministic mock network ----------------
   user_path hierarchy mirrors the real scoping columns; `cashier` links players
   to the account that funds them. `test` marks users.test_user = 1. */
/* WAS `HRTX_USERS` — a hand-written cast of admins, cashiers and players with
   an opening balance each — and `HRTX_LEDGER`, a mulberry32 seeded on a
   constant that produced fourteen days of paired transfers, drifting player
   balances and Spanish operator notes. It even arranged for one cashier
   ("cajanorte") never to be topped up so its balance ran negative and the red
   Balance style had something to style.

   The real thing is `ledger_entries`. A transfer already exists there as the
   pair this report renders, because post_transfer writes one row per side. */
const HRTX_TYPE_BY_ID = { 1: "add", 2: "out", 7: "add_bonus", 8: "out_bonus" };

/* One ledger entry as a report row.

   THE ROW'S OWN USER IS THE REFERENCE. `balance_after` is that user's balance
   after this entry, which is exactly what the Balance column means — so `ref`
   is never a choice, and the payer/receiver split falls out of the sign.

   A positive amount means money arrived: this user received it and the
   counterparty paid. Negative is the mirror. Reversing that would swap every
   Payeer and Receiver on the screen while every total stayed correct. */
const hrtxRow = (e) => {
  const amt = Number(e.amount) || 0;
  const inbound = amt > 0;
  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) || "",
  });
  const self = side(e.user, e.user_id);
  const other = side(e.counterparty, e.counterparty_id);
  const payeer = inbound ? other : self;
  const receiver = inbound ? self : other;
  return {
    id: e.id,
    type: HRTX_TYPE_BY_ID[Number(e.type_id)] || (e.type && e.type.code) || "add",
    amount: Math.abs(amt),
    bal: Number(e.balance_after) || 0,
    ts: e.created_at ? Date.parse(e.created_at) : 0,
    skin: e.skin_id,
    desc: e.description || "",
    payeerId: payeer.id, payeerU: payeer.u,
    receiverId: receiver.id, receiverU: receiver.u,
    refId: self.id, refLvl: self.lvl, refPath: self.path,
    refTest: !!(e.user && e.user.test_user),
  };
};


const hrtxDefaultFilters = () => ({
  range: { from: hrtxIsoToday(), to: hrtxIsoToday(), fromTime: "00:00:00", toTime: "23:59:59" },
  type: "", wallet: "real", desc: "", exportScope: "current",
  /* Parent defaults to EMPTY, not to "1". The invented root had id 1; a real
     database has no guaranteed id 1, and an unset Parent must mean "whatever I
     may see" rather than one account's subtree. */
  skin: "", parent: "", usertype: "", username: "", direct: false, test: false,
});

/* The grid string, rebuilt from the row's own sides — `r.desc` is the
   operator's note, which is all the ledger stores. */
const hrtxDesc = (r) => {
  const tail = r.desc ? ` ${r.desc}` : "";
  switch (r.type) {
    case "add":       return `${r.receiverU} - Deposit (Withdraw from: ${r.payeerU})${tail}`;
    case "out":       return `${r.payeerU} - Withdraw (Deposit to: ${r.receiverU})${tail}`;
    case "add_bonus": return `${r.receiverU} - Bonus Deposit (Withdraw from: ${r.payeerU})${tail}`;
    case "out_bonus": return `${r.payeerU} - Bonus Withdraw (Deposit to: ${r.receiverU})${tail}`;
    default:          return `${r.receiverU} - ${HRTX_TYPE_LABELS[r.type] || r.type}${tail}`;
  }
};

const hrtxFilterRows = (f, rows, users) => {
  const from = hrtxTs(f.range && f.range.from, (f.range && f.range.fromTime) || "00:00:00");
  const to = hrtxTs(f.range && f.range.to, (f.range && f.range.toTime) || "23:59:59");
  if (from == null || to == null) return null; // real endpoint: ajaxError("no dates")
  /* Wallet × Type → allowed transactions.type set (ReportsController L7731-7738):
     real+no type → add/out · bonus+no type → add_bonus/out_bonus ·
     bonus+type → "<type>_bonus" · else → the picked type. */
  const types = f.wallet === "bonus"
    ? (f.type ? [`${f.type}_bonus`] : ["add_bonus", "out_bonus"])
    : (f.type ? [f.type] : ["add", "out"]);
  /* No default parent. The old code fell back to the invented root, so an
     unset Parent silently scoped the report to that account's subtree. With
     none selected the correct scope is whatever RLS already returned. */
  const parent = (users || []).find(u => String(u.id) === String(f.parent)) || null;
  const uname = f.username ? Number(f.username) : 0;
  const q = (f.desc || "").trim().toLowerCase();
  return (rows || []).filter(r => {
    if (r.ts < from || r.ts > to) return false;
    if (!types.includes(r.type)) return false;
    if (f.skin && String(r.skin) !== String(f.skin)) return false; // real: any of ref/payeer/receiver skin matches
    /* ltree is DOTTED. The old comparison was a bare prefix against
       hand-written "/1/5" paths; against real ltree it would also match a
       sibling whose path merely starts with the same characters. */
    if (parent && parent.path && !(r.refPath === parent.path
        || String(r.refPath).startsWith(parent.path + "."))) return false;
    if (f.usertype !== "" && String(r.refLvl) !== String(f.usertype)) return false; // ref_user.user_level ('0' Super Admin included)
    if (uname && r.refId !== uname) return false; // ref_user.id
    if (f.direct) { // L7757-7772: both users set → strictly between them (either direction); one → payeer or receiver equals it
      if (uname) { if (!((r.payeerId === parent.id && r.receiverId === uname) || (r.payeerId === uname && r.receiverId === parent.id))) return false; }
      else if (r.payeerId !== parent.id && r.receiverId !== parent.id) return false;
    }
    /* Include test users is DEAD on the real platform — the server branch body is
       commented out (L7774-7776), so test users are always included there.
       Evident intent implemented: off → hide test_user rows. */
    // <!-- SUGGESTION: restore the commented-out include_test_users branch (ref_user.test_user = 0 when unchecked) or drop the checkbox from the view. -->
    if (!f.test && r.refTest) return false;
    /* Description filter: MySQL full-text MATCH(transactions.description)
       AGAINST(?) over the STORED description text only — the generated
       "<user> - Deposit (…)" summary is not searchable on the real platform.
       Prototype: substring match on the same stored text. */
    if (q && !(r.desc || "").toLowerCase().includes(q)) return false;
    return true;
  });
};

/* ---------------- column set (order = ReportsController L7559-7565) ---------- */
const HRTX_COLS = [
  { key: "id", label: "ID", sortable: true, firstDir: "desc", width: 90 },
  { key: "type", label: "Typology", render: r => HRTX_TYPE_LABELS[r.type] || r.type, width: 120 },
  { key: "desc", label: "Description", render: r => hrtxDesc(r) },
  { key: "in", label: "IN", align: "right", width: 110, render: r => hrtxIsIn(r.type) ? hrtxNum(r.amount) : "", cellClass: r => hrtxIsIn(r.type) ? "hrs-pos" : "" },
  { key: "out", label: "OUT", align: "right", width: 110, render: r => hrtxIsIn(r.type) ? "" : hrtxNum(r.amount), cellClass: r => hrtxIsIn(r.type) ? "" : "hrs-neg" },
  { key: "bal", label: "Balance", align: "right", width: 130, render: r => r.bal == null ? "" : hrtxNum(r.bal), cellClass: r => (r.bal != null && r.bal < 0) ? "hrs-neg" : "" },
  { key: "date", label: "Date", sortable: true, firstDir: "asc", width: 130, render: r => hrtxDate(r.ts) },
];

/* Column show/hide — mirrors the table_settings.blade.php modal. Session-only
   here; the real modal persists to localStorage key
   `transactions_report_table_settings`, and hidden columns are also removed
   from the Excel export (hidden_cols → sheet column delete, L7512-7535). */
const HrtxColSettings = ({ hidden, onToggle }) => {
  const [open, setOpen] = hrtxUseState(false);
  return (
    <div style={{ position: "relative" }}>
      <button className="hrs-btn hrs-btn--search" onClick={() => setOpen(o => !o)}>
        <Icon name="settings" size={14} /> Setting
      </button>
      {open && (
        <>
          <div className="hrs-multi__scrim" onClick={() => setOpen(false)} />
          <div className="hrs-multi__pop" style={{ left: "auto", right: 0 }}>
            <div style={{ fontSize: 12, fontWeight: 700, padding: "4px 6px 8px", color: "var(--text-secondary)" }}>Visible columns</div>
            <div className="hrs-multi__list">
              {HRTX_COLS.map(c => (
                <label key={c.key} className="hrs-multi__opt">
                  <input type="checkbox" checked={!hidden[c.key]} onChange={() => onToggle(c.key)} />
                  <span>{c.label}</span>
                </label>
              ))}
            </div>
            <div style={{ fontSize: 11, color: "var(--text-tertiary)", padding: "6px 6px 2px", lineHeight: 1.45 }}>
              Session-only here — the real modal persists to localStorage and hidden columns are dropped from the export too.
            </div>
          </div>
        </>
      )}
    </div>
  );
};

/* ================================================================
   RTransactions — Report ▾ → Transactions (overrides the legacy
   HostReports.jsx definition via load order)
   ================================================================ */
const RTransactions = () => {
  const [draft, setDraft] = hrtxUseState(hrtxDefaultFilters);
  /* Auto-load with the defaults: the real DataTable fires its first fetch on
     page load with today's range — this screen is NOT a no-auto-load report. */
  const [applied, setApplied] = hrtxUseState(hrtxDefaultFilters);

  /* Declared before the filter list and the row memo that read them —
     in-browser Babel turns a use-before-declaration into `undefined` rather
     than a ReferenceError (tdzcheck). */
  const feed = useHrsFetch(() => window.sb.list("ledger", {
    limit: 5000, filters: { types: [1, 2, 7, 8] },
  }), []);
  const usersFeed = useHrsFetch(() => window.sb.list("networkUsers", { limit: 500 }), []);
  const skinsFeed = useHrsFetch(() => window.sb.list("skins", { limit: 200 }), []);

  const rows = hrtxUseMemo(() => (feed.data || []).map(hrtxRow), [feed.data]);
  const opUsers = hrtxUseMemo(() => (usersFeed.data || []).map(u => ({
    id: u.id, u: u.username, lvl: Number(u.user_level), path: String(u.path || ""),
  })), [usersFeed.data]);
  const [sort, setSort] = hrtxUseState({ key: "id", dir: "desc" }); // JS order [[0,"desc"]]
  const [page, setPage] = hrtxUseState(0);
  const [pageSize, setPageSize] = hrtxUseState(50); // real pageLength 50, lengthMenu [5,10,25,50]
  const [hidden, setHidden] = hrtxUseState({});

  const fields = hrtxUseMemo(() => {
    const parent = opUsers.find(u => String(u.id) === String(draft.parent)) || null;
    const unameOpts = opUsers
      .filter(u => !parent || !parent.path || u.path === parent.path || String(u.path).startsWith(parent.path + "."))
      .filter(u => draft.usertype === "" ? true : String(u.lvl) === String(draft.usertype))
      .map(u => ({ value: String(u.id), label: `${u.u} (${hrtxLevelName(u.lvl)})` }));
    return [
      { key: "range", label: "From / To", type: "daterange", withTime: true, icon: "calendar", grow: true,
        defaultValue: hrtxDefaultFilters().range,
        tip: <>Defaults to today, 00:00:00 → 23:59:59. Dates are required — the real endpoint replies <code>ajaxError("no dates")</code> without them.</> },
      { key: "type", label: "Transaction Type", type: "select", icon: "tag", placeholder: "-ALL-",
        options: [{ value: "add", label: "Deposit" }, { value: "out", label: "Withdraw" }],
        tip: <>Only <code>add</code> / <code>out</code> are offered (the view loops the full type map but skips the rest). With Bonus Wallet selected the server maps the choice to its <code>_bonus</code> variant.</> },
      { key: "wallet", label: "Wallet", type: "select", icon: "wallet", defaultValue: "real",
        options: [{ value: "real", label: "Real Wallet" }, { value: "bonus", label: "Bonus Wallet" }],
        tip: <>Real → <code>add</code>/<code>out</code> rows · Bonus → <code>add_bonus</code>/<code>out_bonus</code> rows.</> },
      { key: "desc", label: "Description", type: "text", icon: "search", placeholder: "Description",
        tip: <>Full-text <code>MATCH … AGAINST</code> over the stored description text only — the generated "user - Deposit (…)" summary is not searchable.</> },
      { key: "exportScope", label: "Export", type: "select", icon: "download", defaultValue: "current",
        options: [{ value: "current", label: "Current Page" }, { value: "all", label: "All Pages" }],
        tip: <>Current Page sends the DataTable offset/limit; All Pages exports the whole filtered set. On the real platform this select is hidden from Customer Care without <code>support_export</code>.</> },
      { key: "skin", label: "Skin", type: "select", icon: "flag", placeholder: "- Select -",
        options: ((skinsFeed.data || [])).map(k => ({ value: String(k.id), label: k.name })),
        tip: <>Rendered only for super admins / Customer Care on the real page. Matches when any of the row's three joined users belongs to the skin.</> },
      { key: "parent", label: "Parent", type: "select", icon: "users", defaultValue: "1",
        options: opUsers.filter(u => u.lvl !== 30).map(u => ({ value: String(u.id), label: `${u.u} (${hrtxLevelName(u.lvl)})` })),
        tip: <>select2 AJAX on the real page (levels 0/2/8/10/15/20). Defaults to yourself (Customer Care / Administration: your parent); disabled for Affiliates. Scopes rows to the reference user's subtree.</> },
      { key: "usertype", label: "User Type", type: "select", icon: "user", placeholder: "- Select -",
        options: HRTX_LEVELS.map(([v, l]) => ({ value: v, label: l })),
        tip: <>Filters the row's reference user level. Unlike Credit Transactions, Player is selectable here; Super Admin ('0') is explicitly handled.</> },
      { key: "username", label: "Username", type: "select", icon: "user", placeholder: "-ALL-",
        options: unameOpts,
        tip: <>select2 AJAX on the real page, scoped to the chosen Parent and User Type; disabled for Affiliates. Filters reference user id.</> },
      { key: "direct", label: "Only direct txs", type: "toggle",
        tip: <>Label inferred — <code>backend.only_direct_txs</code> resolves nowhere in committed lang files. With Parent + Username set: only rows directly between the two (either direction); otherwise rows where the Parent is payeer or receiver.</> },
      { key: "test", label: "Include test users", type: "toggle",
        tip: <>Label inferred — <code>backend.include_test_users</code> resolves nowhere in committed lang files. Dead on the real platform (server branch commented out); the evident intent is implemented here.</> },
    ];
  }, [draft.parent, draft.usertype]);

  const filteredOrNull = hrtxUseMemo(() => hrtxFilterRows(applied, rows, opUsers), [applied, rows, opUsers]);
  /* An empty result and a failed read are the same picture once the rows are
     mapped, and the totals row underneath would report zeros for a request
     that never came back. */
  const feedBusy = feed.loading || usersFeed.loading;
  const feedErr = feed.error || usersFeed.error || skinsFeed.error;
  const filtered = filteredOrNull || [];
  const sorted = hrtxUseMemo(() => {
    const dir = sort.dir === "asc" ? 1 : -1;
    return [...filtered].sort((a, b) => (sort.key === "date" ? a.ts - b.ts : a.id - b.id) * dir);
  }, [filtered, sort]);

  const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize));
  const curPage = Math.min(page, totalPages - 1);
  const pageRows = sorted.slice(curPage * pageSize, (curPage + 1) * pageSize);

  /* IN / OUT / Difference — getSumDeposits2 on a clone of the fully-filtered
     query BEFORE offset/limit: whole filtered set, not the visible page. */
  const totIn = filtered.reduce((a, r) => a + (HRTX_IN_TYPES.includes(r.type) ? r.amount : 0), 0);
  const totOut = filtered.reduce((a, r) => a + (HRTX_OUT_TYPES.includes(r.type) ? r.amount : 0), 0);

  const onChange = (k, v) => setDraft(d => {
    const n = { ...d, [k]: v };
    if (k === "parent" || k === "usertype") n.username = ""; // select2 repopulates on scope change
    return n;
  });
  const onSearch = (v) => {
    if (!(v.range && v.range.from && v.range.to)) hrsToast("no dates", "Dates are required — the real endpoint replies ajaxError(\"no dates\").");
    setApplied({ ...v });
    setPage(0);
  };

  /* Export — real: same endpoint with action=excel in a new window, XLSX
     `transactions_report.xlsx` (filter-summary header row + green/red totals
     row, hidden columns deleted). Prototype: CSV of the same grid columns,
     honoring the Settings-modal hidden columns like the real hidden_cols.
     REAL-PLATFORM BUG (transactions.js L119-120): the export payload reads
     `#search_time_start` (nonexistent — the element id is `serach_time_start`)
     and sends `serach_time_end` while the controller reads `search_time_end`,
     so exports always cover 00:00:00-23:59:59 regardless of the chosen times.
     Evident intent implemented: the export honors the applied times. (The real
     driver also re-scrapes the DOM at click time; here the export matches the
     applied search, i.e. what the grid shows.) */
  // <!-- SUGGESTION: fix the export payload ids in public/js/pages/transactions/transactions.js (serach_time_start / search_time_end) so the chosen times reach the controller. -->
  const exportRows = draft.exportScope === "current" ? pageRows : sorted;
  const visCols = HRTX_COLS.filter(c => !hidden[c.key]);
  const doCsv = () => hrsCsv(
    exportRows,
    visCols.map(c => ({ key: c.key, label: c.label, get: (r) => c.render ? c.render(r) : r[c.key] })),
    "transactions_report.csv"
  );

  return (
    <HrsShell
      title="Transactions"
      subtitle="Report ▾ · host money-movement ledger (MySQL transactions table)"
      gate={["support_report", "support_report_transactions"]}
      gateNote={<> Gates bind Customer Care only — every other role passes; the controller forces the 403 by authorizing the deliberately nonexistent ability <code>asdasdas</code>. Export is additionally hidden from Customer Care without <code>support_export</code>.</>}
      explainer={{
        bullets: [
          <>Every row is one movement on the <code>transactions</code> transfer ledger — money moved between network accounts (deposits, withdraws, bonus-wallet movements). Player game history lives elsewhere (<code>transaction_history</code>).</>,
          <>Real Wallet shows <code>add</code>/<code>out</code> rows; Bonus Wallet shows <code>add_bonus</code>/<code>out_bonus</code>. A transfer writes two rows — an <b>IN</b> for the receiver and a mirrored <b>OUT</b> for the payer.</>,
          <>The IN / OUT / Difference boxes total the <b>whole filtered set</b> (computed before pagination), not the visible page.</>,
          <>The grid loads today's range on page open and re-queries on Search; only ID and Date are sortable.</>,
        ],
      }}
      actions={<HrtxColSettings hidden={hidden} onToggle={(k) => setHidden(h => ({ ...h, [k]: !h[k] }))} />}
    >
      <HrsFilters
        fields={fields}
        values={draft}
        onChange={onChange}
        onSearch={onSearch}
        resultLabel={`${hrsInt(sorted.length)} rows`}
      />

      {feedErr && <HrsError error={feedErr} onRetry={() => { feed.retry(); usersFeed.retry(); skinsFeed.retry(); }} />}
      {feedBusy && <HrsSkeleton rows={8} cols={7} />}

      <HrsTable
        columns={HRTX_COLS.map(c => ({ ...c, hidden: !!hidden[c.key] }))}
        rows={feedBusy || feedErr ? [] : pageRows}
        sort={sort}
        onSort={(next) => { setSort(next); setPage(0); }}
        rowKey="id"
        renderCard={r => (
          <>
            <div className="hrs-card__top">
              <b>{HRTX_TYPE_LABELS[r.type] || r.type}</b>
              <span className={hrtxIsIn(r.type) ? "hrs-pos" : "hrs-neg"} style={{ padding: "1px 8px", borderRadius: 4 }}>
                {hrtxIsIn(r.type) ? "+" : "−"}{hrtxNum(r.amount)}
              </span>
            </div>
            <div className="hrs-card__grid">
              <span>ID</span><b>{r.id}</b>
              <span>Date</span><b>{hrtxDate(r.ts)}</b>
              <span>Balance</span><b className={r.bal < 0 ? "hrs-neg" : ""}>{hrtxNum(r.bal)}</b>
              <span>Description</span><b style={{ fontWeight: 500, textAlign: "right" }}>{hrtxDesc(r)}</b>
            </div>
          </>
        )}
        empty="No data available in table"
      />

      {/* backend.financial_in → IN (green) · backend.financial_out → OUT (red) ·
          backend.financial_difference resolves nowhere in committed lang →
          "Difference" (label inferred), amber like the real #ffc107 box. */}
      <HrsBars items={[
        { label: "IN", value: hrtxNum(totIn), tone: "in", tip: <>Sum of <code>add</code> / <code>add_bonus</code> / <code>add_bonus_cashback</code> amounts across the whole filtered set.</> },
        { label: "OUT", value: hrtxNum(totOut), tone: "out", tip: <>Sum of <code>out</code> / <code>out_bonus</code> / <code>out_bonus_cashback</code> amounts across the whole filtered set.</> },
        { label: "Difference", value: hrtxNum(totIn - totOut), tone: "net", tip: <>IN − OUT over all pages of the filtered set. Label inferred — <code>backend.financial_difference</code> resolves nowhere in committed lang files.</> },
      ]} />

      <HrsExport
        onCsv={doCsv}
        filename="transactions_report.csv"
        gate="support_export"
        count={exportRows.length}
        note={<>Scope: {draft.exportScope === "current" ? "Current Page" : "All Pages"}. Real export is XLSX with a filter-summary header row and a colored totals row; hidden columns are excluded here like the real <code>hidden_cols</code>.</>}
      />

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

window.RTransactions = RTransactions;
