// Represents: GET /commissions_payments · Admin/CommissionPaymentController — see docs/ISYSTEM_REFERENCE.md §Batch 3 "Commissions payments"
/* Commissions ▾ → Commission Payments. THIS SCREEN MOVES REAL MONEY.
   ------------------------------------------------------------------------------------------------
   Real endpoints simulated here:
     GET  /commissions_payments/            index()  L50   — the page (generics/index.blade.php)
     GET  /commissions_payments/rows/       rows()   L83   — serverSide DataTables feed
     GET  /commissions_payments/{id}        edit()   L253  — edit-modal body
     POST /commissions_payments/pay/{id}    pay()    L226  — single payment (CSRF POST via doBan)
     POST /commissions_payments/{id?}       store()  L263  — save amount + note
   Views: admin/generics/index.blade.php + filters/commissions_payments.blade.php +
   models/commission_payment.blade.php; JS driver public/js/pages/generic/commissions_payments.js
   (+ shared banConfirm/doBan in public/js/custom.js:58/104). Page title =
   Str::plural(__('backend.extras.commissions_payments')) — main_label is set twice at index() L74/L78,
   the second one wins.

   WHAT MOVES THE MONEY (CommissionsController::payCommissions L1204-1245 → performPayout L1252-1283):
     1. pay()/bulk force `payable = true` and save.
     2. `amount <= 0` is rejected outright — no claim, no transfer.
     3. IDEMPOTENT ATOMIC CLAIM:
            UPDATE commissions_payments SET paid = 1, pay_time = <now> WHERE id = ? AND paid = 0
        0 rows affected ⇒ somebody already paid this row ⇒ skip. The in-repo comment is explicit:
        a crash mid-flight under-pays, it never double-pays. That ordering is preserved verbatim
        below (hcpayAttempt) — the claim happens BEFORE the transfer, and an already-paid row can
        never be paid a second time.
     4. performPayout() builds a per-game_type description and calls TransferController::processTransfer
        (TransferController.php:516, `add` case L571-698) with type='add', system='commissions',
        payeer_id = the RECIPIENT'S SKIN-ADMIN ANCESTOR (User::getMyParent(ADMIN_LEVEL, user_id)) and
        api_key = that admin's api_key. So the money leaves the skin admin's own wallet, not a
        platform float account.
     5. processTransfer: DB transaction + lockForUpdate on both users, payer must satisfy
        balance + credits >= amount, payer.balance -= amount, receiver.balance += amount,
        available_balance recomputed as credits + balance, and TWO mirrored `transactions` rows are
        written (payer `out`, receiver `add`). A `transaction_history` row is written ONLY when the
        receiver is a PLAYER (user_level 30) — commission recipients are agents/shops, so in practice
        no history row is produced. Same engine, same mechanics as src/pages/HostDeposit.jsx (§Batch 5
        "Deposit"), which documents the engine-side defects that live below this screen and are not
        reproducible from its markup (swallowed rollback, no idempotency key, api_key only checked
        against *some* users row, no-op saveLog).
     6. Non-`ok` response ⇒ THE CLAIM IS RELEASED (paid = 0, pay_time = null), `details` keeps the JSON
        error and Log::error fires; the operator sees backend.extras.something_went_wrong =
        "Something went wrong". Success ⇒ `details` keeps the JSON response.

   Rows are NOT created here. Crons create them: GET /crons/payMonthlyCommissions → payMonthlyCommissions()
   (previous commission month from getDateCalendario(); per user × game_type sport_profit/casino/
   casinolive/virtual; `payable` seeded from the commission profile's auto_pay; rows carrying
   edit_user_id are skipped by recalculation; every payable && !paid row for the period is then
   auto-paid), GET /crons/payWeeklyCommissions (same routine, commission_type='weekly'), and
   GET /crons/payTurnover → payTurnover() (weekly `sport_turnover` rows for agents whose sport profile
   tipologia ∈ [1,3]). The Period column only displays the stored "Y-m-d,Y-m-d" string — there is NO
   period picker on this screen, so hrsPeriodOptions("commission") / ("week") are used below to
   generate the mock rows' periods instead of as a filter: they reproduce the real first-Monday →
   day-before-next-first-Monday commission months (getDateCalendario) and the Mon–Sun turnover weeks.

   KNOWN-BUG POLICY (CLAUDE.md) — divergences implemented, each flagged at its call site:
   (1) Bulk "Pay All" / "Pay Selected" are, on the real platform, SIDE EFFECTS OF THE GET /rows LISTING
       REQUEST: the JS stuffs hidden inputs pay_all_hdn=1 / pay_selecteds_hdn=<csv ids> into the
       DataTables column-search values, redraws, then clears them (commissions_payments.js L139-157);
       rows() L147-172 then pays synchronously inside that GET. No CSRF, no POST semantics, and a
       browser refresh with those params still attached re-fires the attempt — the only thing standing
       between that and a double payment is the paid=0 claim. For a money-moving action that is a real
       defect, so the evident intent is implemented here: an explicit, confirmed, POST-style action
       (HcpayPayModal) that states amount, recipient and period before anything moves.
   (2) The listing filters are loose LIKEs — ID is a substring match ("4" returns 244), User Type is
       LIKE '%v%' on users.user_level so "2" (Skin Access) also returns every 20 (Shop) row, and
       Commission Type "casino" also returns "casinolive" rows. Exact matching is implemented instead;
       each field's Tip says what the real one does.
   Preserved faithfully, NOT "fixed": the atomic claim and its error text ("Payment status Paid"), the
   amount <= 0 rejection, the claim release on transfer failure, `payable` being forced true before any
   attempt, sortability limited to Username and Skin, the raw untranslated `commissions.casinolive`
   label, and the absence of KPIs/export/create — this screen genuinely has none.

   <!-- SUGGESTION: move bulk Pay All / Pay Selected off the GET /commissions_payments/rows listing request onto a real POST endpoint (admin.commission_payment.pay_bulk) with CSRF + a per-row can('pay') re-check, and drop the pay_all_hdn / pay_selecteds_hdn hidden DataTables columns. Today a refresh of the rows URL with those params still attached re-fires a money movement, and the only protection is the paid=0 claim. -->
   <!-- SUGGESTION: tighten the three loose listing filters in rows() — exact match on commissions_payments.id, exact match on users.user_level (today "2" also matches 20), and exact match on game_type (today "casino" also matches "casinolive"). Add a `casinolive` option to the Commission Type select while you are there, since rows of that type exist and cannot currently be isolated. -->
   <!-- SUGGESTION: either delete the create path or secure it. POST /commissions_payments with no id is live (routes/admin.php:1701), validates only amount+note, never calls authorize() (that only fires when $id is present, controller L300 "TO DO: check this functionality") and would insert a row with no user_id / period / game_type. The UI cannot reach it, but the endpoint can. -->
   <!-- SUGGESTION: restore the commented-out per-column order block (rows() L134-137) and rename the registered `user_level` column to `user_type` so Period, Amount, Status and Last Update become sortable; today every column except Username and Skin silently falls back to id DESC, and the payable_label→payable / status→paid remaps are dead code. -->
   <!-- SUGGESTION: add the missing translation keys used by this screen — commissions.casinolive and commissions.poker (rows of the first type render the raw key today), plus backend.commissions_payments, backend.last_update, backend.paid, backend.select_all, backend.pay_selected, backend.pay_all and backend.commission_payment. -->
   <!-- SUGGESTION: the Amount column resolves the currency with User::find(User::getMyParent(ADMIN_LEVEL, user_id))->currency per row — an N+1 over the whole page. Resolve the skin-admin ancestor once per skin in rows() and reuse it. -->
   <!-- SUGGESTION: CommissionPaymentPolicy::viewAny checks checkUserBoPerm($user->id, "support_commission") twice with && on the same line (Policy L22) — harmless, but drop the duplicate. --> */

const { useState: hcpayUseState, useMemo: hcpayUseMemo } = React;

/* Deterministic PRNG (FNV-1a + mulberry32) — same convention as the sibling Host pages, so the
   ledger renders identically on every load and the narrative rows below stay pinned. */
const hcpayHash = (s) => { let h = 2166136261 >>> 0; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } return h >>> 0; };
const hcpayRng = pbRng;   // was a local copy of mulberry32 — see pbRng in src/data.jsx
/* UsersController::usersLevels() display names (Master/Agent/Promoter/Shop are per-skin overridable
   via custom_*_name — defaults used). Same map as HostReportCommissions.jsx's HRCM_LEVELS, widened
   to every level the User Type filter offers. */
const HCPAY_LEVELS = {
  0: "Super Admin", 1: "Affiliate", 2: "Skin Access", 4: "Customer Care", 6: "Administration",
  8: "Master", 9: "Regulation User", 10: "Agent", 15: "Promoter", 20: "Shop", 30: "Player",
};
/* Option order mirrors usersLevels() as the real select renders it. */
const HCPAY_LEVEL_OPTS = [6, 1, 4, 30, 20, 15, 10, 8, 2, 0, 9].map(v => ({ value: String(v), label: HCPAY_LEVELS[v] }));

/* game_type → __('commissions.<value>'). `casinolive` has NO key in the repo's default lang, so the
   real screen prints the raw key — reproduced honestly rather than silently translated. */
const HCPAY_GAME_LABEL = {
  sport_profit: "Sport GGR",
  sport_turnover: "Sport Turnover",
  casino: "Casino GGR",
  casinolive: "commissions.casinolive",
  virtual: "Virtual GGR",
};
/* The filter select offers only these four — there is deliberately no `casinolive` option upstream. */
const HCPAY_GAME_FILTER = [
  { value: "casino", label: "Casino GGR" },
  { value: "sport_profit", label: "Sport GGR" },
  { value: "sport_turnover", label: "Sport Turnover" },
  { value: "virtual", label: "Virtual GGR" },
];
/* commission_type → __('backend.extras.<value>'), column labelled "Periodicity" (commissions.periodicity). */
const HCPAY_PERIODICITY = { monthly: "Monthly", weekly: "Weekly" };

const HCPAY_PAGE_SIZES = [5, 10, 25, 50]; // commissions_payments.js lengthMenu; pageLength 50

/* Skins the acting user can see (rows() scopes by Auth::user()->getSkinIDS()). Names + currencies are
   the ones data.jsx BRANDS / HostSkins.jsx already configure, so a skin means the same thing on every
   screen of this build. `admin` is the skin-admin ancestor resolved by
   User::getMyParent(ADMIN_LEVEL, user_id) — it is both the currency source for the Amount column and
   the wallet every payment is debited from. */
const hcpayDmy = (iso) => { const p = (iso || "").split("-"); return p.length === 3 ? `${p[2]}/${p[1]}/${p[0]}` : ""; };
/* `d/m/Y G:i:s` — G is the hour WITHOUT a leading zero, which is why the real Last Update column
   prints "02/03/2026 8:31:01" rather than "08:31:01". */
const hcpayStamp = (iso, h, m, s) => {
  const [y, mo, d] = (iso || "").split("-");
  return { text: `${d}/${mo}/${y} ${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`, ts: Date.parse(`${iso}T00:00:00Z`) / 1000 + h * 3600 + m * 60 + s };
};
const hcpayNextDay = (iso) => { const d = new Date(`${iso}T00:00:00Z`); d.setUTCDate(d.getUTCDate() + 1); return d.toISOString().slice(0, 10); };
/* Last Update stamp for rows touched in this session — same d/m/Y G:i:s shape as the cron-written ones. */
const hcpayNow = () => {
  const d = new Date();
  const p = (n) => String(n).padStart(2, "0");
  return { text: `${p(d.getDate())}/${p(d.getMonth() + 1)}/${d.getFullYear()} ${d.getHours()}:${p(d.getMinutes())}:${p(d.getSeconds())}`, ts: Math.floor(d.getTime() / 1000) };
};

/* performPayout() description. The reference quotes one variant verbatim —
   "Commissions on GGR Sport (dd/mm/YYYY - dd/mm/YYYY)" — the others follow its shape. */
const HCPAY_DESC_HEAD = {
  sport_profit: "Commissions on GGR Sport",
  casino: "Commissions on GGR Casino",
  casinolive: "Commissions on GGR Casino Live",
  virtual: "Commissions on GGR Virtual",
  sport_turnover: "Commissions on Turnover Sport",
};
const hcpayDesc = (r) => {
  const [a, b] = r.period.split(",");
  return `${HCPAY_DESC_HEAD[r.game_type] || "Commissions"} (${hcpayDmy(a)} - ${hcpayDmy(b)})`;
};

const hcpayJson = (o) => JSON.stringify(o, null, 2);


/* THE BRAND LIST AND THE NETWORK BEHIND IT ARE GONE — invented brands with
   invented admin wallets, and an invented set of operators with per-user
   scales and auto-pay flags. The brand filter is a `skins` feed. */

/* THE PAYMENT ROWS WERE GENERATED, INCLUDING WHICH ONES HAD BEEN PAID.
   `hcpayBuildRows` invented four commission months and three turnover weeks,
   an amount per (user, game type) from a PRNG, and then set `paid` from a
   hardcoded rule — with two named exceptions written in by hand: "lucca1's
   newest Casino Live row settled at exactly 0.00" and "the one funds failure in
   the set: gb24admin's wallet holds 1,850.00 EUR". A settlement screen showing
   which commissions have been paid, from a generator with scripted failures.

   `commission_payments` (007) is the real table and carries every column the
   screen reads: period, amount, currency, payable, paid, paid_at, the
   game_type, the periodicity — and `ledger_entry_id`, which is the difference
   between "marked paid" and "money moved". `commission_settlement_drift` exists
   to find rows where those two disagree. */
const hcpayRowFromDb = (r) => {
  const u = r.user || {};
  const sk = r.skin || {};
  return {
    id: Number(r.id),
    period: `${r.period_start},${r.period_end}`,
    periodStart: r.period_start,
    periodEnd: r.period_end,
    skin: sk.name || "",
    skinId: r.skin_id == null ? null : Number(r.skin_id),
    cur: r.currency || sk.currency || "",
    /* The PAYER is the brand's admin account. It is not on this table — the
       payout is made from whoever runs the payment — so it stays null rather
       than being assumed to be the skin admin, which was hardcoded before. */
    payer: null,
    user: u.username || String(r.user_id),
    userId: r.user_id == null ? null : Number(r.user_id),
    level: u.user_level == null ? null : Number(u.user_level),
    amount: Number(r.amount) || 0,
    commission_type: r.periodicity || "",
    game_type: r.game_type || "",
    /* 0/1 rather than booleans: every filter and comparison on this screen
       already speaks in those, and upstream stores tinyints. */
    paid: r.paid ? 1 : 0,
    payable: r.payable ? 1 : 0,
    pay_time: r.paid_at ? Date.parse(r.paid_at) : null,
    updated: { ts: Date.parse(r.updated_at || r.created_at) || 0 },
    /* MONEY MOVED, OR NOT. A row marked paid with no ledger entry is a
       settlement nobody funded — shown rather than smoothed over. */
    ledgerEntryId: r.ledger_entry_id == null ? null : Number(r.ledger_entry_id),
    details: r.details || null,
    edit_user_id: null,
    note: r.note || "",
    _raw: r,
  };
};

const HCPAY_BLANK = { skin: "", status: "", level: "", id: "", username: "", game: "" };

/* =================================================================================================
   The payment attempt — payCommissions() → performPayout() → processTransfer(), in that order.
   Returns a result record; it never mutates, the caller applies the state changes.
   ================================================================================================= */
const hcpayAttempt = (row, payer, recvBal, seq) => {
  const base = { id: row.id, row, desc: hcpayDesc(row) };

  // payCommissions L1206: amount <= 0 is rejected before anything is claimed.
  if (row.amount <= 0) {
    return { ...base, outcome: "rejected", claimed: false, message: "Amount must be greater than zero" /* label inferred — the guard returns early without a backend.* key */ };
  }

  /* ATOMIC CLAIM — UPDATE … SET paid = 1, pay_time = ? WHERE id = ? AND paid = 0.
     0 rows affected means the row was already settled: the attempt is skipped, never retried.
     pay() surfaces exactly this text on a single payment (controller L230-231). */
  if (row.paid === 1) {
    return { ...base, outcome: "already", claimed: false, message: "Payment status Paid" };
  }

  // processTransfer `add`: payer must satisfy balance + credits >= amount (TransferController L589-597).
  if (payer.bal + payer.cr < row.amount) {
    // EMBED-OK: `row` is a mapped row — `user` is a username string.
    const details = { status: "error", message: "not enough balance", type: "add", system: "commissions", payeer: row.payer, receiver: row.user, amount: row.amount.toFixed(2), currency: row.cur };
    // Claim released: paid back to 0, pay_time null, details keeps the JSON error, Log::error fires.
    return { ...base, outcome: "failed", claimed: true, released: true, details, message: "Something went wrong" /* backend.extras.something_went_wrong */ };
  }

  const newPayer = Math.round((payer.bal - row.amount) * 100) / 100;
  const newRecv = Math.round(((recvBal || 0) + row.amount) * 100) / 100;
  // EMBED-OK: `row` is a mapped row — `user` is a username string.
  const details = { status: "ok", type: "add", system: "commissions", payeer: row.payer, receiver: row.user, amount: row.amount.toFixed(2), currency: row.cur, transaction_id: seq + 1 };
  return {
    ...base, outcome: "paid", claimed: true, details, newPayer, newRecv,
    tx: [
      { tx: seq, who: row.payer, side: "Payer · skin admin", type: "out", amt: -row.amount, col: "ref_new_balance", newVal: newPayer },
      // EMBED-OK: `row` is a mapped commission-payment row — `user` is the username string the mapper produced, not the embedded user object.
      { tx: seq + 1, who: row.user, side: "Receiver", type: "add", amt: row.amount, col: "ref_new_balance", newVal: newRecv },
    ],
  };
};

/* =================================================================================================
   Presentational pieces
   ================================================================================================= */
const HcpayStatusPill = ({ paid }) => (
  <span className={`hcpay-status hcpay-status--${paid ? "paid" : "topay"}`}>
    {paid ? <Icon name="check" size={11} /> : <span className="hcpay-status__dot" />}
    {paid ? "Paid" : "To pay"}
  </span>
);

/* Modal chrome — shared .bp-modal, full-screen on mobile (brief §11). The real modal is
   generaModalGestione() → admin/utils/modal.blade.php with the body AJAX-loaded from
   GET /commissions_payments/{id}. */
const HcpayModal = ({ title, icon, onClose, children, footer, wide, tone }) => (
  <div className="bp-modal-scrim hcpay-scrim" onClick={onClose}>
    <div className={`bp-modal hcpay-modal${wide ? " hcpay-modal--wide" : ""}`} onClick={e => e.stopPropagation()}>
      <div className={`hcpay-modal__head${tone ? ` hcpay-modal__head--${tone}` : ""}`}>
        <div className="hcpay-modal__title">{icon && <Icon name={icon} size={17} />}{title}</div>
        <button className="hcpay-x" title="Close" onClick={onClose}><Icon name="x" size={14} /></button>
      </div>
      <div className="hcpay-modal__body">{children}</div>
      {footer && <div className="hcpay-modal__foot">{footer}</div>}
    </div>
  </div>
);

/* The three Customer-Care permissions this screen splits across. Documentation, not a control:
   the demo persona is the super admin, for whom the policy short-circuits on SUPERADMIN. */
const HcpayGates = () => (
  <div className="hcpay-gates">
    <div className="hcpay-gates__cap">
      <Icon name="shield" size={13} />
      Who can do what here — <code>CommissionPaymentPolicy</code> splits this screen across three
      Customer-Care permissions, and every one of them also needs the row to be unpaid.
    </div>
    <div className="hcpay-gates__row hcpay-gates__row--head">
      <span>Back-office permission</span><span>Unlocks</span><span>Also satisfied by</span>
    </div>
    <div className="hcpay-gates__row">
      <span><code>support_commission</code></span>
      <span>Seeing the screen at all — sidebar entry (a) and <code>CommissionPaymentPolicy::viewAny</code>.</span>
      <span>Skin admin of the same skin · Super admin</span>
    </div>
    <div className="hcpay-gates__row">
      <span><code>support_commission_edit_payments</code></span>
      <span>The <b>Edit</b> action (<code>update</code> policy) — and only while the row is unpaid.</span>
      <span>Skin admin of the same skin · Super admin</span>
    </div>
    <div className="hcpay-gates__row">
      <span><code>support_commission_payments</code></span>
      <span>The <b>Pay</b> action, single and bulk (<code>pay</code> policy) — unpaid rows only.</span>
      <span>Skin admin of the same skin · Super admin</span>
    </div>
  </div>
);

/* =================================================================================================
   Edit modal — GET /commissions_payments/{id} → POST /commissions_payments/{id}
   Left column is read-only row info; only amount and note are editable.
   ================================================================================================= */
const HcpayEditModal = ({ row, onClose, onSave }) => {
  const [amount, setAmount] = hcpayUseState(String(row.amount));
  const [note, setNote] = hcpayUseState(row.note || "");
  const [errs, setErrs] = hcpayUseState(null);

  const save = () => {
    /* Inline Validator::make in store() L265-287 — amount: required|gt:0, note: required.
       Failure returns ajaxError JSON with a `campierrati` key list, which the modal paints onto the
       offending fields. The messages themselves are __('backend.*') keys that do not resolve in the
       committed lang files. */
    const e = {};
    const n = parseFloat(String(amount).replace(/,/g, ""));
    if (String(amount).trim() === "") e.amount = "Insert an amount"; // label inferred
    else if (!isFinite(n)) e.amount = "The amount must be a number"; // label inferred
    else if (!(n > 0)) e.amount = "The amount must be greater than 0"; // label inferred (rule: gt:0)
    if (!note.trim()) e.note = "Insert a note"; // label inferred
    if (Object.keys(e).length) { setErrs(e); return; }
    onSave(row, n, note.trim());
  };

  const info = [
    ["Period", <span className="hcpay-period">{row.period.replace(",", ", ")}</span>],
    ["Skin", row.skin],
    ["Username", row.user],
    ["User Type", HCPAY_LEVELS[row.level]],
    ["Amount", `${hrsMoney(row.amount, row.cur)}`],
    ["Periodicity", HCPAY_PERIODICITY[row.commission_type]],
    ["Commission Type", HCPAY_GAME_LABEL[row.game_type]],
    ["Status", <HcpayStatusPill paid={row.paid === 1} />],
    ["Payable", row.payable ? "Yes" : "No"],
  ];

  return (
    <HcpayModal
      title={`Commission Payment #${row.id}`} /* label inferred — backend.commission_payment does not resolve */
      icon="edit" onClose={onClose} wide
      footer={<>
        <button className="btn btn--secondary" onClick={onClose}>Cancel</button>
        <button className="btn btn--primary" onClick={save}><Icon name="check" size={13} /> Save</button>
      </>}>

      {errs && (
        <div className="hcpay-alert hcpay-alert--err">
          <Icon name="alert" size={14} />
          <div>Some fields need attention.{/* ajaxError + campierrati */}</div>
        </div>
      )}

      <div className="hcpay-editgrid">
        <div className="hcpay-info">
          <div className="hcpay-info__cap">Row data<Tip>Read-only in the real modal too — everything except amount and note comes from the cron that generated the row.</Tip></div>
          {info.map(([k, v]) => (
            <div className="hcpay-info__r" key={k}><span>{k}</span><b>{v}</b></div>
          ))}
        </div>

        <div className="hcpay-form">
          <div className="hcpay-field">
            <label>Amount <span className="hcpay-req">*</span></label>
            <div className="hcpay-amtwrap">
              <input className={`input${errs && errs.amount ? " hcpay-inv" : ""}`} inputMode="decimal"
                value={amount} onChange={e => { setAmount(e.target.value); setErrs(null); }} />
              <span className="hcpay-cur">{row.cur}</span>
            </div>
            {errs && errs.amount && <div className="hcpay-err">{errs.amount}</div>}
            <div className="hcpay-hint">
              Changing the amount stamps <code>edit_user_id</code> on the row. From that moment every
              recalculation cron skips it — <code>payMonthlyCommissions</code> / <code>payWeeklyCommissions</code> /
              <code> payTurnover</code> all guard on <code>whereNotNull('edit_user_id')</code>, so the figure you
              save here is final until someone pays it.
            </div>
          </div>

          <div className="hcpay-field">
            <label>Note <span className="hcpay-req">*</span></label>{/* commissions.note */}
            <textarea className={`input hcpay-ta${errs && errs.note ? " hcpay-inv" : ""}`} rows={4}
              value={note} onChange={e => { setNote(e.target.value); setErrs(null); }} />
            {errs && errs.note && <div className="hcpay-err">{errs.note}</div>}
          </div>

          <div className="hcpay-note hcpay-note--info">
            <Icon name="lock" size={13} />
            <span>Editing is blocked once a row is paid — <code>CommissionPaymentPolicy::update</code> requires
              <b> !paid</b> on top of the role/permission check, which is why the pencil disappears from settled rows.</span>
          </div>
        </div>
      </div>
    </HcpayModal>
  );
};

/* =================================================================================================
   Payment modal — the confirm step for single AND bulk, plus the receipt.
   DIVERGENCE (known-bug policy): on the real platform only the SINGLE payment gets a confirm
   (banConfirm → POST /commissions_payments/pay/{id}); Pay All / Pay Selected fire silently as a side
   effect of the GET /rows listing request. Both are routed through this explicit confirm here.
   ================================================================================================= */
const HcpayPayModal = ({ job, rows, payers, onCancel, onConfirm, onClose }) => {
  const [ack, setAck] = hcpayUseState(false);
  const done = !!job.results;
  const bulk = job.mode !== "single";
  /* The id list is what the operator checked, sent raw — exactly like the real csv in
     pay_selecteds_hdn. It can therefore contain a row that has been settled since it was ticked;
     those are surfaced here as "will be skipped" and, at execution time, hit the atomic claim. */
  const targets = rows.filter(r => job.ids.indexOf(r.id) !== -1).slice().sort((a, b) => b.id - a.id);
  const willPay = targets.filter(r => r.paid === 0 && r.amount > 0);
  const willSkip = targets.filter(r => r.paid === 1 || r.amount <= 0);

  /* Per-currency totals of what is about to move (or what moved). */
  const totalsOf = (list) => {
    const m = {};
    list.forEach(r => { m[r.cur] = (m[r.cur] || 0) + r.amount; });
    return Object.keys(m).sort().map(c => hrsMoney(m[c], c));
  };

  const title = done
    ? "Payment run complete"
    : job.mode === "single" ? "Confirm commission payment"
      : job.mode === "selected" ? "Confirm payment of the selected rows"
        : "Confirm payment of every unpaid row in this filter";

  const results = job.results || [];
  const okRows = results.filter(r => r.outcome === "paid");
  const badRows = results.filter(r => r.outcome !== "paid");

  const OUTCOME = {
    paid: { label: "Paid", cls: "ok" },
    failed: { label: "Transfer failed", cls: "err" },
    already: { label: "Skipped — already paid", cls: "warn" },
    rejected: { label: "Rejected", cls: "warn" },
  };

  return (
    <HcpayModal
      title={title} icon={done ? "check" : "wallet"} tone={done ? "ok" : "pay"} wide
      onClose={done ? onClose : onCancel}
      /* Money confirm/cancel use the same buttons as the other approve flows in this build —
         green #1f9d57 to commit, red to back out (rpt-btn--green / --reset, exactly as
         HostDeposit.jsx's "Confirm transfer" and HostDeposits' approve action). */
      footer={done
        ? <button className="rpt-btn rpt-btn--blue" onClick={onClose}>Close</button>
        : <>
          <button className="rpt-btn rpt-btn--reset" onClick={onCancel}><Icon name="x" size={13} /> Cancel</button>
          <button className="rpt-btn rpt-btn--green hcpay-gobtn" disabled={bulk && !ack} onClick={onConfirm}>
            <Icon name="wallet" size={13} /> {job.mode === "single" ? "Pay this commission" : `Pay ${willPay.length} row${willPay.length === 1 ? "" : "s"}`}
          </button>
        </>}>

      {!done && (
        <>
          <div className="hcpay-alert hcpay-alert--pay">
            <Icon name="alert" size={15} />
            <div>
              <b>This moves real money.</b> Each row is claimed with an atomic
              <code> UPDATE … SET paid = 1 WHERE id = ? AND paid = 0</code>, then paid out of the
              <b> skin admin's own wallet</b> through <code>TransferController::processTransfer</code>
              (<code>type=add</code>, <code>system=commissions</code>). A row that is already paid is skipped,
              never paid twice.
            </div>
          </div>

          <div className="hcpay-sumgrid">
            <div className="hcpay-sum"><div className="l">Rows to pay</div><div className="v">{willPay.length}{willSkip.length > 0 && <span className="hcpay-sum__x"> · {willSkip.length} will be skipped</span>}</div></div>
            <div className="hcpay-sum"><div className="l">Total to move</div><div className="v">{totalsOf(willPay).join(" · ") || "—"}</div></div>
            <div className="hcpay-sum"><div className="l">Paying accounts</div><div className="v">{[...new Set(willPay.map(t => t.payer))].join(", ") || "—"}</div></div>
          </div>

          {/* Amount · recipient · period, spelled out for every row — required before any money moves. */}
          <div className="hcpay-plan">
            <div className="hcpay-plan__cap">What will be paid</div>
            <div className="hcpay-plan__scroll">
              <table className="hcpay-plan__table">
                <thead>
                  <tr><th>ID</th><th>Recipient</th><th>Period</th><th>Commission</th><th className="hcpay-r">Amount</th><th>Paid from</th></tr>
                </thead>
                <tbody>
                  {targets.map(r => {
                    const p = payers[r.payer];
                    const short = r.paid === 0 && r.amount > 0 && p.bal + p.cr < r.amount;
                    const skip = r.paid === 1 || r.amount <= 0;
                    return (
                      <tr key={r.id} className={[short ? "hcpay-plan__short" : "", skip ? "hcpay-plan__skip" : ""].filter(Boolean).join(" ") || undefined}>
                        <td>{r.id}</td>
                        <td className="hcpay-l"><b>{r.user}</b> <span className="hcpay-dim">{HCPAY_LEVELS[r.level]} · {r.skin}</span></td>
                        <td><span className="hcpay-period">{r.period.replace(",", ", ")}</span></td>
                        <td>{HCPAY_GAME_LABEL[r.game_type]} <span className="hcpay-dim">{HCPAY_PERIODICITY[r.commission_type]}</span></td>
                        <td className="hcpay-r"><b>{hrsMoney(r.amount, r.cur)}</b></td>
                        <td>
                          {r.payer}
                          <span className="hcpay-dim"> {hrsMoney(p.bal + p.cr, r.cur)} available</span>
                          {short && <span className="hcpay-shortchip">short</span>}
                          {r.amount <= 0 && <span className="hcpay-shortchip">amount 0 — rejected before the claim</span>}
                          {r.paid === 1 && <span className="hcpay-shortchip">already paid — the claim will skip it</span>}
                        </td>
                      </tr>
                    );
                  })}
                </tbody>
              </table>
            </div>
            <div className="hcpay-plan__note">
              Each successful row writes two mirrored <code>transactions</code> rows (payer <code>out</code>,
              receiver <code>add</code>). No <code>transaction_history</code> row is written: history is only
              recorded when the receiver is a <b>player</b> (user_level 30), and commission recipients are
              agents and shops.
            </div>
          </div>

          {bulk && (
            <label className="hcpay-ack">
              <input type="checkbox" checked={ack} onChange={e => setAck(e.target.checked)} />
              <span>
                I have checked the rows above and I am authorising {willPay.length} commission payment{willPay.length === 1 ? "" : "s"} totalling {totalsOf(willPay).join(" · ") || "nothing"}.
              </span>
            </label>
          )}

          <div className="hcpay-note hcpay-note--warn">
            <Icon name="info" size={13} />
            <span>
              <b>Divergence from the real platform.</b> Upstream, this bulk action has no confirm and no POST:
              the JS writes <code>pay_all_hdn</code> / <code>pay_selecteds_hdn</code> into the DataTables
              column-search values and the payments execute inside the
              <code> GET /commissions_payments/rows</code> listing request. Refreshing that URL re-fires the
              attempt. The prototype implements the evident intent instead — an explicit, confirmed action.
            </span>
          </div>
        </>
      )}

      {done && (
        <>
          <div className="hcpay-sumgrid">
            <div className="hcpay-sum hcpay-sum--ok"><div className="l">Paid</div><div className="v">{okRows.length}</div></div>
            <div className="hcpay-sum"><div className="l">Moved</div><div className="v">{totalsOf(okRows.map(r => r.row)).join(" · ") || "—"}</div></div>
            <div className={`hcpay-sum${badRows.length ? " hcpay-sum--err" : ""}`}><div className="l">Not paid</div><div className="v">{badRows.length}</div></div>
          </div>

          <div className="hcpay-plan">
            <div className="hcpay-plan__cap">Result per row</div>
            <div className="hcpay-plan__scroll">
              <table className="hcpay-plan__table">
                <thead>
                  <tr><th>ID</th><th>Recipient</th><th className="hcpay-r">Amount</th><th>Outcome</th><th>Description / message</th></tr>
                </thead>
                <tbody>
                  {results.map(res => (
                    <tr key={res.id}>
                      <td>{res.id}</td>
                      <td className="hcpay-l"><b>{res.row.user}</b> <span className="hcpay-dim">{res.row.skin}</span></td>
                      <td className="hcpay-r">{hrsMoney(res.row.amount, res.row.cur)}</td>
                      <td><span className={`hcpay-out hcpay-out--${OUTCOME[res.outcome].cls}`}>{OUTCOME[res.outcome].label}</span></td>
                      <td className="hcpay-l">
                        {res.outcome === "paid"
                          ? <span className="hcpay-dim">{res.desc}</span>
                          : <><b>{res.message}</b>{res.released && <span className="hcpay-dim"> — claim released, <code>paid</code> back to 0</span>}</>}
                      </td>
                    </tr>
                  ))}
                </tbody>
              </table>
            </div>
          </div>

          {okRows.length > 0 && (
            <div className="hcpay-plan">
              <div className="hcpay-plan__cap">Ledger rows written — two mirrored <code>transactions</code> rows per payment</div>
              <div className="hcpay-plan__scroll">
                <table className="hcpay-plan__table">
                  <thead><tr><th>Tx</th><th>Account</th><th>Side</th><th>transactions.type</th><th className="hcpay-r">Amount</th><th className="hcpay-r">New balance</th></tr></thead>
                  <tbody>
                    {okRows.reduce((acc, res) => acc.concat(res.tx.map(t => ({ t, cur: res.row.cur }))), []).map(({ t, cur }) => (
                      <tr key={t.tx}>
                        <td>#{t.tx}</td>
                        <td className="hcpay-l"><b>{t.who}</b></td>
                        <td>{t.side}</td>
                        <td><span className="hcpay-type">{t.type}</span></td>
                        <td className={`hcpay-r ${t.amt < 0 ? "hcpay-neg" : "hcpay-pos"}`}>{hrsMoney(t.amt, cur)}</td>
                        <td className="hcpay-r">{hrsMoney(t.newVal, cur)} <span className="hcpay-dim">{t.col}</span></td>
                      </tr>
                    ))}
                  </tbody>
                </table>
              </div>
            </div>
          )}

          {badRows.length > 0 && (
            <details className="hcpay-details">
              <summary>details column — the JSON kept on each row that did not settle ({badRows.length})</summary>
              {badRows.map(res => (
                <div className="hcpay-detailblock" key={res.id}>
                  <div className="hcpay-detailblock__h">#{res.id} · {res.row.user}</div>
                  <pre>{res.details ? hcpayJson(res.details) : "— no transfer was attempted, so nothing was written to `details` —"}</pre>
                </div>
              ))}
            </details>
          )}
        </>
      )}
    </HcpayModal>
  );
};

/* =================================================================================================
   The screen
   ================================================================================================= */
const CommissionPayments = () => {
  window.useLocale && window.useLocale();

  const payFeed = useHrsFetch(() => window.sb.list("commissionPayments", { limit: 2000 }), []);
  const skinFeed = useHrsFetch(() => window.sb.list("skins", { limit: 200 }), []);
  const [rowEdits, setRowEdits] = hcpayUseState({});
  /* The stored rows, with any edit this session laid over the top. Kept as an
     overlay rather than a copy of the feed so a refetch cannot silently revert
     what the operator just did — and so `rows` is never a second source of
     truth that drifts from the table. */
  const rows = hcpayUseMemo(
    () => (payFeed.data || []).map(hcpayRowFromDb).map(r => (rowEdits[r.id] ? Object.assign({}, r, rowEdits[r.id]) : r)),
    [payFeed.data, rowEdits]);
  const setRows = (fnOrRows) => {
    /* The screen's pay/edit flows call setRows with an updater over the whole
       list. Diffed back into the overlay so the feed stays the source. */
    const next = typeof fnOrRows === "function" ? fnOrRows(rows) : fnOrRows;
    const byId = {}; next.forEach(r => { byId[r.id] = r; });
    setRowEdits(prev => {
      const out = Object.assign({}, prev);
      rows.forEach(r => {
        const n = byId[r.id];
        if (!n) return;
        if (n.paid !== r.paid || n.amount !== r.amount || n.note !== r.note || n.details !== r.details) {
          out[r.id] = { paid: n.paid, amount: n.amount, note: n.note, details: n.details, pay_time: n.pay_time };
        }
      });
      return out;
    });
  };
  /* THE WALLETS WERE INVENTED — a payer balance per brand and a receiver
     balance per user, both from a PRNG, so the "not enough balance" outcome was
     scripted. Balances live in `user_balances` and the transfer that pays a
     commission goes through post_transfer, which checks them server-side. The
     screen no longer holds a shadow copy to check against. */
  const [payers, setPayers] = hcpayUseState({});
  const [recv, setRecv] = hcpayUseState({});
  const [txSeq, setTxSeq] = hcpayUseState(918440);

  const [draft, setDraft] = hcpayUseState(HCPAY_BLANK);
  const [applied, setApplied] = hcpayUseState(HCPAY_BLANK);
  const [sort, setSort] = hcpayUseState({ key: "id", dir: "desc" }); // rows() L103 default: commissions_payments.id DESC
  const [page, setPage] = hcpayUseState(0);
  const [pageSize, setPageSize] = hcpayUseState(50);
  const [sel, setSel] = hcpayUseState([]);
  const [edit, setEdit] = hcpayUseState(null);
  const [job, setJob] = hcpayUseState(null);

  /* ---- filters (rows() L104-127) ----
     DIVERGENCE, known-bug policy: the real query uses LIKE '%v%' for ID, user_level and game_type,
     so "4" returns #244, "2" (Skin Access) returns every Shop row, and "casino" returns casinolive
     rows as well. Exact matching is implemented; Username stays a contains match, which is what a
     name search is meant to be. */
  const filtered = hcpayUseMemo(() => rows.filter(r => {
    if (applied.skin && r.skin !== applied.skin) return false;
    if (applied.status === "yes" && r.paid !== 1) return false;
    if (applied.status === "no" && r.paid !== 0) return false;
    if (applied.level && String(r.level) !== String(applied.level)) return false;
    if (applied.id && String(r.id) !== String(applied.id).trim()) return false;
    if (applied.username && r.user.toLowerCase().indexOf(applied.username.trim().toLowerCase()) === -1) return false;
    if (applied.game && r.game_type !== applied.game) return false;
    return true;
  }), [rows, applied]);

  /* ---- sort (rows() L139-143) ----
     Only username and skin are actually orderable upstream; every other column falls back to
     commissions_payments.id DESC because the generic per-column order block is commented out. */
  const sorted = hcpayUseMemo(() => {
    const a = filtered.slice();
    const dir = sort.dir === "asc" ? 1 : -1;
    if (sort.key === "user") a.sort((x, y) => x.user.toLowerCase().localeCompare(y.user.toLowerCase()) * dir || y.id - x.id);
    else if (sort.key === "skin") a.sort((x, y) => x.skin.localeCompare(y.skin) * dir || y.id - x.id);
    else a.sort((x, y) => y.id - x.id);
    return a;
  }, [filtered, sort]);

  const pages = Math.max(1, Math.ceil(sorted.length / pageSize));
  const safePage = Math.min(page, pages - 1);
  const paged = sorted.slice(safePage * pageSize, safePage * pageSize + pageSize);

  /* JSON extras that exist for one purpose only: enabling/disabling the bulk buttons (drawCallback
     L68-74). total_unpaid_count spans the whole filtered set, page_unpaid_count only this page. */
  const totalUnpaid = filtered.filter(r => r.paid === 0).length;
  const pageUnpaidIds = paged.filter(r => r.paid === 0).map(r => r.id);
  /* Checked ids on this page, sent raw the way the real pay_selecteds_hdn csv is — a row settled
     since it was ticked stays in the list and is caught by the claim, not by the UI. */
  const selHere = sel.filter(id => paged.some(r => r.id === id));
  const selPayable = selHere.filter(id => paged.some(r => r.id === id && r.paid === 0));

  const onSearch = (v) => { setApplied(v); setPage(0); setSel([]); };
  const onReset = () => { setDraft(HCPAY_BLANK); setApplied(HCPAY_BLANK); setPage(0); setSel([]); };

  const toggleSel = (id) => setSel(s => s.indexOf(id) === -1 ? s.concat(id) : s.filter(x => x !== id));

  /* ---- the money path ---- */
  const runPay = () => {
    const targets = rows.filter(r => job.ids.indexOf(r.id) !== -1).slice().sort((a, b) => b.id - a.id);
    const nextPayers = { ...payers };
    const nextRecv = { ...recv };
    const byId = {};
    let seq = txSeq;
    const results = [];

    targets.forEach(r => {
      const live = byId[r.id] || r;
      const res = hcpayAttempt(live, nextPayers[r.payer], nextRecv[r.user], seq);
      results.push(res);
      /* `payable` is forced true and saved before the attempt, whatever the outcome. */
      const patch = { payable: 1 };
      if (res.outcome === "paid") {
        const now = hcpayNow();
        seq += 2;
        nextPayers[r.payer] = { ...nextPayers[r.payer], bal: res.newPayer };
        nextRecv[r.user] = res.newRecv;
        patch.paid = 1;
        patch.pay_time = now.ts;
        patch.updated = now;
        patch.details = res.details;
      } else if (res.outcome === "failed") {
        // Claim released — paid stays 0, pay_time null, the JSON error is kept on the row.
        patch.paid = 0;
        patch.pay_time = null;
        patch.details = res.details;
        patch.updated = hcpayNow();
      }
      byId[r.id] = { ...live, ...patch };
    });

    setRows(rs => rs.map(r => byId[r.id] ? byId[r.id] : r));
    setPayers(nextPayers);
    setRecv(nextRecv);
    setTxSeq(seq);
    /* A bulk run consumes the checkbox selection; a single-row payment leaves it exactly as it was —
       which is how a ticked row can go stale and meet the claim on the next Pay Selected. */
    if (job.mode !== "single") setSel([]);
    setJob({ ...job, results });

    const okCount = results.filter(r => r.outcome === "paid").length;
    hrsToast(
      okCount === results.length ? "Commissions paid" : "Payment run finished with errors",
      `${okCount} of ${results.length} row${results.length === 1 ? "" : "s"} settled · paid from the skin admin wallet via processTransfer (type=add, system=commissions).`
    );
  };

  const saveEdit = (row, amount, note) => {
    setRows(rs => rs.map(r => r.id === row.id
      ? { ...r, amount, note, edit_user_id: amount !== row.amount ? 1 : r.edit_user_id, updated: hcpayNow() }
      : r));
    setEdit(null);
    hrsToast(`Commission payment #${row.id} saved`,
      amount !== row.amount
        ? `Amount changed to ${hrsMoney(amount, row.cur)} — edit_user_id stamped, so recalculation crons will skip this row from now on.`
        : "Note saved. The amount is unchanged, so edit_user_id was not stamped.");
  };

  /* ---- columns ---- */
  const columns = [
    {
      key: "id", label: "ID", width: 96, render: r => (
        <span className="hcpay-idcell">
          {/* commissions_payments.js L53-65 renders the checkbox only when can_pay — i.e. unpaid rows */}
          {r.paid === 0
            ? <input type="checkbox" checked={sel.indexOf(r.id) !== -1} onChange={() => toggleSel(r.id)} title={`Select #${r.id} for a bulk payment`} />
            : <span className="hcpay-cbspacer" />}
          <b>{r.id}</b>
        </span>
      ),
    },
    { key: "period", label: "Period", render: r => <span className="hcpay-period">{r.period.replace(",", ", ")}</span> },
    { key: "skin", label: "Skin", sortable: true, firstDir: "asc" },
    { key: "user", label: "Username", sortable: true, firstDir: "asc", align: "left", render: r => <span className="hcpay-user">{r.user}</span> },
    { key: "level", label: "User Type", render: r => HCPAY_LEVELS[r.level] },
    {
      key: "amount", label: "Amount", align: "right", render: r => (
        <span className="hcpay-amt">
          {hrsMoney(r.amount, r.cur)}
          {r.edit_user_id && <Tip size={12}>Edited by an operator — <code>edit_user_id</code> is set, so every recalculation cron now skips this row.{r.note ? <><br /><br /><b>Note:</b> {r.note}</> : null}</Tip>}
        </span>
      ),
    },
    { key: "commission_type", label: "Periodicity", render: r => HCPAY_PERIODICITY[r.commission_type] },
    {
      key: "game_type", label: "Commission Type", render: r => (
        r.game_type === "casinolive"
          ? <span className="hcpay-rawkey">{HCPAY_GAME_LABEL.casinolive}<Tip size={12}>Rendered exactly as the real screen does: <code>commissions.casinolive</code> has no entry in the committed language files, so the raw key reaches the operator. The filter select has no option for this type either.</Tip></span>
          : HCPAY_GAME_LABEL[r.game_type]
      ),
    },
    {
      key: "paid", label: "Status", render: r => (
        <span className="hcpay-statuscell">
          <HcpayStatusPill paid={r.paid === 1} />
          {r.paid === 0 && r.details && r.details.status === "error" && (
            <Tip size={12}>
              A payment was attempted and did not settle. The row keeps the failure in its <code>details</code> column
              (<code>{r.details.message}</code>){r.amount > 0 ? <> and the claim was released — <code>paid</code> is back to 0 and <code>pay_time</code> to null</> : <> — the amount is 0, so it was rejected before any claim was taken</>}.
            </Tip>
          )}
        </span>
      ),
    },
    { key: "updated", label: "Last Update", render: r => <span className="hcpay-stamp">{r.updated.text}</span> }, // backend.last_update — key missing upstream
    {
      key: "_acts", label: "Actions", align: "center", width: 108, render: r => (
        <span className="hcpay-acts">
          {/* Edit: can_edit = update policy AND !paid */}
          {r.paid === 0
            ? <button className="hcpay-act hcpay-act--edit" title="Edit" onClick={() => setEdit(r)}><Icon name="edit" size={13} /></button>
            : <span className="hcpay-act hcpay-act--off" title="Paid rows cannot be edited — CommissionPaymentPolicy::update requires !paid"><Icon name="lock" size={12} /></span>}
          {/* Pay: can_pay = pay policy AND !paid */}
          {r.paid === 0
            ? <button className="hcpay-act hcpay-act--pay" title="Pay" onClick={() => setJob({ mode: "single", ids: [r.id] })}><Icon name="wallet" size={13} /></button>
            : <span className="hcpay-act hcpay-act--off" title="Already paid — the atomic claim makes a second payment impossible"><Icon name="check" size={12} /></span>}
        </span>
      ),
    },
  ];

  const FIELDS = [
    {
      key: "skin", label: "Skin", type: "select", icon: "flag", placeholder: "Select",
      options: (skinFeed.data || []).map(s => ({ value: s.name, label: s.name })),
      tip: <>Exact match on <code>skins.id</code>. Rendered only for super admins and Customer Care upstream (filter partial L8) — every other role sees the list already scoped to their own skins.</>,
    },
    {
      key: "status", label: "Status", type: "select", icon: "check", placeholder: "Select",
      options: [{ value: "yes", label: "Paid" }, { value: "no", label: "To pay" }],
      tip: <>yes/no is converted to 1/0 and matched against <code>commissions_payments.paid</code>.</>,
    },
    {
      key: "level", label: "User Type", type: "select", icon: "users", placeholder: "Select",
      options: HCPAY_LEVEL_OPTS,
      tip: <>Exact match here. Upstream this is <code>LIKE '%v%'</code> on <code>users.user_level</code>, so picking <b>Skin Access</b> (2) also returns every <b>Shop</b> (20) row.</>,
    },
    {
      key: "id", label: "ID", type: "text", icon: "tag", placeholder: "ID",
      tip: <>Exact match here. Upstream it is a substring <code>LIKE</code>, so typing <code>4</code> also returns #244, #140, #204…</>,
    },
    {
      key: "username", label: "Username", type: "text", icon: "user", placeholder: "Username", grow: true,
      tip: <>Contains match on <code>users.username</code> — same as the real filter.</>,
    },
    {
      key: "game", label: "Commission Type", type: "select", icon: "percent", placeholder: "Select",
      options: HCPAY_GAME_FILTER,
      tip: <>Exact match here. Upstream it is <code>LIKE</code> on <code>game_type</code>, so <b>Casino GGR</b> also returns <code>casinolive</code> rows — and there is no option for <code>casinolive</code> at all.</>,
    },
  ];

  return (
    <HrsShell
      title="Commission Payments"
      subtitle="Settle the commissions the monthly, weekly and turnover crons calculated — each payment leaves the skin admin's own wallet"
      gate={<>Two sidebar entries point here. The <b>Commissions ▾</b> one is <code>isadmin()</code> — <b>super admin only</b>. The standalone top-level one opens up to <code>isSkinAdmin()</code> and to Customer Care holding <code>support_commission</code>. </>}
      gateNote={<>Server side, <code>CommissionPaymentPolicy</code> re-checks everything and splits it three ways — <code>support_commission</code> to look, <code>support_commission_edit_payments</code> to edit, <code>support_commission_payments</code> to pay — each also satisfied by being the skin's own ADMIN or a super admin, and each additionally requiring the row to be unpaid. The list itself is scoped by <code>users.user_path LIKE &lt;your path&gt;%</code> (skipped for Customer Care) and by your <code>getSkinIDS()</code>. This prototype acts as a super admin, so every action renders.</>}
      explainer={
        <Explainer compact title="What this screen does, in plain English"
          bullets={[
            <>Rows are <b>not created here</b>. Three crons write them: the monthly and weekly commission runs (one row per user × game type × period, the amount being the period's report value minus what was already paid) and the turnover run (weekly <code>sport_turnover</code> rows for sport agents). The Period column just prints the stored <code>"start,end"</code> string — there is no period picker.</>,
            <>Rows whose commission profile has <b>auto_pay</b> on arrive already settled: the cron pays them the moment it creates them. Everything still marked <b>To pay</b> is either on a profile without auto_pay, or an attempt that did not go through.</>,
            <>Paying claims the row first — <code>UPDATE … SET paid = 1 WHERE id = ? AND paid = 0</code> — and only then moves the money. If the transfer fails the claim is released and the JSON error is kept on the row. A crash halfway through under-pays; it can never double-pay.</>,
            <>The money comes out of the <b>recipient's skin-admin account</b>, not a platform float. If that wallet's <code>balance + credits</code> is below the amount, the payment fails with "Something went wrong" and nothing moves.</>,
            <>A row edited here is frozen: changing the amount stamps <code>edit_user_id</code>, and from then on every recalculation cron leaves it alone.</>,
          ]} />
      }>

      <HcpayGates />

      <HrsFilters
        fields={FIELDS} values={draft}
        onChange={(k, v) => setDraft(d => ({ ...d, [k]: v }))}
        onSearch={onSearch} onReset={onReset}
        resultLabel={`${hrsInt(sorted.length)} of ${hrsInt(rows.length)}`} />

      {/* Bulk bar — the real one is a button row inside the filter partial (Select All / Pay Selected /
          Pay All), enabled from the response extras page_unpaid_count / total_unpaid_count. */}
      <div className="hcpay-bulk">
        <div className="hcpay-bulk__counts">
          <span className="hcpay-bulk__c"><b>{hrsInt(totalUnpaid)}</b> unpaid in this filter<Tip size={12}>Response extra <code>total_unpaid_count</code> — the whole filtered set, not just this page. It is the only thing that enables <b>Pay All</b>.</Tip></span>
          <span className="hcpay-bulk__sep">·</span>
          <span className="hcpay-bulk__c"><b>{hrsInt(pageUnpaidIds.length)}</b> unpaid on this page<Tip size={12}>Response extra <code>page_unpaid_count</code>, which enables <b>Select All</b> and <b>Pay Selected</b>.</Tip></span>
          {selHere.length > 0 && <><span className="hcpay-bulk__sep">·</span><span className="hcpay-bulk__c hcpay-bulk__c--sel"><b>{selHere.length}</b> selected</span></>}
        </div>
        <div className="hcpay-bulk__btns">
          <button className="hcpay-bbtn hcpay-bbtn--sel" disabled={pageUnpaidIds.length === 0}
            onClick={() => setSel(s => selPayable.length === pageUnpaidIds.length ? s.filter(id => pageUnpaidIds.indexOf(id) === -1) : [...new Set(s.concat(pageUnpaidIds))])}>
            <Icon name="list" size={13} /> {selPayable.length === pageUnpaidIds.length && pageUnpaidIds.length > 0 ? "Clear selection" : "Select All"}
          </button>
          <button className="hcpay-bbtn hcpay-bbtn--pay" disabled={selHere.length === 0}
            onClick={() => setJob({ mode: "selected", ids: selHere })}>
            <Icon name="wallet" size={13} /> Pay Selected
          </button>
          <button className="hcpay-bbtn hcpay-bbtn--all" disabled={totalUnpaid === 0}
            onClick={() => setJob({ mode: "all", ids: filtered.filter(r => r.paid === 0).map(r => r.id) })}>
            <Icon name="zap" size={13} /> Pay All
          </button>
        </div>
      </div>

      {payFeed.loading && <HrsSkeleton rows={8} cols={8} />}
      {!payFeed.loading && payFeed.error && <HrsError error={payFeed.error} onRetry={payFeed.retry} />}
      {!payFeed.loading && !payFeed.error && (<>
      <HrsTable
        columns={columns} rows={paged} rowKey="id"
        sort={sort} onSort={(s) => { setSort(s); setPage(0); }}
        empty="No commission payment matches these filters."
        renderCard={r => (
          <>
            <div className="hrs-card__top">
              <b>{r.user}</b>
              <span className="hcpay-cardamt">{hrsMoney(r.amount, r.cur)}</span>
            </div>
            <div className="hcpay-cardline">
              <HcpayStatusPill paid={r.paid === 1} />
              <span className="hcpay-period">{r.period.replace(",", ", ")}</span>
            </div>
            <div className="hrs-card__grid">
              <span>ID</span><b>{r.id}</b>
              <span>Skin</span><b>{r.skin}</b>
              <span>User Type</span><b>{HCPAY_LEVELS[r.level]}</b>
              <span>Periodicity</span><b>{HCPAY_PERIODICITY[r.commission_type]}</b>
              <span>Commission Type</span><b>{HCPAY_GAME_LABEL[r.game_type]}</b>
              <span>Last Update</span><b>{r.updated.text}</b>
            </div>
            {r.paid === 0 && (
              <div className="hcpay-card__acts">
                <label className="hcpay-card__sel">
                  <input type="checkbox" checked={sel.indexOf(r.id) !== -1} onChange={() => toggleSel(r.id)} /> Select
                </label>
                <button className="btn btn--secondary btn--sm" onClick={() => setEdit(r)}><Icon name="edit" size={12} /> Edit</button>
                <button className="hcpay-bbtn hcpay-bbtn--pay hcpay-bbtn--sm" onClick={() => setJob({ mode: "single", ids: [r.id] })}><Icon name="wallet" size={12} /> Pay</button>
              </div>
            )}
          </>
        )} />

      <HrsPager page={safePage} pageSize={pageSize} total={sorted.length} sizes={HCPAY_PAGE_SIZES}
        onPage={setPage} onPageSize={(n) => { setPageSize(n); setPage(0); }} />
      </>)}

      <div className="hcpay-foot">
        <Icon name="info" size={13} />
        <span>
          No export and no "New" button on this screen — both are genuinely absent upstream. The
          generic layout loads the DataTables export bundle but configures no buttons, and creation is
          suppressed (<code>no_create</code>, create route commented out), even though
          <code> POST /commissions_payments</code> would still answer.
        </span>
      </div>

      {edit && <HcpayEditModal row={rows.find(r => r.id === edit.id) || edit} onClose={() => setEdit(null)} onSave={saveEdit} />}
      {job && (
        <HcpayPayModal job={job} rows={rows} payers={payers}
          onCancel={() => setJob(null)} onClose={() => setJob(null)} onConfirm={runPay} />
      )}
    </HrsShell>
  );
};

/* Loads after src/pages/HostCommissions.jsx, deliberately replacing its legacy CommissionPayments
   global (app.jsx:472 renders <CommissionPayments/> for route key "comm-payments"). */
window.CommissionPayments = CommissionPayments;
