// Represents: GET /dashboard (unnamed route; the sidebar "Dashboard" item links "/" which redirects there) · DashboardController@index — see docs/ISYSTEM_REFERENCE.md §Batch 1 "Dashboard"
/* Host (white-label) Dashboard — the operator landing page of the Iwakiri
   admin. Distinct from PayBO's own payments Dashboard (protected file).

   Rendered widgets, each traceable to the reference:
     · Players tiles (Players / Players 7d / Players 24h + super-admin skin filter)
     · Users tiles (Users / Users balance / Users credits)
     · Deposit / Withdraw quick buttons (hardcoded skin gate — surfaced honestly)
     · Mobile-only quick menu (real view: `hide-on-desktop`)
     · Promoter box (promoter code + affiliation link + Print QR Code)
     · Sport stats panel (show_sport) — Bet/Win/Profit/% Win + daily column chart
     · "Third parties" panel (show_casino || show_casinolive || show_virtual)

   Dead widgets on the real dashboard — intentionally NOT rendered here
   (policy: no invented content for dead code):
     · "Players balance" tile (backend.players_total_balance) — disabled via
       `if(1==2)` in the Blade (view L129-138, L166-185).
     · "Active players" gauge + 10-min active-players table — commented out
       (view L451-556; DashboardController::lastActiveUsers is still live code
       but only reachable from the commented block).
     · "Last activity" timeline — commented out (view L681-744).
     · Sportsbook Balance Limit tile — real gate is `isSkinAdmin() &&
       checkSkinSett(skin,'enable_sportsbook_balance_limits') && !isadmin()`;
       this demo's persona is a super admin, so the tile can never render.
   <!-- SUGGESTION: delete the commented-out Blade blocks and the footer JS that
        still calls _initActivePlayersWidget() against the non-existent
        #ActivePlayersWidget element on every page load (view L772-834). -->
   <!-- SUGGESTION: the controller also computes show_poker and $isCasino24hs
        and generates a dead $api_token — none are ever used by the view. -->

   Honesty pass (no control fires a toast implying an action that did not happen):
     · quick-menu "Sport bets" now really navigates to /sport/bet — the
       Bet-from-backoffice screen exists here (src/pages/HostSportBet.jsx).
     · "Withdraw" and "Print QR Code" are rendered DISABLED via the shared
       NoBackend affordance (ui.jsx), which names the endpoint they need in
       its title — GET /withdraw/ and GET /printAffQR have no prototype page
       and cannot be faked.
     · "Copy affiliation link" keeps its toast: the clipboard write really
       happens, so the toast is telling the truth.

   Design alignment (canon, uiux plan Wave 1): page chrome is HrsShell with
   the actions slot; KPI tiles are HrsKpis; loading is HrsSkeleton and empties
   are HrsEmpty (skeleton / error / empty / content never coexist); the skin
   filter is a shared HrsFilters control; disabled controls go through the
   shared NoBackend / .btn[disabled] treatment; chart series colors come from
   the --chart-* tokens; formatting via hrsInt/hrsMoney/hrsPct.

   Known real-platform quirks carried as comments, not behavior:
     · `?import_slotomatica` on GET /dashboard runs a full synchronous game
       import inside the request (DashboardController L185-251).
     · The Players-24h AJAX (`/getRegistredPlayersByDates`) also targets a
       #last_30d_online_players element that does not exist in the DOM.
     · Highcharts month/weekday labels are hardcoded Italian; the "Third
       parties" header is hardcoded English. */

const { useState: useStateHd, useMemo: useMemoHd, useEffect: useEffectHd } = React;

/* The controller's window: $start = today − 7d, $end = today → 8 daily
   buckets (the daily chart makes one report call per day of that window).
   Anchored to the real today now, not a frozen 2026-08-07: a dashboard whose
   date range never advances is the first thing that tells an operator the
   screen is not connected to anything. */
const hdWindow8 = () => {
  const out = [];
  const now = new Date();
  const todayUtc = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
  for (let i = 7; i >= 0; i--) {
    const d = new Date(todayUtc - i * 86400000);
    const dd = String(d.getUTCDate()).padStart(2, "0");
    const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
    out.push({ iso: `${d.getUTCFullYear()}-${mm}-${dd}`, dm: `${dd}/${mm}`, dmy: `${dd}/${mm}/${d.getUTCFullYear()}` });
  }
  return out;
};

/* ------------------------------------------------------------------ *
 * Live aggregates.
 *
 * Every number on this page was generated. "Players: 565,513" and "Users
 * balance: 27,638,594,340.74" were literals in the JSX; the registration
 * tiles came from a PRNG seeded per skin; and both Bet/Win/Profit panels were
 * `base + rng() * spread` over eight days.
 *
 * They come from three views now (supabase/011_dashboard_views.sql). The
 * counting and the summing happen in the database, not here: counting a skin's
 * players in the browser means fetching every player to produce one integer,
 * and summing balances there puts every balance on the wire — which is the
 * exposure the RLS policies exist to stop.
 * ------------------------------------------------------------------ */

/* transaction_types ids. Persisted, shared with every report — the panels pick
   ids, never labels, because a label is translated and a mismatch would
   silently contribute nothing and report low. */
const HD_TYPES = {
  SPORT_BET: 3, SPORT_WIN: 4,
  CASINO_BET: 5, CASINO_WIN: 6,
  BETEXCHANGE_BET: 155, BETEXCHANGE_WIN: 160,
};
/* Sport panel = the sport categories; Third parties = casino, casino live and
   virtual, which all settle through the casino types. Bonus-wallet variants
   (15/20/25/30) are deliberately excluded: this panel is real-money GGR. */
const HD_SPORT_TYPES  = [HD_TYPES.SPORT_BET, HD_TYPES.SPORT_WIN, HD_TYPES.BETEXCHANGE_BET, HD_TYPES.BETEXCHANGE_WIN];
const HD_CASINO_TYPES = [HD_TYPES.CASINO_BET, HD_TYPES.CASINO_WIN];
const HD_BET_TYPES = new Set([HD_TYPES.SPORT_BET, HD_TYPES.CASINO_BET, HD_TYPES.BETEXCHANGE_BET]);

/* Daily ledger rows -> the Bet / Win / Profit shape the panel renders.
   `debit` is money out of the player (the stake), `credit` is money back to
   them (the payout) — the view derives both so the sign convention is stated
   once rather than re-derived per screen. */
const hdPanelFrom = (rows, days, typeIds) => {
  const wanted = new Set(typeIds);
  const byDay = {};
  days.forEach(d => { byDay[d.iso] = { bet: 0, win: 0 }; });
  (rows || []).forEach(r => {
    if (!wanted.has(Number(r.type_id))) return;
    const slot = byDay[r.day];
    if (!slot) return;                       // outside the eight-day window
    if (HD_BET_TYPES.has(Number(r.type_id))) slot.bet += Number(r.debit) || 0;
    else slot.win += Number(r.credit) || 0;
  });
  const bet = days.map(d => Math.round(byDay[d.iso].bet * 100) / 100);
  const win = days.map(d => Math.round(byDay[d.iso].win * 100) / 100);
  const profit = bet.map((b, i) => Math.round((b - win[i]) * 100) / 100);
  const totBet = Math.round(bet.reduce((a, v) => a + v, 0) * 100) / 100;
  const totWin = Math.round(win.reduce((a, v) => a + v, 0) * 100) / 100;
  return {
    days, bet, win, profit, totBet, totWin,
    totProfit: Math.round((totBet - totWin) * 100) / 100,
    pctWin: totBet > 0 ? Math.round((totWin / totBet) * 10000) / 100 : 0,
  };
};

/* Bet / Win / Profit series colors — the sanctioned --chart-* ramp
   (styles/tokens.css, Wave 0). --chart-3/4/5 absorbed this file's old hex
   trio (bet green, win deep red, profit dark amber — the Host convention,
   re-stepped so the dataviz CVD/contrast checks pass on white), so the
   rendered colors are unchanged: they just live in tokens now (canon §2.10 —
   no chart hex literals in pages). */
const HD_CHART_COLORS = { bet: "var(--chart-3)", win: "var(--chart-4)", profit: "var(--chart-5)" };

/* Cross-page navigation: the shared goRoute (ui.jsx) pushes the target page's
   canonical path, then app.jsx's popstate handler resolves it — same
   path→page mechanism the browser Back/Forward already uses. Falls back to
   the shared Host toast when the route has no prototype page (canon §2.13:
   hrsToast on Host screens — the local PAYBO.emitToast shape-hack is gone). */
const hdNavTo = (routeId, label) => {
  if (!goRoute(routeId)) hrsToast(label || routeId, "No prototype page is registered for this route yet.");
};

/* ---------- Bet/Win/Profit stats panel (Sport stats · Third parties) ---------- */
const HdStatPanel = ({ icon, title, sub, range, data, loading, note, emptyText }) => {
  const isEmpty = !loading && data.totBet === 0 && data.totWin === 0;
  /* Zero-coloring canon (§2.5): an exact-zero profit renders uncolored — only
     a real sign gets the shared hrs-pos / hrs-neg treatment. */
  const profitCls = data.totProfit > 0 ? "hrs-pos" : data.totProfit < 0 ? "hrs-neg" : "";
  const stats = [
    { k: "Bet", v: hrsMoney(data.totBet) },
    { k: "Win", v: hrsMoney(data.totWin) },
    { k: "Profit", v: hrsMoney(data.totProfit), cls: profitCls },
    { k: "% Win", v: hrsPct(data.pctWin) },
  ];
  return (
    <section className="hd-widget">
      <div className="hd-widget__head">
        <span className="hd-widget__title"><Icon name={icon} size={15} /> {title}{note ? <Tip>{note}</Tip> : null}</span>
        <span className="hd-widget__range">{sub} · {range}</span>
      </div>
      {loading ? (
        /* Shared skeleton instead of the old hd-shim shimmer (canon §2.7):
           totals and chart never render mid-fetch, so the panel cannot show a
           confident 0.00 while the ledger is still loading. */
        <HrsSkeleton rows={4} cols={4} />
      ) : isEmpty ? (
        <HrsEmpty>{emptyText}</HrsEmpty>
      ) : (
        <div className="hd-widget__body">
          {/* Totals over the window — Bet / Win / Profit / % Win
              (backend.st_bet / st_win / st_profit; profit = totBet − totWin) */}
          <div className="hd-stats4">
            {stats.map(s => (
              <div key={s.k} className="hd-stat">
                <div className="hd-stat__k">{s.k}</div>
                <div className={`hd-stat__v${s.cls ? " " + s.cls : ""}`}>{s.v}</div>
              </div>
            ))}
          </div>
          {/* Daily Bet/Win/Profit — the Highcharts column chart equivalent
              (generaGraficoBetWinByType: one report call per day). */}
          <div className="hd-chartcol">
            <div className="hd-legend">
              <span><i style={{ background: HD_CHART_COLORS.bet }} />Bet</span>
              <span><i style={{ background: HD_CHART_COLORS.win }} />Win</span>
              <span><i style={{ background: HD_CHART_COLORS.profit }} />Profit</span>
            </div>
            <BarChart
              series={[data.bet, data.win, data.profit]}
              labels={data.days.map(d => d.dm)}
              colors={[HD_CHART_COLORS.bet, HD_CHART_COLORS.win, HD_CHART_COLORS.profit]}
              seriesLabels={["Bet", "Win", "Profit"]}
              allowNegative={true}
              height={220} />
            <div className="hd-xlabels">
              {data.days.map(d => <span key={d.iso}>{d.dm}</span>)}
            </div>
          </div>
        </div>
      )}
    </section>
  );
};

/* ---------- Mobile-only quick menu (real view: `hide-on-desktop`) ---------- */
const HdQuickMenu = () => {
  const items = [
    // Real gates per link, from the reference (view L276-347):
    { k: "players", label: "Players", icon: "users", route: "host-players" },                 // always
    { k: "users", label: "Users", icon: "user", route: "host-users" },                        // hidden for SHOP level
    { k: "coupons", label: "Sport coupons", icon: "receipt", route: "host-sportcoupons" },    // if show_sport
    { k: "bets", label: "Sport bets", icon: "list", route: "sport-bet" },                     // SHOP or super admin, if show_sport — /sport/bet → HostSportBet.jsx
    { k: "deposit", label: "Deposit", icon: "arrow_down", route: "host-deposit" },            // real link /transfer/ — not ADMINISTRATION/CC/AFFILIATE
    { k: "vouchers", label: "Vouchers", icon: "tag", route: "host-vouchers" },                // SHOP, skin ADMIN or super admin
  ];
  return (
    <div className="hd-quick">
      <div className="hd-block__lbl">
        Quick menu
        <Tip>Mirrors the real dashboard's mobile-only quick menu (<code>hide-on-desktop</code>). Per-link gates: Users is hidden for SHOP-level viewers; Sport coupons/bets need the <code>show_sport</code> skin flag (bets also SHOP or super admin); Deposit is hidden for ADMINISTRATION / CUSTOMER_CARE / AFFILIATE; Vouchers needs SHOP, skin ADMIN or super admin. This persona (super admin) sees all of them.</Tip>
      </div>
      <div className="hd-quick__grid">
        {/* Every quick-menu link now points at a page this prototype actually
            has, so all six navigate for real (hdNavTo → goRoute + popstate).
            "Sport bets" reaches src/pages/HostSportBet.jsx. */}
        {items.map(it => (
          <button key={it.k} className="hd-quick__item"
            onClick={() => hdNavTo(it.route, it.label)}>
            <Icon name={it.icon} size={15} />
            <span>{it.label}</span>
          </button>
        ))}
      </div>
    </div>
  );
};

const HostDashboard = ({ brand }) => {
  window.useLocale && window.useLocale();

  const days = useMemoHd(() => hdWindow8(), []);
  const range = `${days[0].dmy} - ${days[7].dmy}`;
  const [skin, setSkin] = useStateHd("ALL"); // default: session admin_skin if the global switcher is set, else ALL

  const statsFeed = useHrsFetch(() => window.sb.list("dashboardStats", { limit: 500 }), []);
  const ledgerFeed = useHrsFetch(
    () => window.sb.list("dashboardLedgerDaily", { limit: 2000, filters: { from: days[0].iso, to: days[7].iso } }),
    [days[0].iso, days[7].iso]);

  const skins = useMemoHd(
    () => (statsFeed.data || []).map(r => ({ id: String(r.skin_id), name: r.skin_name, currency: r.currency })),
    [statsFeed.data]);

  /* One skin, or every skin the caller can see summed. "ALL" is not a stored
     row — it is the aggregate of what RLS returned, which is exactly what the
     unfiltered descendant count means upstream. */
  const stats = useMemoHd(() => {
    const rows = (statsFeed.data || []).filter(r => skin === "ALL" || String(r.skin_id) === skin);
    const add = (k) => rows.reduce((a, r) => a + (Number(r[k]) || 0), 0);
    /* Currencies do NOT convert here. Summing balances across skins that hold
       different currencies produces a number with no unit, and this screen has
       no rate to convert with — the Currencies screen owns that. So an "ALL"
       total is only shown as money when every visible skin shares a currency;
       otherwise the tile says so. */
    const currencies = Array.from(new Set(rows.map(r => r.currency).filter(Boolean)));
    return {
      players: add("players"), players7d: add("players_7d"), players24h: add("players_24h"),
      operators: add("operators"),
      operatorBalance: add("operator_balance"), operatorCredits: add("operator_credits"),
      currency: currencies.length === 1 ? currencies[0] : null,
      mixedCurrency: currencies.length > 1,
      skins: rows.length,
    };
  }, [statsFeed.data, skin]);

  const ledgerRows = useMemoHd(
    () => (ledgerFeed.data || []).filter(r => skin === "ALL" || String(r.skin_id) === skin),
    [ledgerFeed.data, skin]);
  const sport = useMemoHd(() => hdPanelFrom(ledgerRows, days, HD_SPORT_TYPES), [ledgerRows, days]);
  const thirdParties = useMemoHd(() => hdPanelFrom(ledgerRows, days, HD_CASINO_TYPES), [ledgerRows, days]);

  const boot = statsFeed.loading;
  const pickSkin = (id) => setSkin(id);

  /* Promoter box. `promoter_code` does not exist in this schema — nothing
     stores one and nothing generates one, so "AB0001" and the affiliation link
     built from it were both invented. The box stays (the real dashboard has
     it) and says what is missing instead.
     <!-- SUGGESTION: model the promoter code. isystem keeps it on users and GENERATES one during page load when the account has none (UsersController::affCodeGenerator) — a write on a GET, which is its own problem, but the column is what the signup flow resolves `promoter_code` against and what decides a new player's parent shop. Without it neither the affiliation link nor the QR sheet can be produced. --> */
  const affLink = null;
  const copyLink = () => {};

  return (
    /* .host-dashboard scopes this page's remaining structural CSS; the page
       chrome itself is the shared HrsShell (canon §2.2 — no hand-built
       hd-topbar header). */
    <div className="host-dashboard">
      <HrsShell
        title="Dashboard"
        subtitle={<>Operator overview · last 7 days · {range}</>}
        gate="support_dashboard"
        gateNote={<> Customer Care without it gets an empty welcome page; scoped managers are redirected to /players.</>}
        actions={
          <>
            {/* Real gate: viewer not ADMINISTRATION/CUSTOMER_CARE/AFFILIATE AND
                skin_id ∈ {5, 6, 3, 19} — hardcoded in the Blade (view L259-273).
                Evident intent: quick access to the transfer screens.
                <!-- SUGGESTION: replace the hardcoded skin-id list with a
                     skin_settings flag so new skins can enable the buttons
                     without a code change. --> */}
            <Tip>Shown only when the viewer is not ADMINISTRATION / CUSTOMER_CARE / AFFILIATE <em>and</em> the skin is one of the hardcoded ids 5, 6, 3, 19 (view L259-273). They link to the /deposit/ and /withdraw/ transfer screens.</Tip>
            <button className="btn btn--primary" onClick={() => hdNavTo("host-deposit", "Deposit")}>
              <Icon name="arrow_down" size={14} /> Deposit
            </button>
            {/* Deposit's twin has no prototype page: `/withdraw/`
                (WithdrawController@index) is the agent PSP withdraw shell, a
                different screen from the Withdraws *list* this build does have.
                Kept visible so the pair still documents the real dashboard, but
                rendered through the shared NoBackend disabled affordance
                instead of firing a toast that pretended a navigation happened.
                Same .btn--primary as Deposit so the two still read as one
                control pair. */}
            <NoBackend className="btn btn--primary" what="Withdraw"
              need="the withdraw transfer screen: GET /withdraw/ (WithdrawController@index)">
              <Icon name="arrow_up" size={14} /> Withdraw
            </NoBackend>
            <Tip>Disabled here, not broken: the real button links to <code>GET /withdraw/</code> (WithdrawController@index) — the agent PSP withdraw page, which is <em>not</em> the Withdraws list this prototype builds. No prototype page exists for it, so the button stays on the map but does nothing rather than claiming it navigated.</Tip>
          </>
        }
        explainer={
          <Explainer compact title="What this screen is"
            bullets={[
              <span key="g2">Players row: <code>support_players</code> · Users row: <code>support_users</code>, also hidden for SHOP-level viewers (and when the viewer's parent is SHOP).</span>,
              <span key="g3">Panels: <code>show_sport</code> / <code>show_casino</code> / <code>show_casinolive</code> / <code>show_virtual</code> skin flags (<code>show_poker</code> is computed but has no panel).</span>,
              <span key="g4">Players total and Users balance/credits are cached ~10 minutes per admin — numbers can lag by up to that much.</span>,
              <span key="g5">A "Sportsbook Balance Limit" tile exists only for skin admins with <code>enable_sportsbook_balance_limits</code> (never for super admins) — not rendered for this persona.</span>,
            ]}>
            The white-label operator's landing page: player-base registration KPIs, sub-account totals,
            the promoter / affiliation box, and last-7-days Bet / Win / Profit for Sport and casino
            third parties. This demo's persona is a <strong>super admin</strong>, so every role-gated
            widget renders and the Players-24h skin filter is available.
          </Explainer>
        }>

        {/* ---- Skin filter (super admin only; view L190) ----
            Promoted out of the Players-24h tile into the shared HrsFilters
            control (the hd-skinsel dialect is gone). Live-apply is deliberate
            and documented: picking a skin re-filters rows this page already
            fetched under RLS — no refetch happens, so there is no per-change
            fetch for draft→applied Search semantics to defer (canon §2.3). */}
        <HrsFilters
          fields={[{
            key: "skin", label: "Skin", type: "select", defaultValue: "ALL",
            options: [{ value: "ALL", label: "-ALL-" }, ...skins.map(s => ({ value: s.id, label: s.name }))],
            tip: <span>Skin filter is <strong>super admin only</strong> (<code>isadmin()</code>, view L190) — the AJAX endpoint silently returns [] for anyone else. Real platform: changing it reloads only the 7d and 24h tiles (the Players total is a 10-minute cached count and ignores it); in this prototype it scopes every number on the page. Options: -ALL- plus every skin (skins list cached 24 h).</span>,
          }]}
          values={{ skin }}
          onChange={(_k, v) => pickSkin(v)} />

        {/* A dashboard that renders zeroes because the read failed is worse
            than one that renders nothing: zero is a number an operator will
            act on. Error, skeleton and tiles never coexist (canon §2.7). */}
        {statsFeed.error ? (
          <HrsError error={statsFeed.error} onRetry={statsFeed.retry} />
        ) : (
          <>
            {/* ---- Row 1 · Players tiles (gate: support_players) ---- */}
            <div className="hd-block">
              <div className="hd-block__lbl">
                Players
                <Tip>Row hidden for Customer Care lacking <code>support_players</code> (view L115). Counts are descendants of the viewer in the <code>user_path</code> hierarchy with <code>user_level = PLAYER(30)</code>.</Tip>
              </div>
              {boot ? <HrsSkeleton rows={2} cols={3} /> : (
                /* "Players 24h" is really "registered since today's midnight" —
                   the caption keeps the real label but says what it actually
                   counts.
                   <!-- SUGGESTION: rename the tile "Players today" (or make the
                        query a true rolling 24h) so the label matches the data. --> */
                <HrsKpis items={[
                  { label: "Players", tone: "brand", value: hrsInt(stats.players),
                    sub: skin === "ALL" ? `All descendant player accounts across ${stats.skins} skin${stats.skins === 1 ? "" : "s"}` : "Player accounts on this skin",
                    tip: <span>COUNT of strict descendants (<code>user_path LIKE …/%</code>) with player level. Cached 10 minutes per admin (<code>total_players_&lt;auth_id&gt;</code>). AFFILIATE viewers are additionally filtered by their <code>multiple_skins</code> list.</span> },
                  { label: "Players 7d", tone: "brand", value: hrsInt(stats.players7d),
                    sub: "Registered since midnight 7 days ago",
                    tip: <span>Read-replica count (<code>PlayerReadonly</code>, READ UNCOMMITTED) of players registered since midnight 7 days ago — scoped to your descendants, or to the selected skin for super admins.</span> },
                  { label: "Players 24h", tone: "brand", value: hrsInt(stats.players24h),
                    sub: stats.players24h === 0 ? "No new registrations since midnight" : "Registered since today's midnight — not a rolling 24 h" },
                ]} />
              )}
            </div>

            {/* ---- Row 2 · Users tiles (gate: support_users · viewer below SHOP level) ---- */}
            <div className="hd-block">
              <div className="hd-block__lbl">
                Users
                <Tip>Row hidden for Customer Care lacking <code>support_users</code>, when the viewer's parent is SHOP level, and for viewers at or above <code>SHOP_LEVEL(20)</code> (view L211, L215). "Users" = sub-accounts (admins, agents, shops), i.e. descendants with <code>user_level ≠ 30</code>.</Tip>
              </div>
              {/* Money is only shown as one number when there is one currency to
                  show it in. Adding ARS to BRL because both are numbers produces
                  a figure with no unit — and this screen has no rate to convert
                  with; the Currencies screen owns that. */}
              {boot ? <HrsSkeleton rows={2} cols={3} /> : (
                <HrsKpis items={[
                  { label: "Users", value: hrsInt(stats.operators),
                    sub: "Sub-accounts below you · admins, agents, shops" },
                  { label: "Users balance",
                    value: stats.mixedCurrency ? "—" : hrsMoney(stats.operatorBalance, stats.currency),
                    sub: stats.mixedCurrency
                      ? "Not summable — the visible skins hold different currencies"
                      : "SUM(balance + withdrawable) over sub-accounts",
                    tip: <span>SUM of <code>user_balances.real_total</code> over descendants with <code>user_level &lt; 30</code> — the generated <code>balance + balance_withdrawable</code> column, not one half of it. Pick a single skin to see a per-currency total.</span> },
                  { label: "Users credits",
                    value: stats.mixedCurrency ? "—" : hrsMoney(stats.operatorCredits, stats.currency),
                    sub: stats.mixedCurrency ? "Not summable across currencies" : "SUM(credits) over sub-accounts" },
                ]} />
              )}
            </div>
          </>
        )}

        {/* ---- Mobile-only quick menu ---- */}
        <HdQuickMenu />

        {/* ---- Promoter box (gate: SHOP level or super admin, view L349-401) ---- */}
        <section className="hd-aff">
          <div className="hd-aff__code">
            <div className="hd-aff__label">
              <Icon name="user" size={13} /> Promoter code
              <Tip>Visible to SHOP-level users and super admins. If the account has no <code>promoter_code</code> yet, the real page <strong>generates and saves one during page load</strong> (UsersController::affCodeGenerator, view L364-368).</Tip>
            </div>
            <input className="input" readOnly value="" placeholder="No promoter code — not modelled" />
          </div>
          <div className="hd-aff__linkbox">
            <div className="hd-aff__row">
              <div className="hd-aff__label">
                Affiliation link
                <Tip>Skin domain + <code>/signup/?promoter_code=…</code>. Super admins get the <code>SKIN_DOMAIN</code> placeholder host (view L376-377); skin 60 is special-cased to <code>/home?promoter_code=…</code> (view L373).</Tip>
              </div>
              {/* Real action: GET /printAffQR (UsersController@printAffQR) — a
                  server-rendered QR print page. Nothing in a static prototype
                  can produce it, so the button renders through the shared
                  NoBackend disabled affordance and says what it needs instead
                  of toasting as if the page had opened. */}
              <span className="row gap-2">
                <NoBackend className="btn btn--secondary" what="Print QR Code"
                  need="the QR print page: GET /printAffQR (UsersController@printAffQR)">
                  <Icon name="grid" size={13} /> Print QR Code
                </NoBackend>
                <Tip>Disabled here: the QR sheet is a server-rendered page (<code>GET /printAffQR</code> → UsersController@printAffQR, SHOP level or super admin). The button is kept so the box still matches the real one.</Tip>
              </span>
            </div>
            <div className="hd-aff__inputrow">
              <input className="input" readOnly value={affLink || ""}
                placeholder="No affiliation link — it is built from a promoter code, and no column stores one" />
              {affLink ? (
                <button className="btn btn--secondary btn--icon" title="Copy link" onClick={copyLink}>
                  <Icon name="copy" size={14} />
                </button>
              ) : (
                <NoBackend className="btn btn--secondary btn--icon"
                  title="Nothing to copy — no promoter code is stored, and the affiliation link is built from one">
                  <Icon name="copy" size={14} />
                </NoBackend>
              )}
            </div>
          </div>
        </section>

        {ledgerFeed.error ? (
          <HrsError error={ledgerFeed.error} onRetry={ledgerFeed.retry} />
        ) : (
          <>
            {/* ---- Sport stats (gate: show_sport skin flag) ---- */}
            <HdStatPanel icon="activity" title="Sport stats" sub="Sport stats of last 7 days" range={range}
              data={sport} loading={ledgerFeed.loading}
              emptyText="No sport activity recorded in this window."
              note={<span>Appears only when the <code>show_sport</code> skin flag is on. Aggregates the <code>business_report</code> table for providers with category SPORT(6), scoped to your descendants by <code>user_path</code>; Profit = Bet − Win. The daily chart makes one report call per day of the 8-day window.</span>} />

            {/* ---- Third parties (gate: show_casino || show_casinolive || show_virtual) ----
                Header text "Third parties" / "Last 7 days" is hardcoded English
                in the real Blade (view L624-626) — kept verbatim. */}
            <HdStatPanel icon="grid" title="Third parties" sub="Last 7 days" range={range}
              data={thirdParties} loading={ledgerFeed.loading}
              emptyText="No casino / live / virtual activity recorded in this window."
              note={<span>Appears when any of <code>show_casino</code> / <code>show_casinolive</code> / <code>show_virtual</code> is on. Same four KPIs + daily chart, aggregated over categories CASINO(1) + CASINO_LIVE(2) + VIRTUAL(4) (config/cats.php). Header text is hardcoded English in the real view.</span>} />
          </>
        )}
      </HrsShell>
    </div>
  );
};

window.HostDashboard = HostDashboard;
