// Represents: GET /commissionsprofiles/ · CommissionsProfilesController — see docs/ISYSTEM_REFERENCE.md §Batch 3 "Commission profiles"
/* Commissions ▾ → Commission profiles, rebuilt on the shared Hrs* report shell (src/report-shell.jsx).
   Replaces the legacy CommissionProfiles that lived in the retired src/pages/HostCommissions.jsx
   bundle: the `window.CommissionProfiles` assignment at the bottom of this file is now the only
   definition app.jsx's "comm-profiles" route resolves (app.jsx:471, /commissions/profiles —
   routes.jsx:69). Its two former bundle-mates are likewise their own files now —
   HostCommSportProfiles.jsx (SportProfiles) and HostCommPayments.jsx (CommissionPayments).

   TRACEABILITY (docs/ISYSTEM_REFERENCE.md §Batch 3 "Commission profiles" unless noted)
   - Routes, all unnamed inside Route::name('admin.')->middleware(['auth','admin','2fa','g2fa'])
     (routes/admin.php:25) under the outer ['admin','adminsettings'] group (L15):
       GET  /commissionsprofiles/                          L1418-1420  index
       GET  /commissionsprofiles/getCommissionsprofilesTable L1422-1424 DataTables feed
       GET  /commissionsprofiles/form/?id=                 L1426-1428  modal body
       POST /commissionsprofiles/saveCommissionsprofile/   L1430-1432  save
       GET  /commissionsprofiles/delete/{id}/              L1586-1588  (parked ~150 lines away,
                                                                       after the report-costs routes)
   - Controller CommissionsProfilesController: index L27, getCommissionsprofilesTable L57,
     commissionsprofileForm L165, saveCommissionsprofile L252, delete L431. Reusable statics consumed
     elsewhere: commissionProfilesList($skin,$level) L17 (feeds the user forms' profile picker),
     stati() L46, getCommissionsprofilesList() L197, updateProviderCostsProfile() L206.
   - Views: admin/commissionsprofiles/index.blade.php + modals/commissionsprofile.blade.php
     (generaModalGestione → admin/utils/modal.blade.php) + forms/commissionsprofile.blade.php
     (AJAX-loaded body). JS driver public/js/pages/commissionsprofiles/ajax.js.
   - Data model: `profili_provvigionali` (model CommissionsProfile — unix-timestamp addedTime/
     updateTime) ⟷ `profili_cobanco_providers` (model CobancoProvider — fillable profile_id /
     provider_id / percentage, no timestamps) ⟷ `providers`; `skins.id` via skin_id;
     `sport_profiles.id` via sport_profile_id; `users.profilo_provvigionale` is the attachment FK
     (hasMany CommissionsProfile::users()). `user_providers` (the skin admin's per-provider %) gates
     the cobanco inputs. No FormRequest — validation is inline in saveCommissionsprofile.
   - List: ID / Name (edit link) / Skin (leftJoin skins) / Users Assigned / Actions. Users Assigned is
     User::active()->where('profilo_provvigionale',$row->id)->count() per row (stato = 1; an N+1).
     Sortable: id, name, skin_id only. Filters: ID (exact =), Name (LIKE %v%), Skin (exact =,
     rendered for superadmin only). Search button applies; 50/page, menu [5,10,25,50], server-side.
   - Faithful omissions ("nothing added", brief §3): no KPI strip, no export, no bulk actions, no
     status column (stati() 0/1 is dead code — `stato` is hardcoded to 1 on every save, L390), no
     note / coupon_min / played_min / sport_perc / calc_method fields (in the model's $fillable but
     absent from this form — coupon_min/played_min are enforced by CommissionsController L376-380 and
     the sport percentages come from the linked sport profile), and no #calc_method_box (JS toggles an
     element the form never renders). The row payload's hidden `nome_completo` only feeds the edit
     modal title; nothing displays it.

   CROSS-CHECKED with the two screens that consume these profiles, already rebuilt this session —
   terminology is kept identical on purpose:
   - Batch 2 "Commissions report" (src/pages/HostReportCommissions.jsx): profile-less users show the
     "Not set" fallback (real: "NON IMPOSTATO"), cobanco profiles show "COBANCO", and profilo_provvigionale
     -1 / -2 users are "at cost" — they hold no profile and settle by paying a per-provider % of GGR.
     The category vocabulary (Sport / Casino / Casino Live / Virtual, Poker unreachable) is that report's
     tab set. CommissionsController matches profit against the "/"-imploded ranges written by this form,
     and skips -1/-2 masters in commission runs.
   - Batch 3 "Commission Payments": `calc_period` must match the run type — 'm' rows are produced by
     payMonthlyCommissions, 'w' rows by payWeeklyCommissions (their Periodicity column) — and `auto_pay`
     seeds each generated row's `payable` flag, i.e. whether the cron pays it without an operator click.
   - The profile names below include the five used by HostUsers.jsx' profile picker (HU_PROFILES), so the
     attachment side and this catalogue speak of the same rows. Skin ids/names/currencies match
     HostReportBusiness.jsx (HRBZ_SKINS) and provider ids match HostSetVendorsGroups.jsx (HVG_PROVIDERS).

   KNOWN-BUG POLICY (CLAUDE.md "Build policies") — evident intent implemented, divergence recorded:
   1) COBANCO SAVE-ORDER BUG (the headline one). updateProviderCostsProfile() runs at L296-299, i.e.
      BEFORE the validation errors return (L392-400) and BEFORE the profile row is created (L418), so on
      a brand-new cobanco profile `$id` is empty and every `profili_cobanco_providers` row is written
      with an EMPTY profile_id — orphan rows that belong to no profile, while the profile itself saves
      with no provider costs. Per-provider percentages only persist when editing an existing profile;
      a failed validation still leaves the provider rows written. Implemented here as the evident
      intent: validate first (nothing is written when validation fails), create the profile row, then
      write the cobanco rows against the NEW id — see HcpFormModal.save() and the linkage line the
      cobanco panel prints.
      <!-- SUGGESTION: in CommissionsProfilesController::saveCommissionsprofile, move the
           updateProviderCostsProfile() call to after the validation block AND after the
           CommissionsProfile::create(), and feed it the created model's id (create()->id is currently
           discarded, L418) so new cobanco profiles get correctly linked profili_cobanco_providers rows.
           A one-off cleanup of existing rows with an empty profile_id is also needed. -->
   2) DELETE HAS NO IN-USE GUARD. delete() (L432) hard-deletes any profile, including one with assigned
      users — the Users Assigned count is display-only — leaving users.profilo_provvigionale pointing at
      a row that no longer exists (those users then read as "Not set" in the Commissions report and are
      skipped by the commission runs). Evident intent implemented: HcpDeleteDialog refuses to delete a
      profile with assigned active users and explains why; unassigned profiles delete normally.
      <!-- SUGGESTION: guard CommissionsProfilesController::delete() with the same
           User::active()->where('profilo_provvigionale',$id)->count() the list already computes, and
           refuse (or require a reassignment target) when it is > 0. -->
   3) EDIT-WINDOW LOCK IS CLIENT-SIDE ONLY AND INCONSISTENT. The form's JS hides #buttonsave whenever
      act=='edit' && (new Date).getDate() > 2 — plain calendar day, for EVERYONE including the super
      admin (forms L992-1003) — while the warning banner uses getDayOfCurrentPeriod() (days since the
      first Monday of the month, app/Helpers/utils.php:2377) and only renders for non-exempt viewers, so
      the flag literally named "disable_change_comm_profile_limits" only SUPPRESSES THE WARNING. The
      explanation element the JS shows ($("#cant_save_label")) exists nowhere, so on the real platform
      the Save button silently disappears with no reason given. saveCommissionsprofile enforces no date
      rule at all. Evident intent implemented: one rule (the period-day one), the exemption honoured,
      and the state explained on screen instead of a vanishing button — see HcpEditWindow.
      <!-- SUGGESTION: pick one rule (getDayOfCurrentPeriod() > 2), apply it in saveCommissionsprofile
           server-side, exempt superadmin + disable_change_comm_profile_limits skins consistently in
           both the banner and the Save button, and render a visible reason instead of hiding #buttonsave
           against a #cant_save_label element that does not exist. -->
   4) SPORT-PROFILE SELECT IGNORES THE SKIN. The blade calls getSportprofilesList(0, $skin_id) but
      SportProfilesController::getSportprofilesList() (SportProfilesController.php:181) takes no
      parameters and returns ALL sport_profiles rows, so a profile can be linked to another skin's sport
      profile. The real list is preserved here (hiding rows would hide operator data), but every option
      is annotated with its skin and a cross-skin pick is flagged inline.
      <!-- SUGGESTION: give getSportprofilesList() the ($id, $skin_id) signature the blade already calls
           it with and scope the query by skin_id, or validate sport_profile_id against the submitted
           skin_id in saveCommissionsprofile. -->
   5) DUPLICATED DOM IDS in the real form (all four third-tier <cat>_perc inputs share id="sport_perc",
      two blocks share id="sport_view_profile", the auto_pay wrapper div and its checkbox share
      id="auto_pay") — fixed by construction here, every control has a unique id.
      <!-- SUGGESTION: give each category's third-tier percentage input its own id (casino_perc,
           casinolive_perc, poker_perc, virtual_perc) — today a label click or a JS getElementById can
           only ever reach the first one. -->
   6) getCommissionsprofilesTable echo()es json_encode() instead of returning a response, hardcodes
      sEcho to 0, and keys every row by the TRANSLATED header string, so the row-action JS reading
      full['ID'] breaks in any locale where backend.id is not exactly "ID" (same defect as Sport
      profiles and Vendors Groups). Not user-visible here; recorded for completeness.
      <!-- SUGGESTION: return response()->json() with stable machine keys (id/name/skin/users_cnt) and
           let the view map them to translated headers. -->

   LABEL POLICY: keys that resolve nowhere in the committed default lang (storage/lang is gitignored)
   or leak Italian get operator-facing wording marked with a "label inferred" JSX comment at the point of use —
   Period "Monthly"/"Weekly" (backend.monthly / backend.weekly resolve nowhere; only
   backend.extras.weekly/monthly exist), "Sport profile" (backend.sport_profile resolves to the Italian
   "Profilo sport" even in the EN file), "New profile" modal title (the real modal is opened with
   "Nuovo commissionsprofile"), "Edit <name>" (real prefix "Modifica"), and the Cobanco panel's helper
   text. Resolved keys used verbatim: backend.new_profile "New profile", backend.commission_profiles
   "Commission profiles", commissions.users_assigned "Users Assigned", backend.fill_in_all_fields,
   backend.fill_in_all_fields_category, backend.impossible_interval_category, backend.actions.

   Index page loads TinyMCE from a CDN and two datepicker bundles it never uses — not reproduced. */

const { useState: hcpUseState, useMemo: hcpUseMemo } = React;

/* Deterministic PRNG (FNV-1a + mulberry32) — same convention as the sibling Host pages, so the
   catalogue renders identically on every load. */
const hcpHash = (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 hcpRng = pbRng;   // was a local copy of mulberry32 — see pbRng in src/data.jsx
/* Name a skin out of whatever list the caller holds. Replaces `hcpSkinName`,
   which closed over the hardcoded catalogue and so could name a brand that does
   not exist while failing to name one that does. */
const hcpNameIn = (skins, id) => {
  const hit = (skins || []).filter(x => x.id === Number(id))[0];
  return hit ? hit.name : `#${id}`;
};

/* THE NINE-BRAND CATALOGUE IS GONE. `HCP_SKINS` hardcoded nine skins with an
   invented per-skin flag matrix — show_sport, can_provv_cobanco,
   enable_commissions_management and the rest — and every gate on this screen
   read it. Skins and their settings are fetched now; presence of a
   `skin_settings` row IS the flag, there is no boolean column. */

/* User Type select = UsersController::usersLevels() filtered to skip PLAYER(30) and anything below
   MASTER(8). Names are the default-lang ones (HostUsers.jsx HU_ROLES); a skin admin sees the
   per-skin custom_{master,agent,promoter,shop}_name overrides instead. */
const HCP_LEVELS = [
  { lvl: 8,  name: "Master" },          // custom_master_name — the only level that may be Cobanco
  { lvl: 9,  name: "Regulation User" },
  { lvl: 10, name: "Agent" },           // custom_agent_name
  { lvl: 15, name: "Promoter" },        // custom_promoter_name
  { lvl: 20, name: "Shop" },            // custom_shop_name
];
const hcpLevelName = (lvl) => { const r = HCP_LEVELS.find(x => x.lvl === Number(lvl)); return r ? r.name : ""; };

/* calc_period. Both option labels are "label inferred": backend.monthly / backend.weekly resolve in no
   committed lang file (only backend.extras.weekly/monthly exist), so the real select renders raw keys. */
const HCP_PERIODS = [{ value: "m", label: "Monthly" }, { value: "w", label: "Weekly" }];
const hcpPeriodLabel = (p) => (p === "w" ? "Weekly" : "Monthly");

/* The four category boxes. Casino / Casino Live / Virtual are titled "Commissions on GGR"; Poker is
   titled "Commissions" and its inputs carry Rake placeholders. Sport has no box — its percentages come
   from the linked sport profile. Column prefixes follow the <cat>_* pattern documented for the form. */
const HCP_CATS = [
  { key: "casino",     label: "Casino",      flag: "show_casino",     sub: "Commissions on GGR", unit: "GGR" },
  { key: "casinolive", label: "Casino Live", flag: "show_casinolive", sub: "Commissions on GGR", unit: "GGR" },
  { key: "poker",      label: "Poker",       flag: "show_poker",      sub: "Commissions",        unit: "Rake" },
  { key: "virtual",    label: "Virtual",     flag: "show_virtual",    sub: "Commissions on GGR", unit: "GGR" },
];

/* sport_profiles rows (SportProfilesController::getSportprofilesList()). tipologia labels from
   SportProfilesController::types(): 1 misto a scaletta, 3 misto fisso, 10 utile. Ids, names, typologies
   and skins are BYTE-FOR-BYTE the HSP_SEED catalogue of src/pages/HostCommSportProfiles.jsx (Batch 3
   "Sport profiles", rebuilt this session), so both screens agree on which sport profiles exist. The
   list is deliberately GLOBAL — the real helper takes no arguments and returns every skin's rows
   (divergence 4). */
/* `HCP_SPORT_PROFILES` was nine transcribed rows. Fetched from
   sport_commission_profiles now, with the typology label the database carries
   rather than a three-entry lookup table. */

/* `HCP_PROVIDERS` was ten transcribed providers and `hcpMasterPct` was a
   SEEDED PRNG standing in for the skin admin's `user_providers` percentages —
   the numbers that decide which providers a cobanco profile may be priced
   against at all. A generated cost basis is the worst possible thing to have on
   this screen: it decided which inputs were enabled and what the red "not
   enabled on the master" warning said, so the form was refusing and permitting
   provider costs at random. Both come from the database now, passed down as
   props because the modal cannot see the page's feeds. */

/* THE SEED IS GONE. `HCP_SEED` was twenty transcribed profiles and
   `hcpBuildProfiles` gave each one a PRNG ladder, PRNG cobanco rates and a PRNG
   "users assigned" count — a commission catalogue where every percentage was
   invented. The real rows, their tiers and their cobanco providers come from
   `commissionProfileDetail`, which embeds all three plus a real assignment
   count. */

const HCP_PAGE_SIZES = [5, 10, 25, 50]; // ajax.js lengthMenu, pageLength 50

/* Comma → dot normalisation, exactly what saveCommissionsprofile does to every percentage/range value
   before imploding (L404-415) — Italian keyboards type "12,5". */
const hcpNum = (v) => String(v == null ? "" : v).trim().replace(",", ".");
const hcpFilled = (v) => hcpNum(v) !== "";        // '0' counts as filled, per the controller's checks
const hcpRangeStr = (min, max, perc) => `${hcpNum(min)}/${hcpNum(max)}/${hcpNum(perc)}`;

/* getDayOfCurrentPeriod() (app/Helpers/utils.php:2377) — commission periods start on the FIRST MONDAY
   of the month, so "day 1 of the period" is that Monday, not the 1st of the month. */
const hcpFirstMonday = (y, m) => { const first = new Date(y, m, 1); return new Date(y, m, 1 + ((8 - first.getDay()) % 7)); };
const hcpPeriodStart = (today) => {
  const fm = hcpFirstMonday(today.getFullYear(), today.getMonth());
  if (today >= fm) return fm;
  const pm = today.getMonth() === 0 ? hcpFirstMonday(today.getFullYear() - 1, 11) : hcpFirstMonday(today.getFullYear(), today.getMonth() - 1);
  return pm;
};
const hcpPeriodDay = (today) => Math.floor((today - hcpPeriodStart(today)) / 86400000) + 1;
const hcpFmtDate = (d) => `${String(d.getDate()).padStart(2, "0")}/${String(d.getMonth() + 1).padStart(2, "0")}/${d.getFullYear()}`;

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

const HcpField = ({ label, req, hint, err, htmlFor, children, wide }) => (
  <div className={`hcp-field${wide ? " hcp-field--wide" : ""}`}>
    <label className="hcp-label" htmlFor={htmlFor}>{label}{req && <span className="hcp-req">*</span>}</label>
    {children}
    {err && <div className="hcp-fielderr">{err}</div>}
    {hint && !err && <div className="hcp-hint">{hint}</div>}
  </div>
);

/* ------------------------------------------------------------------ *
 * One category ladder: three tiers. Tier 2's From and tier 3's From are
 * readonly mirrors (the real form JS copies first_max → second_min and
 * second_max → <cat>_min), tier 3's To is a disabled "-" (open ended).
 * ------------------------------------------------------------------ */
const HcpCatBox = ({ cat, value, onChange, errs, hidden }) => {
  const v = value;
  const set = (k, val) => onChange({ ...v, [k]: val });
  const money = cat.unit === "Rake" ? "Rake" : "GGR";
  const err = errs[cat.key];
  return (
    <section className={`hcp-cat${err ? " hcp-cat--err" : ""}${hidden ? " hcp-cat--off" : ""}`}>
      <div className="hcp-cat__head">
        <div className="hcp-cat__title">{cat.label}</div>
        <div className="hcp-cat__sub">
          {cat.sub}
          <Tip size={12}>
            Three tiers of {money}. Tiers 1 and 2 are stored as the "/"-imploded strings
            <code> {cat.key}_perc_first_range</code> and <code>{cat.key}_perc_second_range</code>
            ("min/max/perc"); the open-ended third tier's percentage goes to the scalar
            <code> {cat.key}_perc</code> column. <code>CommissionsController</code> matches the period's
            profit against these ranges to pick the rate.
          </Tip>
        </div>
      </div>

      <div className="hcp-tiers">
        <div className="hcp-tier">
          <span className="hcp-tier__n">1</span>
          <span className="hcp-tier__lb">From</span>
          <input id={`hcp-${cat.key}-fmin`} className="input hcp-in" inputMode="decimal" placeholder={money}
            value={v.fMin} onChange={e => set("fMin", e.target.value)} />
          <span className="hcp-tier__lb">To</span>
          <input id={`hcp-${cat.key}-fmax`} className="input hcp-in" inputMode="decimal" placeholder={money}
            value={v.fMax} onChange={e => set("fMax", e.target.value)} />
          <input id={`hcp-${cat.key}-fperc`} className="input hcp-in hcp-in--pct" inputMode="decimal" placeholder="0.00"
            value={v.fPerc} onChange={e => set("fPerc", e.target.value)} />
          <span className="hcp-pct">%</span>
        </div>

        <div className="hcp-tier">
          <span className="hcp-tier__n">2</span>
          <span className="hcp-tier__lb">From</span>
          {/* second_min is readonly and JS-mirrored from first_max — kept readonly here too. */}
          <input id={`hcp-${cat.key}-smin`} className="input hcp-in hcp-in--ro" readOnly tabIndex={-1}
            value={hcpNum(v.fMax)} title="Mirrored from tier 1 'To' (the real form copies first_max into second_min)" />
          <span className="hcp-tier__lb">To</span>
          <input id={`hcp-${cat.key}-smax`} className="input hcp-in" inputMode="decimal" placeholder={money}
            value={v.sMax} onChange={e => set("sMax", e.target.value)} />
          <input id={`hcp-${cat.key}-sperc`} className="input hcp-in hcp-in--pct" inputMode="decimal" placeholder="0.00"
            value={v.sPerc} onChange={e => set("sPerc", e.target.value)} />
          <span className="hcp-pct">%</span>
        </div>

        <div className="hcp-tier">
          <span className="hcp-tier__n">3</span>
          <span className="hcp-tier__lb">From</span>
          <input id={`hcp-${cat.key}-tmin`} className="input hcp-in hcp-in--ro" readOnly tabIndex={-1}
            value={hcpNum(v.sMax)} title="Mirrored from tier 2 'To'" />
          <span className="hcp-tier__lb">To</span>
          <input className="input hcp-in hcp-in--ro" readOnly tabIndex={-1} value="-" title="Open ended — no upper bound" />
          <input id={`hcp-${cat.key}-tperc`} className="input hcp-in hcp-in--pct" inputMode="decimal" placeholder="0.00"
            value={v.tPerc} onChange={e => set("tPerc", e.target.value)} />
          <span className="hcp-pct">%</span>
        </div>
      </div>

      <div className="hcp-cat__store">
        <code>{cat.key}_perc_first_range</code> = "{hcpRangeStr(v.fMin, v.fMax, v.fPerc)}" ·
        <code> {cat.key}_perc_second_range</code> = "{hcpRangeStr(v.fMax, v.sMax, v.sPerc)}" ·
        <code> {cat.key}_perc</code> = {hcpNum(v.tPerc) || "—"}
      </div>
      {err && <div className="hcp-err"><Icon name="alert" size={12} /> {err}</div>}
    </section>
  );
};

/* ------------------------------------------------------------------ *
 * Cobanco provider costs — replaces the category boxes when Cobanco is
 * on. One providers[<id>][percentage] input per skin provider, editable
 * only when the skin admin holds that provider at > 0% (users_providers);
 * the master's percentage is shown as "Min: X%".
 * Saving syncs profili_cobanco_providers through updateProviderCostsProfile()
 * (delete-missing / create / update-percentage; providers absent from the
 * skin admin's user_providers are skipped) — see divergence 1 in the header
 * for the empty-profile_id bug this panel deliberately does not reproduce.
 * ------------------------------------------------------------------ */
const HcpCobancoTable = ({ skinId, value, onChange, profileId, providers, masterRates }) => {
  /* The editable set and every minimum come from the SKIN ADMIN's own user_providers rows, so the
     table is meaningless before a skin is chosen (the real form is always opened with one, since the
     blade renders the skin's providers server-side). */
  if (!skinId) {
    return (
      <section className="hcp-cob">
        <div className="hcp-cat__head">
          <div className="hcp-cat__title">Cobanco provider costs</div>
          <div className="hcp-cat__sub">Set the provider costs for calculating the cobanco</div>
        </div>
        <div className="hcp-note hcp-note--warn">
          <Icon name="alert" size={12} />
          <span>Pick a <b>Skin</b> first — the providers listed here and their minimum percentages are the skin
            admin's own <code>user_providers</code> contract rows.</span>
        </div>
      </section>
    );
  }
  /* `min` is the skin admin's own percentage on that provider — the cost a
     cobanco profile takes a share of. A provider absent from their contract, or
     held at 0, cannot be priced: the profile would promise a share of nothing.
     Zero when unknown, which disables the input; 059 refuses the same case
     server-side, so a stale client view cannot let one through. */
  const rows = (providers || []).map(p => ({ ...p, min: Number((masterRates || {})[p.id] || 0) }));
  const priced = rows.filter(r => r.min > 0 && hcpFilled(value[r.id])).length;
  return (
    <section className="hcp-cob">
      <div className="hcp-cat__head">
        <div className="hcp-cat__title">Cobanco provider costs</div>
        <div className="hcp-cat__sub">Set the provider costs for calculating the cobanco</div>
      </div>

      <div className="hcp-cob__grid">
        {rows.map(r => {
          const off = r.min <= 0;
          return (
            <div key={r.id} className={`hcp-cob__row${off ? " hcp-cob__row--off" : ""}`}>
              <div className="hcp-cob__n">{r.name}<span className="hcp-cob__id">#{r.id}</span></div>
              <div className="hcp-cob__in">
                <input id={`hcp-cob-${r.id}`} className="input hcp-in hcp-in--pct" inputMode="decimal" placeholder="0.00"
                  disabled={off} value={off ? "" : (value[r.id] == null ? "" : value[r.id])}
                  onChange={e => onChange({ ...value, [r.id]: e.target.value })} />
                <span className="hcp-pct">%</span>
              </div>
              {off
                ? <div className="hcp-cob__no">Not enabled on the master or the % is 0</div>
                : <div className="hcp-cob__min">Min: {r.min}%</div>}
            </div>
          );
        })}
      </div>

      <div className="hcp-hint">
        {/* Evident intent of the cobanco save-order bug (header divergence 1): the rows below are written
            AFTER the profile row exists, against its real id — never with an empty profile_id, and never
            at all when validation fails. */}
        On save, <b>{priced}</b> row{priced === 1 ? "" : "s"} are synced into <code>profili_cobanco_providers</code>
        with <code>profile_id = {profileId != null ? profileId : "the id of the profile created by this save"}</code>.
        Providers the skin admin does not hold are skipped, exactly as <code>updateProviderCostsProfile()</code> does.
        The percentages price this master's GGR cost in the Costs / Business reports (internal cost_type 2).
      </div>
      <div className="hcp-note hcp-note--warn">
        <Icon name="alert" size={12} />
        <span>
          On the real platform this table is written <b>before</b> the profile is created and before validation
          returns, so a brand-new cobanco profile produces orphan rows with an <b>empty</b> <code>profile_id</code>
          and saves with no provider costs. Fixed here — see the file header.
        </span>
      </div>
    </section>
  );
};

/* ------------------------------------------------------------------ *
 * Edit window. One rule (getDayOfCurrentPeriod() > 2), the exemption
 * honoured, and the state explained — see header divergence 3.
 * ------------------------------------------------------------------ */
const HcpEditWindow = ({ isNew, exempt, skin }) => {
  const today = new Date();
  const day = hcpPeriodDay(today);
  const start = hcpPeriodStart(today);
  if (isNew) return null;
  const open = day <= 2;
  return (
    <div className={`hcp-note ${open ? "hcp-note--ok" : exempt ? "hcp-note--warn" : "hcp-note--err"}`}>
      <Icon name={open ? "check" : "alert"} size={12} />
      <span>
        {open
          ? <>Day <b>{day}</b> of the current commission period (started {hcpFmtDate(start)}) — inside the 2-day edit window.</>
          : <>Commissions profiles are only editable on the first 2 days of the current period. Today is day <b>{day}</b> (period started {hcpFmtDate(start)}).{" "}
            {exempt
              ? <>Saving stays enabled because this viewer is exempt — Super Admin, or a skin admin of <b>{skin ? skin.name : "this skin"}</b> with <code>disable_change_comm_profile_limits</code>.</>
              : <>Saving is blocked for this viewer.</>}</>}
        <Tip size={12}>
          Real platform: the banner uses <code>getDayOfCurrentPeriod()</code> (days since the first Monday of
          the month) and only renders for non-exempt viewers, while the JS hides the Save button on plain
          calendar day &gt; 2 for <b>everyone including the super admin</b> — and the element it shows instead
          (<code>#cant_save_label</code>) exists nowhere, so Save just disappears with no explanation.
          <code> saveCommissionsprofile</code> enforces no date rule at all, so the endpoint accepts saves any day.
        </Tip>
      </span>
    </div>
  );
};

/* ------------------------------------------------------------------ *
 * Create / Edit — GET /commissionsprofiles/form/?id= then POST
 * /commissionsprofiles/saveCommissionsprofile/?id=. Neither endpoint has
 * any role check (see HcpGateRail).
 * ------------------------------------------------------------------ */
const HcpFormModal = ({ profile, onClose, onSave, isAdmin, authSkinId, skins, sportProfiles, providers, masterRates, skinAdminId }) => {
  const isNew = !profile;
  const [name, setName] = hcpUseState(profile ? profile.name : "");
  const [skinId, setSkinId] = hcpUseState(profile ? String(profile.skinId) : (isAdmin ? "" : String(authSkinId)));
  const [lvl, setLvl] = hcpUseState(profile ? String(profile.lvl) : "");
  const [period, setPeriod] = hcpUseState(profile ? profile.period : "m");
  const [autoPay, setAutoPay] = hcpUseState(profile ? !!profile.autoPay : false);
  const [cobanco, setCobanco] = hcpUseState(profile ? !!profile.cobanco : false);
  const [sportId, setSportId] = hcpUseState(profile && profile.sportId ? String(profile.sportId) : "");
  const [cats, setCats] = hcpUseState(() => {
    const base = {};
    HCP_CATS.forEach(c => {
      base[c.key] = profile ? { ...profile.cats[c.key] } : { fMin: "0", fMax: "", fPerc: "", sMax: "", sPerc: "", tPerc: "" };
    });
    return base;
  });
  const [cob, setCob] = hcpUseState(() => (profile ? { ...profile.cobancoProviders } : {}));
  const [errs, setErrs] = hcpUseState({});     // "campierrati" — the field list the server flags
  const [banner, setBanner] = hcpUseState("");

  const skin = (skins || []).filter(x => x.id === Number(skinId))[0] || null;
  /* Category boxes render per skin flag OR'd with isadmin() (controller L179-191 / L280-284): the demo
     session is the super admin, so every box renders; the chip under each title says whether the skin
     admin would have seen it. Fields of a hidden category are not saved for that viewer. */
  const catOn = (c) => isAdmin || !!(skin && skin.flags[c.flag]);
  const showSport = isAdmin || !!(skin && skin.flags.show_sport);
  /* Cobanco switch: only when can_provv_cobanco (or superadmin), and the form's JS only reveals it while
     User Type is Master(8) or still empty. Persisted as 1 only for Master, else forced 0 (L290-294). */
  const cobancoAllowed = isAdmin || !!(skin && skin.flags.can_provv_cobanco);
  const cobancoVisible = cobancoAllowed && (lvl === "" || Number(lvl) === 8);
  const cobancoOn = cobanco && Number(lvl) === 8;
  const exempt = isAdmin || !!(skin && skin.flags.disable_change_comm_profile_limits);

  const setCat = (key, v) => setCats(cs => ({ ...cs, [key]: v }));

  const save = () => {
    /* Mirrors the inline validation in saveCommissionsprofile (there is no FormRequest):
       name / skin_id / user_level required → backend.fill_in_all_fields "Fill in all fields";
       per VISIBLE category (cobanco unchecked) all six range fields required, '0' accepted →
       backend.fill_in_all_fields_category "Fill in all fields :category"; first_min >= first_max or
       second_min >= second_max → backend.impossible_interval_category "Impossible interval :category";
       the third-tier <cat>_perc is required only for NON-admins (L308/326/348/370). */
    const e = {};
    if (!name.trim()) e.name = "Fill in all fields";
    if (!skinId) e.skin_id = "Fill in all fields";
    if (!lvl) e.user_level = "Fill in all fields";
    if (!cobancoOn) {
      HCP_CATS.forEach(c => {
        if (!catOn(c)) return;
        const v = cats[c.key];
        const six = [v.fMin, v.fMax, v.fPerc, hcpNum(v.fMax), v.sMax, v.sPerc];
        if (six.some(x => !hcpFilled(x)) || (!isAdmin && !hcpFilled(v.tPerc))) { e[c.key] = `Fill in all fields ${c.label}`; return; }
        const fMin = parseFloat(hcpNum(v.fMin)), fMax = parseFloat(hcpNum(v.fMax));
        const sMin = fMax, sMax = parseFloat(hcpNum(v.sMax));
        if (!(fMin < fMax) || !(sMin < sMax)) e[c.key] = `Impossible interval ${c.label}`;
      });
    }
    setErrs(e);
    const first = ["name", "skin_id", "user_level"].map(k => e[k]).filter(Boolean)[0] || HCP_CATS.map(c => e[c.key]).filter(Boolean)[0];
    if (first) { setBanner(first); return; }
    setBanner("");

    /* KNOWN-BUG DIVERGENCE (header 1): the real save calls updateProviderCostsProfile() here — before
       this validation could return and before the profile row exists — so a new cobanco profile writes
       profili_cobanco_providers rows with an EMPTY profile_id. Evident intent implemented: validation
       has already passed, and the cobanco rows travel with the payload so onSave() can attach them to
       the row's real id (a new one is minted there). Nothing is written on a failed save. */
    onSave({
      id: isNew ? null : profile.id,
      name: name.trim(),
      skinId: Number(skinId),
      lvl: Number(lvl),
      period,
      autoPay: autoPay ? 1 : 0,
      cobanco: cobancoOn ? 1 : 0,                                   // forced 0 unless Master (L290-294)
      sportId: showSport && !cobancoOn && sportId ? Number(sportId) : null, // saved only when show_sport && !cobanco
      cats: JSON.parse(JSON.stringify(cats)),
      cobancoProviders: cobancoOn
        ? Object.keys(cob).reduce((acc, k) => { if (hcpFilled(cob[k]) && Number((masterRates || {})[k] || 0) > 0) acc[k] = hcpNum(cob[k]); return acc; }, {})
        : {},
      users: profile ? profile.users : 0,
    });
    onClose();
  };

  const clearErr = (k) => { setErrs(x => { const n = { ...x }; delete n[k]; return n; }); setBanner(""); };
  const sport = (sportProfiles || []).filter(x => x.id === Number(sportId))[0] || null;
  const crossSkin = sport && skin && sport.skinId !== skin.id;

  return (
    <HcpModal
      wide
      /* label inferred — the real modal's "new" title is the leftover Italian "Nuovo commissionsprofile"
         (modals/commissionsprofile.blade.php:8) and the edit prefix is "Modifica" (ajax.js:41). */
      title={isNew ? <>New profile{/* label inferred */}</> : <>Edit {profile.name}{/* label inferred */}</>}
      sub={isNew
        ? "POST /commissionsprofiles/saveCommissionsprofile/ — stato is hardcoded to 1 on every save"
        : `profili_provvigionali.id ${profile.id} · GET /commissionsprofiles/form/?id=${profile.id}`}
      onClose={onClose}
      footer={<>
        <button className="btn btn--secondary" onClick={onClose}>Close</button>
        <button className="btn btn--primary" onClick={save}><Icon name="check" size={13} /> Save</button>
      </>}>

      {banner && <div className="hcp-err hcp-err--banner"><Icon name="alert" size={13} /> {banner}</div>}
      <HcpEditWindow isNew={isNew} exempt={exempt} skin={skin} />

      <div className="hcp-sectitle">Data</div>

      <div className="hcp-grid">
        <HcpField label="Name" req htmlFor="hcp-name" err={errs.name}>
          <input id="hcp-name" className={`input${errs.name ? " hcp-invalid" : ""}`} autoFocus
            value={name} onChange={e => { setName(e.target.value); clearErr("name"); }} />
        </HcpField>

        <HcpField label="Skin" req htmlFor="hcp-skin" err={errs.skin_id}
          hint={isAdmin ? "Super admins pick any skin; a skin admin gets a hidden input pinned to their own." : undefined}>
          <select id="hcp-skin" className={`select${errs.skin_id ? " hcp-invalid" : ""}`} disabled={!isAdmin}
            value={skinId} onChange={e => { setSkinId(e.target.value); clearErr("skin_id"); }}>
            <option value="">- Select -</option>
            {(skins || []).map(sk => <option key={sk.id} value={sk.id}>{sk.name}</option>)}
          </select>
        </HcpField>

        <HcpField label="User Type" req htmlFor="hcp-lvl" err={errs.user_level}
          hint="usersLevels() minus Player(30) and everything below Master(8). A profile is only offered to users of its own level.">
          <select id="hcp-lvl" className={`select${errs.user_level ? " hcp-invalid" : ""}`}
            value={lvl} onChange={e => { setLvl(e.target.value); clearErr("user_level"); }}>
            <option value="">- Select -</option>
            {HCP_LEVELS.map(l => <option key={l.lvl} value={l.lvl}>{l.name}</option>)}
          </select>
        </HcpField>

        <HcpField label="Period" htmlFor="hcp-period"
          hint="Which commission run picks this profile up: Monthly rows come from payMonthlyCommissions, Weekly from payWeeklyCommissions.">
          <select id="hcp-period" className="select" value={period} onChange={e => setPeriod(e.target.value)}>
            {HCP_PERIODS.map(p => <option key={p.value} value={p.value}>{p.label}</option>)}
          </select>
          {/* label inferred — backend.monthly / backend.weekly resolve in no committed lang file. */}
        </HcpField>

        <div className="hcp-switches">
          <div className="hcp-switch">
            <div className="hcp-switch__l">
              Auto Pay
              <Tip size={12}>Stored from <code>auto_pay == 'on'</code>. It seeds the <code>payable</code> flag on every
                commission payment row this profile generates — payable rows are paid by the cron without an operator
                click on the Commission Payments screen.</Tip>
            </div>
            <Toggle value={autoPay} onChange={setAutoPay} onLabel="" offLabel="" size="sm" />
          </div>

          {cobancoVisible && (
            <div className="hcp-switch">
              <div className="hcp-switch__l">
                Cobanco
                <Tip size={12}>Only Masters can be Cobanco: the switch is revealed while User Type is Master or still
                  empty, and the server forces <code>cobanco = 0</code> for any other level. Rendered at all only for
                  skins with <code>can_provv_cobanco</code> (or for a super admin). A cobanco profile replaces the
                  commission ladders with per-provider cost percentages.</Tip>
              </div>
              <Toggle value={cobanco} onChange={setCobanco} onLabel="" offLabel="" size="sm" />
            </div>
          )}
        </div>
      </div>

      {cobanco && lvl !== "" && Number(lvl) !== 8 && (
        <div className="hcp-note hcp-note--warn">
          <Icon name="alert" size={12} />
          <span>Cobanco is only persisted for <b>Master</b>. With User Type <b>{hcpLevelName(lvl)}</b> the save forces
            <code> cobanco = 0</code> and the commission ladders below are what gets stored.</span>
        </div>
      )}

      {showSport && !cobancoOn && (
        <HcpField label={<>Sport profile{/* label inferred — backend.sport_profile resolves to the Italian "Profilo sport" */}</>}
          htmlFor="hcp-sport" wide
          hint="Optional. Saved only while the skin has show_sport and Cobanco is off. Sport percentages come from this linked sport_profiles row, never from this form.">
          <select id="hcp-sport" className="select" value={sportId} onChange={e => setSportId(e.target.value)}>
            <option value="">- Select -</option>
            {(sportProfiles || []).map(p => (
              <option key={p.id} value={p.id}>{p.name}{p.tipologia ? ` — ${p.tipologia}` : ""} · {hcpNameIn(skins, p.skinId)}</option>
            ))}
          </select>
          {crossSkin && (
            <div className="hcp-note hcp-note--warn">
              <Icon name="alert" size={12} />
              <span><b>{sport.name}</b> belongs to skin <b>{hcpNameIn(skins, sport.skinId)}</b>, not <b>{skin.name}</b>. The real
                select is unscoped — <code>getSportprofilesList()</code> ignores the skin the blade passes it — so this
                cross-skin link saves without complaint.</span>
            </div>
          )}
        </HcpField>
      )}

      {!isNew && (
        <div className="hcp-assigned">
          <Icon name="users" size={13} />
          <span><b>Users Assigned</b> {profile.users === null ? "—" : profile.users}</span>
          <Tip size={12}>Read-only count of <code>users</code> rows with <code>stato = 1</code> and
            <code> profilo_provvigionale = {profile.id}</code>. Changing the ladders below changes what those users earn
            from the next commission run.</Tip>
        </div>
      )}

      {cobancoOn ? (
        <HcpCobancoTable providers={providers} masterRates={masterRates} skinId={Number(skinId) || (profile ? profile.skinId : 0)} value={cob} onChange={setCob}
          profileId={isNew ? null : profile.id} />
      ) : (
        <>
          <div className="hcp-sectitle hcp-sectitle--sub">
            Commission ladders
            <span className="hcp-sectitle__note">Sport is not here — it comes from the linked sport profile.</span>
          </div>
          <div className="hcp-cats">
            {HCP_CATS.map(c => (
              <div key={c.key} className="hcp-catwrap">
                {!catOn(c) && (
                  <div className="hcp-cat__flagchip" title={`skin_settings.${c.flag} is not set on this skin`}>
                    hidden for skin admins · {c.flag} off
                  </div>
                )}
                <HcpCatBox cat={c} value={cats[c.key]} onChange={(v) => { setCat(c.key, v); clearErr(c.key); }}
                  errs={errs} hidden={!catOn(c)} />
              </div>
            ))}
          </div>
        </>
      )}
    </HcpModal>
  );
};

/* ------------------------------------------------------------------ *
 * Delete — GET /commissionsprofiles/delete/{id}/ behind a JS confirm.
 * The trash icon renders for every viewer, but the endpoint answers
 * "Permission error" to anyone who is not a super admin.
 * KNOWN-BUG DIVERGENCE (header 2): the real endpoint has no in-use guard,
 * so deleting an assigned profile orphans users.profilo_provvigionale.
 * The evident intent — refuse while users are attached — is implemented.
 * ------------------------------------------------------------------ */
const HcpDeleteDialog = ({ profile, onClose, onConfirm, skins }) => {
  const blocked = profile.users > 0;
  return (
    <HcpModal title={blocked ? "Cannot delete this profile" : "Delete commission profile"}
      sub={`GET /commissionsprofiles/delete/${profile.id}/`}
      onClose={onClose}
      footer={blocked
        ? <button className="btn btn--secondary" onClick={onClose}>Close</button>
        : <>
          <button className="btn btn--secondary" onClick={onClose}>Cancel</button>
          <button className="btn btn--danger" onClick={() => { onConfirm(profile); onClose(); }}>
            <Icon name="trash" size={13} /> Delete
          </button>
        </>}>
      {blocked ? (
        <>
          <div className="hcp-err"><Icon name="alert" size={13} /> <b>{profile.name}</b> is assigned to <b>{profile.users}</b> active user{profile.users === 1 ? "" : "s"}.</div>
          <div className="hcp-hint">
            Deleting it would leave those users' <code>profilo_provvigionale</code> pointing at a row that no longer
            exists: they would read as <b>Not set</b> in the Commissions report and earn nothing from the next
            commission run. <b>The real endpoint performs no such check</b> — the Users Assigned count is display-only
            and the delete succeeds — so this prototype implements the evident intent instead. Reassign the users first.
          </div>
        </>
      ) : (
        <>
          <div className="hcp-dlgq">Delete <b>{profile.name}</b> (ID {profile.id}, {hcpNameIn(skins, profile.skinId)})?</div>
          <div className="hcp-hint">
            No users are assigned, so nothing is orphaned. The real UI sends a plain <code>GET</code> behind a JS
            confirm; the endpoint re-checks <code>isadmin()</code> and answers <code>Permission error</code> to
            everyone else, even though the trash icon renders for every viewer.
            {profile.cobanco === 1 && <> This is a cobanco profile — its <code>profili_cobanco_providers</code> rows are left behind by the real delete.</>}
          </div>
        </>
      )}
    </HcpModal>
  );
};

/* ------------------------------------------------------------------ *
 * Gate rail — the four different answers this screen gives to "who may
 * do this?", surfaced instead of implied.
 * ------------------------------------------------------------------ */
const HcpGateRail = () => (
  <div className="hcp-gates">
    <div className="hcp-gate">
      <div className="hcp-gate__k">Sidebar</div>
      <div className="hcp-gate__v">Two entries, one URL: <b>Commissions ▾</b> for <code>isadmin()</code>, <b>Settings ▾</b> for
        <code> !isadmin() &amp;&amp; isSkinAdmin() &amp;&amp; enable_commissions_management</code>.</div>
    </div>
    <div className="hcp-gate">
      <div className="hcp-gate__k">Page + table</div>
      <div className="hcp-gate__v"><code>isadmin() || (isSkinAdmin() &amp;&amp; enable_commissions_management)</code>, else
        <code> abort(404)</code>. Non-admins are forced to their own skin's rows.</div>
    </div>
    <div className="hcp-gate hcp-gate--warn">
      <div className="hcp-gate__k">Form + save</div>
      <div className="hcp-gate__v"><b>No role check at all.</b> <code>commissionsprofileForm</code> and
        <code> saveCommissionsprofile</code> are reachable by any authenticated, 2FA'd back-office user — and the
        submitted <code>skin_id</code> is trusted.</div>
    </div>
    <div className="hcp-gate hcp-gate--warn">
      <div className="hcp-gate__k">Delete</div>
      <div className="hcp-gate__v"><code>isadmin()</code> only, but the trash icon renders for everyone — non-admins get
        <code> Permission error</code> after clicking through the confirm.</div>
    </div>
  </div>
);

/* ------------------------------------------------------------------ */
const CommissionProfiles = () => {
  window.useLocale && window.useLocale();

  /* WAS `const isAdmin = true; const authSkinId = 68;` — a hardcoded demo
     persona. Every gate on this screen (which skin filter renders, which
     category boxes appear, whether Cobanco is offered) hung off those two
     lines, so the screen showed a super admin's view to everybody and a skin
     admin would have seen controls they cannot use. The server refuses either
     way — 059's gate is the authority — but a form that offers what will be
     refused is a form that wastes the operator's afternoon. */
  /* Search fires on the button (#kt_search), not on keyup — hence the
     draft/applied split. */
  const [draft, setDraft] = hcpUseState({ id: "", name: "", skin: "" });
  const [applied, setApplied] = hcpUseState({ id: "", name: "", skin: "" });
  /* DataTable requests [[0,'desc']] -> id DESC; the server's own fallback when
     no order arrives is id ASC. */
  const [sort, setSort] = hcpUseState({ key: "id", dir: "desc" });
  const [page, setPage] = hcpUseState(0);
  const [pageSize, setPageSize] = hcpUseState(50);
  const [form, setForm] = hcpUseState(null);   // null | { profile: row|null }
  const [del, setDel] = hcpUseState(null);     // null | row

  const [hcpMe, setHcpMe] = hcpUseState(null);
  React.useEffect(() => {
    let alive = true;
    Promise.resolve(window.sb.me()).then(r => { if (alive && r && r.ok) setHcpMe(r.data); });
    return () => { alive = false; };
  }, []);
  const isAdmin = !!hcpMe && Number(hcpMe.user_level) === 0;
  const authSkinId = hcpMe ? hcpMe.skin_id : null;

  /* The catalogue, with its children. `commissionProfileDetail` embeds the tier
     ladder, the cobanco provider rows and an assignment COUNT — so the "Users
     Assigned" column is a real count from the database rather than
     `Math.floor(rnd() * 12) + 1`, which is what it used to be. */
  const profFeed  = useHrsFetch(() => window.sb.list("commissionProfileDetail", { limit: 500 }), []);
  const skinFeed  = useHrsFetch(() => window.sb.list("skins", { limit: 200 }), []);
  const sportFeed = useHrsFetch(() => window.sb.list("sportCommissionProfiles", { limit: 300 }), []);
  const provFeed  = useHrsFetch(() => window.sb.list("providers", { limit: 500 }), []);
  /* The skin admins, so the Cobanco panel can say which providers are priced.
     Level 2 is the brand's own admin — `user_providers` on that account is what
     a cobanco rate is a share OF. */
  const adminFeed = useHrsFetch(() => window.sb.list("networkUsers", { limit: 200, filters: { level: 2 } }), []);

  const hcpBusy = profFeed.loading || skinFeed.loading;
  const hcpErr  = profFeed.error || skinFeed.error;

  /* Skins, with their settings. Presence of a `skin_settings` row IS the flag —
     there is no boolean column, which is the trap CLAUDE.md names for anyone
     writing SQL against these. The nine-brand hardcoded HCP_SKINS array with
     its invented flag matrix is gone. */
  const hcpSkins = hcpUseMemo(() => (skinFeed.data || []).map(sk => {
    const flags = {};
    (sk.skin_settings || []).forEach(r => { flags[r.setting] = 1; });
    return { id: sk.id, name: sk.name, cur: sk.currency, flags };
  }), [skinFeed.data]);
  const hcpSkinOf = (id) => hcpSkins.filter(x => x.id === Number(id))[0] || null;
  const hcpNameOfSkin = (id) => { const x = hcpSkinOf(id); return x ? x.name : `#${id}`; };

  const hcpSportProfiles = hcpUseMemo(() => (sportFeed.data || []).map(sp => ({
    id: sp.id, name: sp.name, skinId: sp.skin_id,
    tipologia: sp.typology ? sp.typology.label : null,
  })), [sportFeed.data]);

  const hcpProviders = hcpUseMemo(
    () => (provFeed.data || []).map(pr => ({ id: pr.id, name: pr.name })),
    [provFeed.data]);

  /* skin_id -> the brand admin's user id. */
  const hcpSkinAdmin = hcpUseMemo(() => {
    const m = {};
    (adminFeed.data || []).forEach(u => { if (m[u.skin_id] == null) m[u.skin_id] = u.id; });
    return m;
  }, [adminFeed.data]);

  /* THE LADDER, out of the embedded tier rows. The screen's editor speaks in
     six boxes per category (first min/max/%, second max/%, third %), which is
     three bands whose edges MEET — so it is read back the same way, and a row
     that does not have all three tiers is left blank rather than filled in with
     a plausible number. */
  const hcpRowFromDb = (p) => {
    const cats = {};
    HCP_CATS.forEach(c => { cats[c.key] = { fMin: "", fMax: "", fPerc: "", sMax: "", sPerc: "", tPerc: "" }; });
    const byCat = {};
    (p.tiers || []).forEach(t => {
      const code = t.category ? t.category.code : null;
      const key = code === "casino_live" ? "casinolive" : code;
      if (!key) return;
      (byCat[key] = byCat[key] || {})[Number(t.tier)] = t;
    });
    Object.keys(byCat).forEach(key => {
      if (!cats[key]) return;
      const t1 = byCat[key][1], t2 = byCat[key][2], t3 = byCat[key][3];
      if (t1) { cats[key].fMin = String(t1.from_amount); cats[key].fMax = t1.to_amount == null ? "" : String(t1.to_amount); cats[key].fPerc = String(t1.percentage); }
      if (t2) { cats[key].sMax = t2.to_amount == null ? "" : String(t2.to_amount); cats[key].sPerc = String(t2.percentage); }
      if (t3) { cats[key].tPerc = String(t3.percentage); }
    });
    const cob = {};
    (p.cobancoProviders || []).forEach(r => { cob[r.provider_id] = String(r.percentage); });
    return {
      id: p.id, name: p.name, skinId: p.skin_id, lvl: p.user_level,
      period: p.calc_period, autoPay: p.auto_pay ? 1 : 0, cobanco: p.cobanco ? 1 : 0,
      sportId: p.sport_profile_id, note: p.note,
      minCoupons: p.min_coupons, minStake: p.min_stake, active: p.active,
      cats, cobancoProviders: cob,
      /* PostgREST returns an embedded aggregate as [{count: n}]. Absent means
         the embed failed, which is not the same as nobody being assigned — so
         it stays null and the column says so. */
      users: Array.isArray(p.assignments) && p.assignments.length
        ? Number(p.assignments[0].count) : null,
    };
  };
  const rows = hcpUseMemo(() => (profFeed.data || []).map(hcpRowFromDb), [profFeed.data]);

  const FIELDS = [
    { key: "id", label: "ID", type: "text", icon: "tag", placeholder: "Exact ID", width: 140,
      tip: <>Exact <code>=</code> match on <code>profili_provvigionali.id</code> — not a contains search.</> },
    { key: "name", label: "Name", type: "text", icon: "search", placeholder: "Profile name", grow: true,
      tip: <><code>LIKE '%value%'</code> on <code>profili_provvigionali.name</code>.</> },
    /* Skin filter is rendered for super admins only (index.blade.php:69-79); a skin admin's rows are
       already forced to their own skin server-side (L126-128), so the control would be a no-op. */
    { key: "skin", label: "Skin", type: "select", icon: "flag", placeholder: "Select", width: 200,
      hidden: !isAdmin, options: hcpSkins.map(sk => ({ value: String(sk.id), label: sk.name })),
      tip: <>Exact match on <code>profili_provvigionali.skin_id</code>. Super-admin only — a skin admin's list is
        already pinned to their own skin.</> },
  ];

  const filtered = hcpUseMemo(() => {
    const idq = String(applied.id || "").trim();
    const nq = String(applied.name || "").trim().toLowerCase();
    const sq = String(applied.skin || "").trim();
    return rows.filter(r => {
      if (idq && String(r.id) !== idq) return false;
      if (nq && r.name.toLowerCase().indexOf(nq) === -1) return false;
      if (sq && String(r.skinId) !== sq) return false;
      return true;
    });
  }, [rows, applied]);

  const sorted = hcpUseMemo(() => {
    const dir = sort.dir === "asc" ? 1 : -1;
    return filtered.slice().sort((a, b) => {
      if (sort.key === "name") return a.name.localeCompare(b.name) * dir;
      /* Faithful to the server switch: the Skin header orders by profili_provvigionali.skin_id, NOT by
         the joined skins.name that the cell displays — so the visible order is not alphabetical. */
      if (sort.key === "skin") return ((a.skinId - b.skinId) || (a.id - b.id)) * dir;
      return (a.id - b.id) * dir;
    });
  }, [filtered, sort]);

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

  const onSearch = (v) => { setApplied({ id: v.id || "", name: v.name || "", skin: v.skin || "" }); setPage(0); };
  /* ajax.js:93 binds a #kt_reset handler but the view renders no reset button — the shell's Reset is the
     client-side clear that handler was written for. */
  const onReset = () => { setDraft({ id: "", name: "", skin: "" }); setApplied({ id: "", name: "", skin: "" }); setPage(0); };

  /* CATEGORY CODE -> game_categories.id. The screen's four boxes are the four
     categories a commission profile can carry; sport has no box because its
     percentages come from the linked sport profile. Read off the fetched
     catalogue rather than hardcoded, because an id is a fact about the
     database. */
  const catFeed = useHrsFetch(() => window.sb.list("gameCategories", { limit: 50 }), []);
  const hcpCatId = hcpUseMemo(() => {
    const m = {};
    (catFeed.data || []).forEach(c => {
      const key = c.code === "casino_live" ? "casinolive" : c.code;
      m[key] = c.id;
    });
    return m;
  }, [catFeed.data]);

  /* THE COST BASIS A COBANCO RATE IS A SHARE OF. `user_providers` on the
     brand's own admin account: a provider absent there, or held at 0, cannot be
     priced on a cobanco profile at all. Fetched at page level rather than
     inside the modal — a hook inside a conditionally-mounted component changes
     hook order when the modal opens, which in this in-browser-Babel build fails
     as a render error rather than a warning. Keyed on the skin the form is
     currently editing. */
  const hcpFormSkinId = form ? (form.profile ? form.profile.skinId : authSkinId) : null;
  const hcpFormAdmin = hcpFormSkinId ? hcpSkinAdmin[hcpFormSkinId] : null;
  const hcpRateFeed = useHrsFetch(
    () => (hcpFormAdmin
      ? window.sb.list("userProviders", { limit: 500, filters: { user: hcpFormAdmin } })
      : Promise.resolve({ ok: true, data: [] })), [hcpFormAdmin]);
  const hcpFormRates = hcpUseMemo(() => {
    const m = {};
    (hcpRateFeed.data || []).forEach(r => { m[r.provider_id] = Number(r.percentage || 0); });
    return m;
  }, [hcpRateFeed.data]);

  const hcpSave = useHrsSave([profFeed]);

  /* A sport profile by id, out of the fetched list. `hcpSportProfile()` used to
     close over nine transcribed rows and — worse — was called WITHOUT a null
     check three times in the render: a profile pointing at a sport profile the
     hardcoded list did not contain crashed the whole screen on
     `.name of undefined`. */
  const hcpSportOf = (id) => hcpSportProfiles.filter(x => x.id === Number(id))[0] || null;

  /* SIX BOXES PER CATEGORY BECOME THREE BANDS. The form collects first
     min/max/%, second max/% and a third %; the bands MEET, so band 2 starts
     where band 1 ended and band 3 starts where band 2 ended and runs open. 059
     refuses a gap or an overlap, so building them any other way here would just
     produce a refusal with the operator's numbers in it. */
  const hcpTiersFor = (p) => {
    const out = [];
    HCP_CATS.forEach(c => {
      const id = hcpCatId[c.key];
      const v = p.cats[c.key];
      if (id == null || !v) return;
      /* A category the operator did not fill in is OMITTED, not sent as zeroes.
         A zero ladder is a real instruction — pay nothing on this vertical —
         and it must not be what "I left the box alone" means. */
      if (!hcpFilled(v.fMax) || !hcpFilled(v.fPerc) || !hcpFilled(v.sMax) || !hcpFilled(v.sPerc)) return;
      const fMin = parseFloat(hcpNum(v.fMin) || "0");
      const fMax = parseFloat(hcpNum(v.fMax));
      const sMax = parseFloat(hcpNum(v.sMax));
      out.push({ category_id: id, tier: 1, from: fMin, to: fMax, percentage: parseFloat(hcpNum(v.fPerc)) });
      out.push({ category_id: id, tier: 2, from: fMax, to: sMax, percentage: parseFloat(hcpNum(v.sPerc)) });
      out.push({ category_id: id, tier: 3, from: sMax, to: null,
                 percentage: parseFloat(hcpNum(v.tPerc) || hcpNum(v.sPerc)) });
    });
    return out;
  };

  const onSave = (p) => {
    /* ONE CALL. The profile, its tier ladder and its cobanco provider rates go
       in one transaction — see supabase/059. The upstream save writes the
       provider rows BEFORE validating and BEFORE the profile exists, so a new
       cobanco profile gets orphan rows with an empty profile_id and no costs of
       its own; that cannot happen through this path. */
    hcpSave.run(() => window.sb.saveCommissionProfile({
      id: p.id,
      skinId: p.skinId,
      name: p.name,
      userLevel: p.lvl,
      calcPeriod: p.period,
      autoPay: !!p.autoPay,
      cobanco: !!p.cobanco,
      sportProfileId: p.sportId,
      note: p.note || null,
      minCoupons: p.minCoupons || 0,
      minStake: p.minStake || 0,
      tiers: hcpTiersFor(p),
      cobancoRates: Object.keys(p.cobancoProviders).map(k => ({
        provider_id: Number(k), percentage: parseFloat(p.cobancoProviders[k]),
      })),
    }), {
      done: p.id == null ? `Commission profile "${p.name}" created` : `Commission profile "${p.name}" saved`,
      fail: p.id == null ? `"${p.name}" was not created` : `"${p.name}" was not saved`,
    }).then(res => { if (res && res.ok) setForm(null); });
  };

  /* SOFT, AND IT REFUSES WHILE ANYONE IS ASSIGNED. isystem hard-deletes any
     profile — the Users Assigned count on the list is display-only — leaving
     those users pointing at a row that is gone. They read as "Not set" in the
     Commissions report and are skipped by every commission run: they stop being
     paid, and nothing anywhere says why. The refusal comes from the database
     (059), so it holds for any caller, not only this button. */
  const onDelete = (p) => {
    hcpSave.run(() => window.sb.deleteCommissionProfile(p.id), {
      done: `Commission profile "${p.name}" deleted`,
      fail: `"${p.name}" was not deleted`,
    }).then(res => { if (res && res.ok) setDel(null); });
  };

  const acts = (r) => (
    <div className="hcp-acts">
      <button className="hcp-act hcp-act--danger" title="Delete" onClick={(e) => { e.stopPropagation(); setDel(r); }}>
        <Icon name="trash" size={13} />
      </button>
      <button className="hcp-act hcp-act--edit" title="Edit" onClick={(e) => { e.stopPropagation(); setForm({ profile: r }); }}>
        <Icon name="edit" size={13} />
      </button>
    </div>
  );

  const columns = [
    { key: "id", label: "ID", sortable: true, firstDir: "desc", width: 90,
      render: r => <span className="hcp-id">{r.id}</span> },
    /* Name is the edit link (the row also carries a hidden nome_completo that only feeds the modal title).
       The level / period / auto-pay / cobanco chips under it are this rebuild's presentation of columns the
       real list stores but does not show — no new data, no new actions. */
    { key: "name", label: "Name", sortable: true, firstDir: "asc",
      render: r => (
        <div className="hcp-namecell">
          <button className="hcp-namelink" title="Edit" onClick={() => setForm({ profile: r })}>
            <span>{r.name}</span><Icon name="chevron_right" size={13} />
          </button>
          <div className="hcp-tags">
            <span className="hcp-tag">{hcpLevelName(r.lvl)}</span>
            <span className="hcp-tag">{hcpPeriodLabel(r.period)}</span>
            {r.autoPay === 1 && <span className="hcp-tag hcp-tag--ok">Auto Pay</span>}
            {r.cobanco === 1
              ? <span className="hcp-tag hcp-tag--cob">Cobanco</span>
              : (hcpSportOf(r.sportId) ? <span className="hcp-tag hcp-tag--sport">{hcpSportOf(r.sportId).name}</span> : null)}
          </div>
        </div>
      ) },
    { key: "skin", label: "Skin", sortable: true, firstDir: "asc", width: 170,
      render: r => (
        <span className="hcp-skin">
          {hcpNameIn(hcpSkins, r.skinId)}
          {r.sportId && hcpSportOf(r.sportId) && hcpSportOf(r.sportId).skinId !== r.skinId
            ? <Tip size={12}>This profile links <b>{hcpSportOf(r.sportId).name}</b>, a sport profile of
                <b> {hcpNameIn(hcpSkins, hcpSportOf(r.sportId).skinId)}</b>. The form's sport-profile select is not scoped
                by skin — <code>getSportprofilesList()</code> ignores the skin the blade passes it — so cross-skin
                links save without complaint.</Tip>
            : null}
        </span>
      ) },
    { key: "users", label: "Users Assigned", align: "right", width: 150,
      /* NULL IS NOT ZERO. The count comes from an embedded aggregate; absent
         means the embed did not resolve, which is "could not tell", and showing
         it as 0 would read as "safe to delete" on the one column that decides
         exactly that. */
      render: r => (r.users === null
        ? <span className="hcp-users hcp-users--zero" title="The assignment count did not load — this is not a count of zero">—</span>
        : <span className={r.users > 0 ? "hcp-users" : "hcp-users hcp-users--zero"}>{hrsInt(r.users)}</span>) },
    { key: "_acts", label: "Actions", align: "center", width: 118, render: acts },
  ];

  return (
    <HrsShell
      title="Commission profiles"
      subtitle="The GGR/Rake ladders and cobanco costs that decide what every Master, Agent, Promoter and Shop earns"
      gate={["isadmin()", "isSkinAdmin() + enable_commissions_management"]}
      gateNote={<>Either one passes the page and the table; both are re-checked in the controller, which
        <code> abort(404)</code>s otherwise. <b>The form and save endpoints check nothing at all</b>, and delete is
        super-admin-only while its icon renders for everyone — see the four gates below.</>}
      explainer={{ bullets: [
        <>A <b>commission profile</b> (<code>profili_provvigionali</code>) is a rate card for one user level. Users point at
          it through <code>users.profilo_provvigionale</code>, and the monthly/weekly commission runs match the period's
          profit against its tiers to decide what to pay.</>,
        <>Per category — Casino, Casino Live, Virtual (on GGR) and Poker (on Rake) — three tiers: two bounded bands and an
          open-ended one. <b>Sport is not configured here</b>: the profile links a <b>sport profile</b>, which carries the
          sport percentages.</>,
        <><b>Cobanco</b> is the Master-only alternative: instead of commission ladders, the profile carries a cost
          percentage per provider (<code>profili_cobanco_providers</code>), each capped below by what the skin admin
          themselves pays. Those percentages are what the Costs and Business reports charge.</>,
        <><b>Period</b> decides which run picks the profile up (monthly vs weekly), and <b>Auto Pay</b> decides whether the
          rows it generates are paid by the cron or wait for an operator on Commission Payments.</>,
        <>A profile is <b>not</b> the only settlement mode: users with <code>profilo_provvigionale</code> −1 / −2 hold no
          profile and are "at cost" — they settle by paying a per-provider % of GGR instead. Those two pseudo-values are
          offered on the user forms, not here.</>,
      ] }}
      actions={
        <button className="hrs-btn hrs-btn--filters" onClick={() => setForm({ profile: null })}>
          <Icon name="plus" size={14} /> New profile
        </button>
      }>

      <HcpGateRail />

      <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)}`} />

      {hcpBusy && <HrsSkeleton rows={8} cols={5} />}
      {!hcpBusy && hcpErr && <HrsError error={hcpErr} onRetry={() => { profFeed.retry(); skinFeed.retry(); }} />}

      {!hcpBusy && !hcpErr && <HrsTable
        columns={columns} rows={paged} rowKey="id"
        sort={sort} onSort={(s) => { setSort(s); setPage(0); }}
        empty={applied.id || applied.name || applied.skin
          ? "No commission profile matches these filters."
          : "No commission profiles yet — create one with New profile."}
        renderCard={r => (
          <>
            <div className="hrs-card__top">
              <b>{r.name}</b>
              <span className="hcp-id">ID {r.id}</span>
            </div>
            <div className="hrs-card__grid">
              <span>Skin</span><b>{hcpNameIn(hcpSkins, r.skinId)}</b>
              <span>User Type</span><b>{hcpLevelName(r.lvl)}</b>
              <span>Period</span><b>{hcpPeriodLabel(r.period)}</b>
              <span>Users Assigned</span><b>{r.users === null ? "—" : hrsInt(r.users)}</b>
            </div>
            <div className="hcp-tags">
              {r.autoPay === 1 && <span className="hcp-tag hcp-tag--ok">Auto Pay</span>}
              {r.cobanco === 1
                ? <span className="hcp-tag hcp-tag--cob">Cobanco</span>
                : (hcpSportOf(r.sportId) ? <span className="hcp-tag hcp-tag--sport">{hcpSportOf(r.sportId).name}</span> : null)}
            </div>
            <div className="hcp-card__acts">
              <button className="btn btn--secondary btn--sm" onClick={() => setForm({ profile: r })}><Icon name="edit" size={12} /> Edit</button>
              <button className="btn btn--ghost btn--sm hcp-card__del" onClick={() => setDel(r)}><Icon name="trash" size={12} /> Delete</button>
            </div>
          </>
        )} />}

      <div className="hcp-tnote">
        Sortable columns are <b>ID</b>, <b>Name</b> and <b>Skin</b> only — the server's switch covers nothing else, so
        clicking any other header would silently fall back to <code>id ASC</code>. <b>Users Assigned</b> is neither
        sortable nor searchable: it is counted per row after the query (one extra <code>COUNT</code> per row).
        {/* <!-- SUGGESTION: replace the per-row User::active()->count() with a single grouped subquery joined into the
             list query, and register users_cnt as an orderable column — the count is the one number operators use to
             judge whether a profile is safe to change. --> */}
      </div>

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

      {form && <HcpFormModal profile={form.profile} onClose={() => setForm(null)} onSave={onSave}
        isAdmin={isAdmin} authSkinId={authSkinId}
        skins={hcpSkins} sportProfiles={hcpSportProfiles} providers={hcpProviders}
        masterRates={hcpFormRates} skinAdminId={hcpSkinAdmin[form.profile ? form.profile.skinId : authSkinId]} />}
      {del && <HcpDeleteDialog profile={del} onClose={() => setDel(null)} onConfirm={onDelete} skins={hcpSkins} />}
    </HrsShell>
  );
};

/* app.jsx:471 renders <CommissionProfiles/> for route key "comm-profiles" — this is the only definition
   of that global since the legacy HostCommissions.jsx bundle was retired. */
window.CommissionProfiles = CommissionProfiles;
