From 6ea0a6feec5c2ccb9bb42b4762eb894f40ea6ae4 Mon Sep 17 00:00:00 2001 From: kang Date: Fri, 12 Jun 2026 20:47:53 +0800 Subject: [PATCH] feat(agents, config, dashboard, i18n): add agent line provision wizard, site deletion, and site dashboard with multi-language support Added agent line provision wizard page with permission gating, replacing redirect placeholder. Introduced site deletion API and UI with confirmation dialog in integration sites management. Added new site-scoped dashboard panel showing bet metrics, P/L trends, active players, and quick links. Enhanced chart tooltip to support custom formatters and fix indicator color --- AGENTS.md | 5 + src/api/admin-integration-sites.ts | 4 + .../admin/(shell)/agents/provision/page.tsx | 19 +- .../admin/admin-no-integration-site-state.tsx | 26 + src/components/ui/chart.tsx | 98 ++-- src/i18n/index.ts | 3 +- src/i18n/locales/en/agents.json | 3 +- src/i18n/locales/en/common.json | 4 + src/i18n/locales/en/config.json | 7 + src/i18n/locales/en/dashboard.json | 30 +- src/i18n/locales/en/settlementCenter.json | 89 +++- src/i18n/locales/ne/common.json | 2 + src/i18n/locales/ne/dashboard.json | 26 + src/i18n/locales/ne/settlementCenter.json | 402 +++++++++++++++ src/i18n/locales/zh/agents.json | 5 +- src/i18n/locales/zh/common.json | 4 + src/i18n/locales/zh/config.json | 7 + src/i18n/locales/zh/dashboard.json | 30 +- src/i18n/locales/zh/settlementCenter.json | 74 ++- src/lib/admin-session-variants.ts | 11 + src/lib/admin-signed-money.tsx | 53 ++ src/lib/platform-system-roles.ts | 7 +- .../agents/agent-line-provision-wizard.tsx | 39 +- src/modules/agents/agents-console.tsx | 101 +++- .../agents/agents-directory-console.tsx | 3 +- src/modules/agents/agents-subnav.tsx | 36 +- .../dashboard/agent-dashboard-console.tsx | 459 +++++------------- .../dashboard/dashboard-analytics-panel.tsx | 11 +- src/modules/dashboard/dashboard-console.tsx | 104 +--- .../dashboard/dashboard-page-client.tsx | 12 +- .../dashboard/dashboard-trend-charts.tsx | 11 +- src/modules/dashboard/dashboard-visuals.tsx | 10 +- .../dashboard/site-dashboard-console.tsx | 255 ++++++++++ src/modules/draws/draw-detail-console.tsx | 11 +- src/modules/draws/draw-finance-console.tsx | 8 +- src/modules/draws/draws-index-console.tsx | 3 +- .../integration/integration-sites-console.tsx | 67 ++- src/modules/reports/reports-console.tsx | 25 +- .../agent-settlement-report-view.tsx | 46 +- .../settlement-batch-details-console.tsx | 8 +- .../settlement/settlement-batches-console.tsx | 3 +- .../settlement/settlement-bills-table.tsx | 27 +- .../settlement/settlement-center-shell.tsx | 5 +- .../settlement/settlement-signed-money.ts | 13 +- src/types/api/admin-agent-line.ts | 2 +- src/types/api/admin-auth.ts | 2 + src/types/api/admin-dashboard.ts | 31 ++ src/types/api/admin-integration-site.ts | 1 + 48 files changed, 1573 insertions(+), 629 deletions(-) create mode 100644 src/components/admin/admin-no-integration-site-state.tsx create mode 100644 src/i18n/locales/ne/settlementCenter.json create mode 100644 src/lib/admin-session-variants.ts create mode 100644 src/lib/admin-signed-money.tsx create mode 100644 src/modules/dashboard/site-dashboard-console.tsx diff --git a/AGENTS.md b/AGENTS.md index 0af1776..8f1f596 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,3 +28,8 @@ This version has breaking changes — APIs, conventions, and file structure may - 玩家详情:按 `funding_mode` 切换 Tab(信用流水 / 钱包流水;信用盘隐藏转账单) 新增涉及玩家资金的页面时,先读 `src/lib/admin-player-display.ts`。 + +## Learned Workspace Facts + +- 无接入站时依赖站点的页面展示 ``;仅 `profile.is_super_admin` 显示创建入口。 +- 超管判定用登录态 `is_super_admin`,勿用站点角色或 `admin_user_site_roles` 绑定推断。 diff --git a/src/api/admin-integration-sites.ts b/src/api/admin-integration-sites.ts index 59239da..a24ff97 100644 --- a/src/api/admin-integration-sites.ts +++ b/src/api/admin-integration-sites.ts @@ -35,6 +35,10 @@ export async function putAdminIntegrationSite( return adminRequest.put(`${A}/integration-sites/${id}`, body); } +export async function deleteAdminIntegrationSite(id: number): Promise { + return adminRequest.delete(`${A}/integration-sites/${id}`); +} + export async function postAdminIntegrationSiteRotateSecrets( id: number, ): Promise { diff --git a/src/app/admin/(shell)/agents/provision/page.tsx b/src/app/admin/(shell)/agents/provision/page.tsx index bac8f04..88c9fa6 100644 --- a/src/app/admin/(shell)/agents/provision/page.tsx +++ b/src/app/admin/(shell)/agents/provision/page.tsx @@ -1,5 +1,18 @@ -import { redirect } from "next/navigation"; +import { ModuleScaffold } from "@/components/admin/module-scaffold"; +import { AdminPermissionGate } from "@/components/admin/admin-permission-gate"; +import { AgentLineProvisionWizard } from "@/modules/agents/agent-line-provision-wizard"; +import { PRD_AGENT_LINE_PROVISION_ACCESS_ANY } from "@/lib/admin-prd"; +import { buildPageMetadata } from "@/lib/page-metadata"; +import type { Metadata } from "next"; -export default function AgentProvisionRedirectPage(): never { - redirect("/admin/agents"); +export const metadata: Metadata = buildPageMetadata("agents", "subnav.provision"); + +export default function AgentProvisionPage() { + return ( + + + + + + ); } diff --git a/src/components/admin/admin-no-integration-site-state.tsx b/src/components/admin/admin-no-integration-site-state.tsx new file mode 100644 index 0000000..073d860 --- /dev/null +++ b/src/components/admin/admin-no-integration-site-state.tsx @@ -0,0 +1,26 @@ +"use client"; + +import Link from "next/link"; +import type { ReactElement } from "react"; +import { useTranslation } from "react-i18next"; + +import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state"; +import { Button } from "@/components/ui/button"; + +export function AdminNoIntegrationSiteState({ + canCreate = false, +}: { + canCreate?: boolean; +}): ReactElement { + const { t } = useTranslation("common"); + + return ( + + {canCreate ? ( + + ) : null} + + ); +} diff --git a/src/components/ui/chart.tsx b/src/components/ui/chart.tsx index 7c2dc84..6cc682e 100644 --- a/src/components/ui/chart.tsx +++ b/src/components/ui/chart.tsx @@ -202,7 +202,15 @@ function ChartTooltipContent({ .map((item, index) => { const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}` const itemConfig = getPayloadConfigFromPayload(config, item, key) - const indicatorColor = color ?? item.payload?.fill ?? item.color + const configColor = + itemConfig && "color" in itemConfig ? itemConfig.color : undefined + const indicatorColor = + color ?? item.color ?? item.payload?.fill ?? configColor + + const formattedValue = + formatter && item?.value !== undefined && item.name + ? formatter(item.value, item.name, item, index, item.payload) + : null return (
- {formatter && item?.value !== undefined && item.name ? ( - formatter(item.value, item.name, item, index, item.payload) + {itemConfig?.icon ? ( + ) : ( - <> - {itemConfig?.icon ? ( - - ) : ( - !hideIndicator && ( -
- ) - )} + !hideIndicator && (
-
- {nestLabel ? tooltipLabel : null} - - {itemConfig?.label ?? item.name} - -
- {item.value != null && ( - - {typeof item.value === "number" - ? item.value.toLocaleString() - : String(item.value)} - - )} -
- + className={cn("shrink-0 rounded-[2px]", { + "h-2.5 w-2.5": indicator === "dot", + "w-1": indicator === "line", + "w-0 border-[1.5px] border-dashed bg-transparent": + indicator === "dashed", + "my-0.5": nestLabel && indicator === "dashed", + })} + style={ + indicator === "dashed" + ? { borderColor: indicatorColor } + : { + backgroundColor: indicatorColor, + borderColor: indicatorColor, + } + } + /> + ) )} +
+
+ {nestLabel ? tooltipLabel : null} + + {itemConfig?.label ?? item.name} + +
+ {item.value != null && ( + + {formattedValue ?? + (typeof item.value === "number" + ? item.value.toLocaleString() + : String(item.value))} + + )} +
) })} diff --git a/src/i18n/index.ts b/src/i18n/index.ts index db3bde8..9478d8b 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -42,6 +42,7 @@ import neReconcile from "@/i18n/locales/ne/reconcile.json"; import neReports from "@/i18n/locales/ne/reports.json"; import neWallet from "@/i18n/locales/ne/wallet.json"; import neAgents from "@/i18n/locales/ne/agents.json"; +import neSettlementCenter from "@/i18n/locales/ne/settlementCenter.json"; import zhAudit from "@/i18n/locales/zh/audit.json"; import zhAdminUsers from "@/i18n/locales/zh/adminUsers.json"; import zhAuth from "@/i18n/locales/zh/auth.json"; @@ -103,7 +104,7 @@ const resources = { settlement: neSettlement, wallet: neWallet, agents: neAgents, - settlementCenter: enSettlementCenter, + settlementCenter: neSettlementCenter, }, zh: { common: zhCommon, diff --git a/src/i18n/locales/en/agents.json b/src/i18n/locales/en/agents.json index efeaf6c..ef0e84d 100644 --- a/src/i18n/locales/en/agents.json +++ b/src/i18n/locales/en/agents.json @@ -176,8 +176,7 @@ }, "lineProvision": { "title": "Create level-1 agent", - "description": "Creates the level-1 agent, admin login, and line settings (share, credit, rebate, settlement cycle) in one step. Code cannot be changed later.", - "code": "Agent code", + "description": "Creates the level-1 agent, admin login, and line settings (share, credit, rebate, settlement cycle) in one step.", "name": "Level-1 agent name", "username": "Admin login", "password": "Initial password", diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index a5bcc8e..3de7015 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -111,6 +111,10 @@ "loading": "Loading…", "comingSoon": "Feature under development" }, + "integrationSites": { + "emptyPlatformHint": "No integration sites yet. Create a site before managing agents, players, or settlement.", + "createSite": "Create integration site" + }, "errors": { "loadFailed": "Failed to load" }, diff --git a/src/i18n/locales/en/config.json b/src/i18n/locales/en/config.json index db4d0c3..7fb4b66 100644 --- a/src/i18n/locales/en/config.json +++ b/src/i18n/locales/en/config.json @@ -72,6 +72,13 @@ "rotateConfirmTitle": "Rotate secrets?", "rotateConfirmDescription": "New SSO and wallet keys will be generated for {{code}}. Old keys stop working immediately.", "rotateConfirm": "Rotate", + "delete": "Delete site", + "deleteSuccess": "Deleted site {{code}}", + "deleteFailed": "Failed to delete site", + "deleteConfirmTitle": "Delete this site?", + "deleteConfirmDescription": "This permanently removes site {{code}} ({{name}}), its agent line, settlement periods, players, and site admin accounts. This cannot be undone.", + "deleteConfirm": "Delete", + "deleting": "Deleting…", "secretsTitle": "Save these secrets now", "secretsDescription": "Secrets for {{code}} are shown only once.", "secretsDismiss": "I have saved them", diff --git a/src/i18n/locales/en/dashboard.json b/src/i18n/locales/en/dashboard.json index 16d9e5f..7fbecfd 100644 --- a/src/i18n/locales/en/dashboard.json +++ b/src/i18n/locales/en/dashboard.json @@ -156,9 +156,35 @@ "riskMonitor": "Risk monitor", "systemSettings": "System settings" }, + "site": { + "title": "Site overview", + "subtitle": "{{name}} · this site", + "todayBet": "Today's bets", + "todayProfit": "Today's P/L", + "sevenDayTitle": "Last 7 days", + "sevenDayProfit": "7-day P/L", + "profitScopeHint": "Site scope: bets minus payouts", + "activePlayersToday": "Active players today", + "betOrdersTodayHint": "{{count}} orders today", + "pendingBills": "Pending bills", + "pendingUnpaid": "Unpaid {{amount}}", + "latestBetAt": "Latest bet {{time}}", + "noBetToday": "No bets yet today", + "scaleTitle": "Site scale", + "agentCount": "Agent nodes", + "playerCount": "Players", + "topAgentToday": "Top agent today: {{name}} ({{amount}})", + "quickLinks": { + "tickets": "Tickets", + "players": "Players", + "reports": "Reports", + "agents": "Agents", + "bills": "Settlement" + } + }, "agent": { "title": "Operations overview", - "subtitle": "Your line scope · {{name}}", + "subtitle": "{{name}} · your line", "heroEyebrow": "Today's line cockpit", "heroTitle": "{{name}} live operations", "creditTitle": "Credit limit", @@ -176,6 +202,7 @@ "teamPlayers": "Players in line", "activePlayersToday": "Active players today", "betOrdersToday": "Bet orders today", + "betOrdersTodayHint": "{{count}} orders today", "todayBet": "Today's bet", "todayPayout": "Today's payout", "todayProfit": "Today's profit", @@ -202,6 +229,7 @@ "yes": "Yes", "no": "No", "viewBills": "View bills", + "lineMeta": "Depth {{depth}} · child agents {{childAgent}} · players {{player}}", "viewLine": "Agent line", "quickLinks": { "tickets": "Tickets", diff --git a/src/i18n/locales/en/settlementCenter.json b/src/i18n/locales/en/settlementCenter.json index 21ced1a..156309e 100644 --- a/src/i18n/locales/en/settlementCenter.json +++ b/src/i18n/locales/en/settlementCenter.json @@ -15,7 +15,8 @@ "closeDialogShare": "{{count}} ledger entries", "closeDialogUnsettled": "{{count}} tickets still unsettled", "closeDialogIrreversible": "Cannot undo. Use adjustments or reversals to fix errors.", - "closeDialogConfirm": "Close period" + "closeDialogConfirm": "Close period", + "closeDialogEmpty": "No share ledger this period; closing will not generate bills." }, "periodDetail": { "back": "Back to periods", @@ -152,7 +153,8 @@ "adjustment": "Adjustment", "reversal": "Reversal", "bad_debt": "Bad debt", - "share_ledger": "Share ledger" + "share_ledger": "Share ledger", + "freezeAmount": "Hold {{amount}}" } }, "columns": { @@ -176,7 +178,13 @@ "adjustmentType": "Type", "originalBill": "Original bill", "reason": "Reason", - "badDebtAmount": "Write-off" + "badDebtAmount": "Write-off", + "playerAccount": "Player account", + "playerId": "Player ID", + "directAgent": "Direct agent", + "superiorAgent": "Upline agent", + "play": "Play", + "drawNo": "Draw no." }, "billStatus": { "pending_confirm": "Pending confirm", @@ -302,21 +310,72 @@ "agent": "Agent bills", "pendingConfirm": "Pending confirm", "awaitingPayment": "Awaiting payment" + }, + "billId": "Bill ID", + "ownerKeyword": "Owner / counterparty", + "ownerKeywordPh": "Player account or agent name", + "status": "Bill status", + "billType": "Bill type", + "filterAll": "All statuses", + "filterAllTypes": "All types", + "filterAdjustment": "Adjust / reverse", + "optional": "Optional", + "searchBtn": "Search", + "reset": "Reset", + "refresh": "Refresh", + "clientFilterHint": "Owner filter: showing {{shown}} / {{total}}", + "emptyFiltered": "No bills match the filter. Try All statuses or reset.", + "emptyClosed": "Period closed but no bills. Often no settled credit tickets in range.", + "rowHint": { + "playerOwner": "Player owner", + "agentOwner": "Agent owner", + "adjustmentOwner": "Adjustment owner", + "badDebtOwner": "Bad debt owner", + "reversalOwner": "Reversal owner", + "playerUpline": "Player's upline agent", + "agentUpline": "Settlement upline" } }, "panels": { - "overview": { "title": "Overview" }, - "ledger": { "title": "Account ledger" }, - "bills": { "title": "Bills" }, - "creditLedger": { "title": "Credit ledger" }, - "playerBills": { "title": "Player bills" }, - "agentBills": { "title": "Agent bills" }, - "pendingConfirm": { "title": "Pending confirm" }, - "awaiting": { "title": "Awaiting payment" }, - "payments": { "title": "Payment log" }, - "adjustments": { "title": "Adjust / reverse" }, - "reports": { "title": "Period reports" }, - "badDebt": { "title": "Bad debt" } + "overview": { + "title": "Overview" + }, + "ledger": { + "title": "Account ledger" + }, + "bills": { + "title": "Bills" + }, + "creditLedger": { + "title": "Credit ledger" + }, + "playerBills": { + "title": "Player bills" + }, + "agentBills": { + "title": "Agent bills" + }, + "pendingConfirm": { + "title": "Pending confirm" + }, + "awaiting": { + "title": "Awaiting payment" + }, + "payments": { + "title": "Payment log" + }, + "adjustments": { + "title": "Adjust / reverse" + }, + "reports": { + "title": "Period reports" + }, + "badDebt": { + "title": "Bad debt" + }, + "workbench": { + "title": "Workbench" + } }, "empty": { "noSite": "Select a site.", diff --git a/src/i18n/locales/ne/common.json b/src/i18n/locales/ne/common.json index 87faad9..b22e199 100644 --- a/src/i18n/locales/ne/common.json +++ b/src/i18n/locales/ne/common.json @@ -163,6 +163,8 @@ "account": "खाता सेटिङ", "integration": "मुख्य साइट एकीकरण", "agents": "एजेन्ट लाइन", + "agent_list": "एजेन्ट सूची", + "settlement_center": "सेटलमेन्ट केन्द्र", "config": "सञ्चालन कन्फिगरेसन" }, "sidebar": { diff --git a/src/i18n/locales/ne/dashboard.json b/src/i18n/locales/ne/dashboard.json index 65326ef..8a5bff4 100644 --- a/src/i18n/locales/ne/dashboard.json +++ b/src/i18n/locales/ne/dashboard.json @@ -153,6 +153,32 @@ "riskMonitor": "जोखिम निगरानी", "systemSettings": "प्रणाली सेटिङ" }, + "site": { + "title": "साइट सारांश", + "subtitle": "{{name}} · यो साइट", + "todayBet": "आजको बाजी", + "todayProfit": "आजको नाफा/नोक्सान", + "sevenDayTitle": "पछिल्लो ७ दिन", + "sevenDayProfit": "७-दिने नाफा/नोक्सान", + "profitScopeHint": "साइट दायरा: बाजी माइनस भुक्तानी", + "activePlayersToday": "आज सक्रिय खेलाडी", + "betOrdersTodayHint": "आज {{count}} अर्डर", + "pendingBills": "बाँकी बिल", + "pendingUnpaid": "नतिरेको {{amount}}", + "latestBetAt": "पछिल्लो बाजी {{time}}", + "noBetToday": "आज अहिलेसम्म बाजी छैन", + "scaleTitle": "साइट परिमाण", + "agentCount": "एजेन्ट नोड", + "playerCount": "खेलाडी संख्या", + "topAgentToday": "आजको शीर्ष एजेन्ट: {{name}} ({{amount}})", + "quickLinks": { + "tickets": "टिकट", + "players": "खेलाडी", + "reports": "रिपोर्ट", + "agents": "एजेन्ट", + "bills": "सेटलमेन्ट" + } + }, "warnings": { "drawPermission": "यो खातासँग ड्रअ/ड्यासबोर्ड हेर्ने अनुमति छैन। वित्तीय र जोखिम डाटा फिर्ता आएन।", "walletPermission": "यो खातासँग वालेट मिलान हेर्ने अनुमति छैन। असामान्य ट्रान्सफर संख्या फिर्ता आएन।", diff --git a/src/i18n/locales/ne/settlementCenter.json b/src/i18n/locales/ne/settlementCenter.json new file mode 100644 index 0000000..e62329d --- /dev/null +++ b/src/i18n/locales/ne/settlementCenter.json @@ -0,0 +1,402 @@ +{ + "title": "सेटलमेन्ट केन्द्र", + "subtitle": "अवधि बन्द, बिल पुष्टि र भुक्तानी", + "subtitleList": "अवधि सूची: खोल्नुहोस्/बन्द गर्नुहोस्; सारांश स्तम्भहरू समावेश — बिल र बेट लेजरका लागि पङ्क्ति कार्य प्रयोग गर्नुहोस्।", + "period": { + "title": "Period", + "statusCompleted": "Completed", + "pipelineShare": "{{count}} ledger entries", + "billTodo": "Pending {{p}} · Awaiting {{a}}", + "openTitle": "Open period", + "openBtn": "Open period", + "closeNeedLedger": "No ledger activity yet. Complete draw settlement first.", + "closeDialogTitle": "Close period", + "closeDialogDesc": "Summarize {{range}} and generate bills.", + "closeDialogShare": "{{count}} ledger entries", + "closeDialogUnsettled": "{{count}} tickets still unsettled", + "closeDialogIrreversible": "Cannot undo. Use adjustments or reversals to fix errors.", + "closeDialogConfirm": "Close period", + "closeDialogEmpty": "No share ledger this period; closing will not generate bills." + }, + "periodDetail": { + "back": "Back to periods", + "notFound": "Period not found or site changed. Go back to the list." + }, + "periodTable": { + "title": "Periods", + "statusFilter": "Status", + "range": "Period", + "ledgerCount": "Ledger", + "pending": "Pending", + "awaiting": "Awaiting", + "shareLedger": "Share ledger", + "gameWinLoss": "Win/loss total", + "platformWinLoss": "Platform win/loss", + "agentWinLoss": "Agent win/loss", + "basicRebate": "Rebate total", + "unsettledTickets": "Unsettled tickets", + "openReportHint": "Open period: share/win-loss from in-period ledger; bill count updates after close.", + "viewDetail": "View details", + "close": "Close", + "closeNow": "Close now", + "hasOpen": "Period {{range}} is open. Close it before opening a new one.", + "emptyOpenHint": "No periods yet. Click Open period in the toolbar.", + "emptyReadOnly": "No period records.", + "emptyFiltered": "No rows match the filter. Reset filters.", + "emptyFilteredOpen": "The open period is hidden by the filter. Choose All or Open.", + "readOnlyHint": "Bound agent accounts cannot open or close site periods." + }, + "header": { + "subtitle": "क्रेडिट-लाइन सेटलमेन्ट", + "statusRunning": "अवधि खुला", + "statusIdle": "कुनै खुला अवधि छैन", + "statusCompleted": "अवधि पूरा भयो" + }, + "subnav": { + "label": "सेटलमेन्ट केन्द्र नेभिगेसन" + }, + "workbench": { + "viewPeriod": "Period", + "closePreset": "Close · {{label}}", + "closeNoData": "Close failed: no share ledger in period. Run credit game settlement first.", + "openPeriodPipeline": "Open {{range}} · {{share}} share entries" + }, + "nav": { + "periods": "अवधिहरू", + "bills": "बिलहरू", + "operations": "भुक्तानी र समायोजन", + "ledger": "लेजर", + "creditLedger": "क्रेडिट लेजर", + "playerBills": "Player bills", + "agentBills": "Agent bills", + "pendingConfirm": "Pending confirm", + "awaitingPayment": "Awaiting payment", + "payments": "Payment log", + "adjustments": "Adjust / reverse", + "badDebt": "Bad debt", + "reports": "Period reports" + }, + "operations": { + "hint": "Payment registration, bad-debt write-offs, and adjustments. Player credit movements are under Account ledger.", + "adjustmentsTitle": "Adjustments / reversals / bad debt", + "loadFailed": "Failed to load payment and adjustment records", + "operationType": "Operation type", + "filterAllTypes": "All types", + "typePayment": "Payment", + "keyword": "Keyword", + "keywordPh": "Method, reason, proof, payer/payee" + }, + "filters": { + "period": "Period", + "statusAll": "All", + "allPeriods": "All periods", + "statusOpen": "Open", + "statusClosed": "Closed", + "statusCompleted": "Completed" + }, + "overview": { + "pendingConfirm": "Pending confirm", + "awaitingPayment": "Awaiting payment", + "totalUnpaid": "Total unpaid", + "openPeriod": "Open period", + "creditLedger": "Credit ledger (in period)", + "shareLedger": "Share ledger (in period)", + "pipelineHint": "Bills are created after period close; counts below are in-period activity." + }, + "ledger": { + "groupIntro": "In-period money movements: credit holds, bill payments, adjustments, and bad debt. Share bills after close are under Bills.", + "paymentsIntro": "Confirmed bill payments (payment_records). Register from bill detail; this page is the period-wide log.", + "adjustmentsIntro": "Bill adjustments and reversals (settlement_adjustments).", + "badDebtIntro": "Bad debt write-off entries linked to original bills." + }, + "creditLedger": { + "periodIntro": "Credit-line bets in this period: bet hold and draw settlement (merged per ticket).", + "emptyPeriod": "No bet ledger entries in this period. Ensure credit players placed bets and draws were settled.", + "intro": "Ledger entries for the selected period.", + "columns": { + "txn": "Txn ID", + "player": "Player", + "reason": "Type", + "ref": "Reference", + "amount": "Amount", + "channel": "Channel", + "status": "Status", + "time": "Time" + }, + "channelCredit": "Credit line", + "viewPlayer": "Player detail", + "entryKind": { + "adjustment": "Adjustment" + }, + "status": { + "posted": "Posted" + }, + "actions": { + "viewPlayer": "Player detail", + "viewBill": "Bill detail", + "confirm": "Confirm bill", + "confirmDesc": "After confirm, the bill moves to awaiting payment.", + "payment": "Record payment", + "adjustment": "Adjust", + "reversal": "Reverse", + "badDebt": "Bad debt" + }, + "reason": { + "payment_record": "Bill payment", + "bet_hold": "Bet hold", + "game_settlement": "Draw settlement", + "game_settlement_win": "Draw settlement credit", + "bet_hold_release": "Hold release", + "game_settlement_loss": "Draw settlement debit", + "settlement_payout": "Settlement payout", + "settlement_confirm": "Period confirm", + "adjustment": "Adjustment", + "reversal": "Reversal", + "bad_debt": "Bad debt", + "share_ledger": "Share ledger", + "freezeAmount": "Hold {{amount}}" + } + }, + "columns": { + "period": "Period", + "type": "Type", + "owner": "Owner", + "counterparty": "Counterparty", + "gross": "Win/loss", + "net": "Net", + "paid": "Paid", + "unpaid": "Unpaid", + "status": "Status", + "billId": "Bill ID", + "payer": "Payer", + "payee": "Payee", + "amount": "Amount", + "method": "Method", + "time": "Time", + "summary": "Summary", + "detail": "Detail", + "adjustmentType": "Type", + "originalBill": "Original bill", + "reason": "Reason", + "badDebtAmount": "Write-off", + "playerAccount": "Player account", + "playerId": "Player ID", + "directAgent": "Direct agent", + "superiorAgent": "Upline agent", + "play": "Play", + "drawNo": "Draw no." + }, + "billStatus": { + "pending_confirm": "Pending confirm", + "confirmed": "Confirmed", + "partial_paid": "Partial paid", + "settled": "Settled", + "overdue": "Overdue", + "reversed": "Reversed" + }, + "billType": { + "adjustment": "Adjustment", + "reversal": "Reversal", + "badDebt": "Bad debt write-off" + }, + "adjustmentType": { + "adjustment": "Adjustment", + "reversal": "Reversal", + "bad_debt": "Bad debt" + }, + "paymentStatus": { + "pending": "Pending", + "confirmed": "Confirmed" + }, + "actions": { + "detail": "Detail", + "viewBill": "View bill", + "billDetail": "Bill detail" + }, + "billDisplay": { + "settlementFlow": "Who pays whom", + "settlementAmount": "Settlement amount", + "pays": "Pays", + "paysShort": "Pays", + "howAmountWorks": "Breakdown", + "payerLabel": "Payer", + "payeeLabel": "Payee", + "playerBreakdownIntro": "Players settle only with their direct agent: net = win/loss − rebate.", + "agentBreakdownIntro": "Agents settle only with their upline: net = team net − downline share − own share.", + "playerGross": "Game win/loss", + "playerLostHint": "Player lost; owes agent", + "playerWonHint": "Player won; agent owes player", + "playerNet": "Player net payable", + "playerNetReceive": "Agent pays player", + "teamGross": "Team game win/loss", + "teamGrossHint": "Includes this agent and all downline players", + "teamGrossShort": "Team", + "playerGrossShort": "Player", + "teamRebate": "Team rebate", + "teamNet": "Team net", + "rebate": "Rebate", + "agentShareKeep": "Share kept at this tier", + "agentShareKeepHint": "Profit retained at this tier by share ratio", + "agentDownlineShare": "Downline share", + "agentDownlineShareHint": "Profit retained by downline agents (see breakdown below)", + "agentDownlineShareItem": "{{agent}} kept", + "agentNet": "Pay {{counterparty}}", + "agentNetReceive": "{{counterparty}} pays this tier", + "billOwner": "Bill owner", + "billCounterparty": "Counterparty", + "unpaidPendingConfirm": "Confirm the bill before recording payment", + "unpaidAwaitingPayment": "Record offline payment", + "fullySettled": "Fully settled this period", + "confirmHint": "Confirm the bill before recording payment.", + "recordReceiptFrom": "Record receipt ({{payer}} → {{payee}})", + "recordPayoutTo": "Record payout ({{payer}} → {{payee}})", + "rebateAllocationsHint": "How rebate is allocated across agent tiers.", + "payment": "Payment", + "flowHint": { + "playerPayAgent": "Player should settle with the direct agent", + "agentPayPlayer": "Direct agent should settle with the player", + "agentPayUpstream": "This agent should settle with upline / platform", + "upstreamPayAgent": "Upline / platform should settle with this agent", + "adjustment": "Adjustment settles separately and keeps the original bill relation", + "badDebt": "Write off unpaid amount and archive it as bad debt", + "reversal": "Reverse the original bill impact according to reversal rules", + "generic": "Apply payment or adjustment based on this bill relation" + }, + "hierarchyHint": "One period creates multiple bills: players pay their agent first; each agent keeps share profit and remits the rest upline. Gross win/loss may match across rows while settlement amounts step down." + }, + "ledgerPanel": { + "search": "Search", + "searchBtn": "Search", + "reset": "Reset filters", + "refresh": "Refresh page", + "filterAll": "Any", + "playerAccount": "Player account", + "playerAccountPh": "Username or site player ID", + "playerId": "Player ID", + "optional": "Optional", + "billStatus": "Bill status", + "dateRange": "Date range", + "rowPosted": "Posted" + }, + "billsPanel": { + "intro": "Share bills after period close. Filter by type or status; open detail to confirm or record payment.", + "hierarchyHint": "One period creates multiple bills: players pay their agent first; each agent keeps share profit and remits the rest upline. Gross win/loss may match across rows while settlement amounts step down.", + "quickFilter": { + "title": "Which settlement layer do you want to review", + "desc": "Choose player or agent settlement first, then narrow by status or bill id.", + "allTitle": "All bills", + "allHint": "View player, agent, adjustment, and bad debt bills together", + "playerTitle": "Player settlement", + "playerHint": "Credit settlement between players and their direct agent", + "agentTitle": "Agent settlement", + "agentHint": "Tier settlement between agents and their upline / platform" + }, + "activeHint": { + "all": "Current focus: all settlement documents in this period", + "player": "Current focus: credit settlement between players and direct agents", + "agent": "Current focus: payments between agents and their upline / platform" + }, + "layer": { + "player": "Player vs direct agent", + "agent": "Agent vs upline / platform", + "adjustment": "Settlement adjustment", + "badDebt": "Bad debt archive", + "reversal": "Historical bill reversal", + "generic": "Settlement supporting document" + }, + "category": { + "all": "All", + "player": "Player bills", + "agent": "Agent bills", + "pendingConfirm": "Pending confirm", + "awaitingPayment": "Awaiting payment" + }, + "billId": "Bill ID", + "ownerKeyword": "Owner / counterparty", + "ownerKeywordPh": "Player account or agent name", + "status": "Bill status", + "billType": "Bill type", + "filterAll": "All statuses", + "filterAllTypes": "All types", + "filterAdjustment": "Adjust / reverse", + "optional": "Optional", + "searchBtn": "Search", + "reset": "Reset", + "refresh": "Refresh", + "clientFilterHint": "Owner filter: showing {{shown}} / {{total}}", + "emptyFiltered": "No bills match the filter. Try All statuses or reset.", + "emptyClosed": "Period closed but no bills. Often no settled credit tickets in range.", + "rowHint": { + "playerOwner": "Player owner", + "agentOwner": "Agent owner", + "adjustmentOwner": "Adjustment owner", + "badDebtOwner": "Bad debt owner", + "reversalOwner": "Reversal owner", + "playerUpline": "Player's upline agent", + "agentUpline": "Settlement upline" + } + }, + "panels": { + "overview": { + "title": "Overview" + }, + "ledger": { + "title": "Account ledger" + }, + "bills": { + "title": "Bills" + }, + "creditLedger": { + "title": "Credit ledger" + }, + "playerBills": { + "title": "Player bills" + }, + "agentBills": { + "title": "Agent bills" + }, + "pendingConfirm": { + "title": "Pending confirm" + }, + "awaiting": { + "title": "Awaiting payment" + }, + "payments": { + "title": "Payment log" + }, + "adjustments": { + "title": "Adjust / reverse" + }, + "reports": { + "title": "Period reports" + }, + "badDebt": { + "title": "Bad debt" + }, + "workbench": { + "title": "Workbench" + } + }, + "empty": { + "noSite": "Select a site.", + "noPeriods": "Close the current period first.", + "noClosed": "Close a period to generate bills.", + "noBadDebt": "No bad debt records.", + "noCreditLedger": "No ledger entries in this period.", + "billsNeedClose": "Bills are created after period close." + }, + "periods": { + "loadFailed": "Failed to load periods" + }, + "toast": { + "periodClosed": "Period closed", + "periodClosedUnsettled": "Period closed; {{count}} ticket(s) still unsettled." + }, + "errors": { + "loadBills": "Failed to load bills", + "loadPayments": "Failed to load payments", + "loadAdjustments": "Failed to load adjustments", + "loadBadDebt": "Failed to load bad debt records", + "loadCreditLedger": "Failed to load credit ledger" + } +} diff --git a/src/i18n/locales/zh/agents.json b/src/i18n/locales/zh/agents.json index 7438f49..448c5c3 100644 --- a/src/i18n/locales/zh/agents.json +++ b/src/i18n/locales/zh/agents.json @@ -301,15 +301,12 @@ }, "lineProvision": { "title": "创建一级代理", - "description": "将一级代理绑定到已有接入站点,并配置后台登录账号与占成、授信、回水、结算周期。代理编码创建后不可修改。", + "description": "将一级代理绑定到已有接入站点,并配置后台登录账号与占成、授信、回水、结算周期。", "siteCode": "接入站点", "siteCodePlaceholder": "选择站点", "siteRequired": "请选择接入站点", - "codeRequired": "请填写代理编码", - "codePatternInvalid": "代理编码仅支持字母、数字、下划线和中划线,且需以字母或数字开头", "noUnboundSite": "暂无未绑定一级代理的站点", "openIntegrationSites": "前往接入站点", - "code": "代理编码", "name": "一级代理名称", "username": "后台登录账号", "password": "初始密码", diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index ffef1bf..23ba5b2 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -111,6 +111,10 @@ "loading": "加载中…", "comingSoon": "功能开发中" }, + "integrationSites": { + "emptyPlatformHint": "暂无接入站点。请先创建站点,再进行代理、玩家、结算等业务操作。", + "createSite": "去创建接入站点" + }, "errors": { "loadFailed": "加载失败" }, diff --git a/src/i18n/locales/zh/config.json b/src/i18n/locales/zh/config.json index ce43224..7a71671 100644 --- a/src/i18n/locales/zh/config.json +++ b/src/i18n/locales/zh/config.json @@ -72,6 +72,13 @@ "rotateConfirmTitle": "确认重置密钥?", "rotateConfirmDescription": "将重新生成站点 {{code}} 的 SSO 与钱包密钥,旧密钥立即失效。", "rotateConfirm": "确认重置", + "delete": "删除站点", + "deleteSuccess": "已删除站点 {{code}}", + "deleteFailed": "删除站点失败", + "deleteConfirmTitle": "确认删除站点?", + "deleteConfirmDescription": "将永久删除站点 {{code}}({{name}})及其代理链、账期、玩家与站点后台账号,此操作不可恢复。", + "deleteConfirm": "确认删除", + "deleting": "删除中…", "secretsTitle": "请妥善保存密钥", "secretsDescription": "站点 {{code}} 的密钥仅显示一次,关闭后无法再次查看完整内容。", "secretsDismiss": "我已保存", diff --git a/src/i18n/locales/zh/dashboard.json b/src/i18n/locales/zh/dashboard.json index d80b31a..0ca9ed2 100644 --- a/src/i18n/locales/zh/dashboard.json +++ b/src/i18n/locales/zh/dashboard.json @@ -156,9 +156,35 @@ "riskMonitor": "风控监控", "systemSettings": "系统设置" }, + "site": { + "title": "站点概览", + "subtitle": "{{name}} · 本站点", + "todayBet": "今日下注", + "todayProfit": "今日盈亏", + "sevenDayTitle": "近 7 天走势", + "sevenDayProfit": "近 7 天盈亏", + "profitScopeHint": "站点口径:投注减派彩(不含占成拆分)", + "activePlayersToday": "今日活跃玩家", + "betOrdersTodayHint": "今日 {{count}} 单", + "pendingBills": "待结账单", + "pendingUnpaid": "未结合计 {{amount}}", + "latestBetAt": "最近下注 {{time}}", + "noBetToday": "今日暂时没有下注", + "scaleTitle": "站点规模", + "agentCount": "代理节点", + "playerCount": "玩家总数", + "topAgentToday": "今日投注最高代理:{{name}}({{amount}})", + "quickLinks": { + "tickets": "注单查询", + "players": "玩家管理", + "reports": "报表统计", + "agents": "代理组织", + "bills": "结算中心" + } + }, "agent": { "title": "经营概览", - "subtitle": "本线路数据范围 · {{name}}", + "subtitle": "{{name}} · 本线路", "heroEyebrow": "今日经营驾驶舱", "heroTitle": "{{name}} 的线路动态", "creditTitle": "授信额度", @@ -176,6 +202,7 @@ "teamPlayers": "线路玩家数", "activePlayersToday": "今日活跃玩家", "betOrdersToday": "今日下注单数", + "betOrdersTodayHint": "今日 {{count}} 单", "todayBet": "今日下注", "todayPayout": "今日派彩", "todayProfit": "今日盈亏", @@ -202,6 +229,7 @@ "yes": "是", "no": "否", "viewBills": "查看账单", + "lineMeta": "层级 {{depth}} · 可开下级 {{childAgent}} · 可开玩家 {{player}}", "viewLine": "代理线路", "quickLinks": { "tickets": "注单查询", diff --git a/src/i18n/locales/zh/settlementCenter.json b/src/i18n/locales/zh/settlementCenter.json index c43453a..ffebdd9 100644 --- a/src/i18n/locales/zh/settlementCenter.json +++ b/src/i18n/locales/zh/settlementCenter.json @@ -159,7 +159,6 @@ "paid": "已收付", "unpaid": "未结", "status": "状态", - "billId": "账单 ID", "payer": "付款方", "payee": "收款方", "amount": "金额", @@ -282,7 +281,8 @@ "player": "玩家账单", "agent": "代理账单", "pendingConfirm": "待确认", - "awaitingPayment": "待收付" + "awaitingPayment": "待收付", + "all": "全部" }, "quickFilter": { "title": "当前想看哪一层结算", @@ -318,22 +318,49 @@ }, "hierarchyHint": "同一账期会生成多笔账单:玩家先与直属代理结,代理扣除本级占成后再向上级缴纳。因此「输赢」可能相同,但「结算金额」会逐级减少。", "emptyFiltered": "当前筛选下暂无账单,请改为「全部状态」或重置筛选。", - "emptyClosed": "本期已关账但暂无账单。常见原因:账期内无信用盘玩家的已结算注单,或占成流水不在本账期时间范围内。" + "emptyClosed": "本期已关账但暂无账单。常见原因:账期内无信用盘玩家的已结算注单,或占成流水不在本账期时间范围内。", + "intro": "关账后生成的占成账单。可按类型或状态筛选,详情内确认或登记收付。" }, "panels": { - "workbench": { "title": "工作台" }, - "overview": { "title": "结算概览" }, - "ledger": { "title": "账务流水" }, - "bills": { "title": "全部账单" }, - "creditLedger": { "title": "信用流水" }, - "playerBills": { "title": "玩家账单" }, - "agentBills": { "title": "代理账单" }, - "pendingConfirm": { "title": "待确认账单" }, - "awaiting": { "title": "待收付账单" }, - "payments": { "title": "收付记录" }, - "adjustments": { "title": "调账 / 冲正" }, - "reports": { "title": "账期报表" }, - "badDebt": { "title": "坏账核销" } + "workbench": { + "title": "工作台" + }, + "overview": { + "title": "结算概览" + }, + "ledger": { + "title": "账务流水" + }, + "bills": { + "title": "全部账单" + }, + "creditLedger": { + "title": "信用流水" + }, + "playerBills": { + "title": "玩家账单" + }, + "agentBills": { + "title": "代理账单" + }, + "pendingConfirm": { + "title": "待确认账单" + }, + "awaiting": { + "title": "待收付账单" + }, + "payments": { + "title": "收付记录" + }, + "adjustments": { + "title": "调账 / 冲正" + }, + "reports": { + "title": "账期报表" + }, + "badDebt": { + "title": "坏账核销" + } }, "empty": { "noSite": "请选择站点。", @@ -356,5 +383,20 @@ "loadAdjustments": "调账记录加载失败", "loadBadDebt": "坏账记录加载失败", "loadCreditLedger": "信用流水加载失败" + }, + "header": { + "subtitle": "信用盘结算", + "statusRunning": "账期进行中", + "statusIdle": "无进行中账期", + "statusCompleted": "账期已结清" + }, + "subnav": { + "label": "结算中心导航" + }, + "workbench": { + "viewPeriod": "账期", + "closePreset": "关账 · {{label}}", + "closeNoData": "关账失败:账期内无占成流水,请先完成信用盘开奖结算。", + "openPeriodPipeline": "开账 {{range}} · 占成流水 {{share}} 笔" } } diff --git a/src/lib/admin-session-variants.ts b/src/lib/admin-session-variants.ts new file mode 100644 index 0000000..93bf31d --- /dev/null +++ b/src/lib/admin-session-variants.ts @@ -0,0 +1,11 @@ +import type { AdminProfile } from "@/types/api/admin-auth"; + +/** 绑定代理节点的经营账号(非超管)。 */ +export function isAgentOperator(profile: AdminProfile | null | undefined): boolean { + return profile?.agent != null && profile.is_super_admin !== true; +} + +/** 平台站点管理员(绑定 site_admin 角色、无代理节点)。 */ +export function isSiteAdminOperator(profile: AdminProfile | null | undefined): boolean { + return profile?.site != null && profile.is_super_admin !== true; +} diff --git a/src/lib/admin-signed-money.tsx b/src/lib/admin-signed-money.tsx new file mode 100644 index 0000000..4862e70 --- /dev/null +++ b/src/lib/admin-signed-money.tsx @@ -0,0 +1,53 @@ +"use client"; + +import type { ReactElement, ReactNode } from "react"; + +import { cn } from "@/lib/utils"; + +/** 盈亏 / 输赢:负红、正绿、零灰 */ +export function signedMoneyClass(amount: number, emphasize = false): string { + if (amount < 0) { + return cn("text-destructive", emphasize && "font-semibold"); + } + if (amount > 0) { + return cn("text-emerald-600 dark:text-emerald-400", emphasize && "font-semibold"); + } + + return cn("text-muted-foreground", emphasize && "font-medium"); +} + +export function SignedMoney({ + amount, + children, + emphasize, + className, +}: { + amount: number; + children: ReactNode; + emphasize?: boolean; + className?: string; +}): ReactElement { + return ( + + {children} + + ); +} + +/** 报表 / 结算字段是否应按正负着色 */ +export function isSignedMoneyField(key: string): boolean { + return ( + key === "approx_house_gross_minor" || + key === "net_win_loss_minor" || + key === "gross_win_loss" || + key === "game_win_loss" || + key === "profit_loss_minor" || + key === "today_profit_minor" || + key === "seven_day_profit_minor" || + key === "platform_profit" || + key === "platform_profit_minor" || + key === "share_profit" || + key === "share_profit_meta" || + key.endsWith("_profit_minor") + ); +} diff --git a/src/lib/platform-system-roles.ts b/src/lib/platform-system-roles.ts index f1b847c..87171e9 100644 --- a/src/lib/platform-system-roles.ts +++ b/src/lib/platform-system-roles.ts @@ -1,10 +1,15 @@ import type { AdminRoleRow } from "@/types/api/index"; export const PLATFORM_SUPER_ADMIN_SLUG = "super_admin"; +export const PLATFORM_SITE_ADMIN_SLUG = "site_admin"; export const PLATFORM_AGENT_SLUG = "agent"; export function isPlatformFixedRole(role: Pick): boolean { - return role.slug === PLATFORM_SUPER_ADMIN_SLUG || role.slug === PLATFORM_AGENT_SLUG; + return ( + role.slug === PLATFORM_SUPER_ADMIN_SLUG + || role.slug === PLATFORM_SITE_ADMIN_SLUG + || role.slug === PLATFORM_AGENT_SLUG + ); } export function isPlatformSuperAdminRole(role: Pick): boolean { diff --git a/src/modules/agents/agent-line-provision-wizard.tsx b/src/modules/agents/agent-line-provision-wizard.tsx index 177ad71..1864021 100644 --- a/src/modules/agents/agent-line-provision-wizard.tsx +++ b/src/modules/agents/agent-line-provision-wizard.tsx @@ -27,11 +27,14 @@ import type { AdminAgentLineProvisionResult } from "@/types/api/admin-agent-line type AgentLineProvisionWizardProps = { embedded?: boolean; + /** 预选接入站点(如代理线路页当前选中的站点) */ + defaultSiteCode?: string; onSuccess?: (result: AdminAgentLineProvisionResult) => void | Promise; }; export function AgentLineProvisionWizard({ embedded = false, + defaultSiteCode, onSuccess, }: AgentLineProvisionWizardProps): React.ReactElement { const { t } = useTranslation(["agents", "common"]); @@ -40,7 +43,6 @@ export function AgentLineProvisionWizard({ const [sites, setSites] = useState([]); const [form, setForm] = useState({ site_code: "", - code: "", name: "", username: "", password: "", @@ -64,24 +66,22 @@ export function AgentLineProvisionWizard({ [sites], ); + useAsyncEffect(() => { + if (sitesLoading || form.site_code !== "" || !defaultSiteCode) { + return; + } + const normalized = defaultSiteCode.trim().toLowerCase(); + if (unboundSites.some((row) => row.code.toLowerCase() === normalized)) { + setForm((f) => ({ ...f, site_code: normalized })); + } + }, [defaultSiteCode, form.site_code, sitesLoading, unboundSites]); + async function onSubmit(e: React.FormEvent): Promise { e.preventDefault(); if (!form.site_code.trim()) { toast.error(t("agents:lineProvision.siteRequired", { defaultValue: "请选择接入站点" })); return; } - if (!form.code.trim()) { - toast.error(t("agents:lineProvision.codeRequired", { defaultValue: "请填写代理编码" })); - return; - } - if (!/^[a-z0-9][a-z0-9_-]*$/i.test(form.code.trim())) { - toast.error( - t("agents:lineProvision.codePatternInvalid", { - defaultValue: "代理编码仅支持字母、数字、下划线和中划线,且需以字母或数字开头", - }), - ); - return; - } if (!form.name.trim()) { toast.error(t("agents:nameRequired", { defaultValue: "请填写代理名称" })); return; @@ -152,7 +152,6 @@ export function AgentLineProvisionWizard({ try { const result = await postAdminAgentLine({ site_code: form.site_code.trim().toLowerCase(), - code: form.code.trim().toLowerCase(), name: form.name.trim(), username: form.username.trim(), password: form.password, @@ -165,7 +164,6 @@ export function AgentLineProvisionWizard({ toast.success(t("agents:lineProvision.success", { defaultValue: "一级代理已创建" })); setForm((f) => ({ site_code: "", - code: "", name: "", username: "", password: "", @@ -200,7 +198,7 @@ export function AgentLineProvisionWizard({

{t("agents:lineProvision.description", { defaultValue: - "将一级代理绑定到已有接入站点,并配置后台登录账号与占成、授信、回水。代理编码创建后不可修改。", + "将一级代理绑定到已有接入站点,并配置后台登录账号与占成、授信、回水。", })}{" "}

-
- - setForm((f) => ({ ...f, code: e.target.value }))} - required - pattern="[a-z0-9][a-z0-9_-]*" - /> -
s.adminSiteId); const setAdminSiteId = useAgentManagementSiteStore((s) => s.setAdminSiteId); const [tree, setTree] = useState([]); @@ -241,10 +244,17 @@ export function AgentsConsole(): React.ReactElement { [flatNodes], ); const visibleAgentRows = flatNodes; - const selectedSiteLabel = useMemo( - () => siteOptions.find((site) => site.id === adminSiteId)?.name ?? null, - [adminSiteId, siteOptions], - ); + const boundSite = profile?.site ?? null; + const selectedSiteLabel = useMemo(() => { + const fromOptions = siteOptions.find((site) => site.id === adminSiteId)?.name; + if (fromOptions) { + return fromOptions; + } + if (boundSite != null && boundSite.id === adminSiteId) { + return boundSite.name; + } + return null; + }, [adminSiteId, boundSite, siteOptions]); const activeSiteCode = useMemo(() => { const fromAgent = boundAgent?.site_code?.trim(); if (fromAgent) { @@ -254,8 +264,11 @@ export function AgentsConsole(): React.ReactElement { if (fromSite) { return fromSite; } + if (boundSite != null && boundSite.id === adminSiteId) { + return boundSite.code.trim(); + } return flatNodes.find((node) => node.depth === 0)?.code?.trim() ?? ""; - }, [adminSiteId, boundAgent?.site_code, flatNodes, siteOptions]); + }, [adminSiteId, boundAgent?.site_code, boundSite, flatNodes, siteOptions]); const rootNode = useMemo( () => flatNodes.find((node) => node.is_root || node.depth === 0) ?? null, [flatNodes], @@ -319,11 +332,23 @@ export function AgentsConsole(): React.ReactElement { setAdminSiteId(profile.agent.admin_site_id); return; } + if (profile?.site?.id) { + setAdminSiteId(profile.site.id); + return; + } if (siteOptions.length > 0 && isSuperAdmin) { setAdminSiteId(siteOptions[0]?.id ?? null); } } - }, [adminSiteId, canViewAgents, isSuperAdmin, profile?.agent?.admin_site_id, setAdminSiteId, siteOptions]); + }, [ + adminSiteId, + canViewAgents, + isSuperAdmin, + profile?.agent?.admin_site_id, + profile?.site?.id, + setAdminSiteId, + siteOptions, + ]); useEffect(() => { if (!Number.isInteger(selectedNodeIdFromUrl) || selectedNodeIdFromUrl <= 0) { @@ -786,10 +811,60 @@ export function AgentsConsole(): React.ReactElement { ); } - if (canViewAgents && loading && tree.length === 0) { + const hasSiteContext = + siteOptions.length > 0 || + profile?.site != null || + (profile?.accessible_sites?.length ?? 0) > 0; + + if (canViewAgents && profile?.agent == null && !sitesLoading && !hasSiteContext) { + return ; + } + + if (canViewAgents && loading && tree.length === 0 && adminSiteId !== null) { return ; } + const showSiteAdminAwaitingRoot = + !loading && + flatNodes.length === 0 && + !canProvisionLine && + isSiteAdminOperator(profile); + + if (showSiteAdminAwaitingRoot) { + return ( +
+

+ {t("lineUi.awaitingRootAgentTitle", { + defaultValue: "本站尚未开通一级代理", + })} +

+

+ {t("lineUi.awaitingRootAgentHint", { + defaultValue: + "一级代理需由平台超级管理员在「开通一级代理」中创建。开通后您可在此管理下级代理、占成与授信。", + })} +

+
+ ); + } + + const showProvisionEmpty = + !loading && flatNodes.length === 0 && canProvisionLine; + + if (showProvisionEmpty) { + return ( +
+ { + await loadTree(adminSiteId); + }} + /> +
+ ); + } + return (
@@ -861,9 +936,13 @@ export function AgentsConsole(): React.ReactElement {
) : (
- {t("lineUi.provisionOnlyHint", { - defaultValue: "当前账号仅可开通一级代理线路,请前往「开通一级代理」页面创建新线路。", - })} + {flatNodes.length === 0 + ? t("lineUi.noRootAgentHint", { + defaultValue: "该站点尚未开通一级代理,请联系平台管理员在「开通一级代理」中创建线路。", + }) + : t("lineUi.provisionOnlyHint", { + defaultValue: "当前账号仅可开通一级代理线路,请前往「开通一级代理」页面创建新线路。", + })}
)} diff --git a/src/modules/agents/agents-directory-console.tsx b/src/modules/agents/agents-directory-console.tsx index 27de7a9..586b48d 100644 --- a/src/modules/agents/agents-directory-console.tsx +++ b/src/modules/agents/agents-directory-console.tsx @@ -12,6 +12,7 @@ import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu"; import { AdminStatusBadge } from "@/components/admin/admin-status-badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { formatAdminCreditMajorDecimal } from "@/lib/money"; import { Select, SelectContent, @@ -44,7 +45,7 @@ function formatCredit(value: number | null | undefined): string { return "-"; } - return new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 0 }).format(value); + return formatAdminCreditMajorDecimal(value); } function statusLabel(status: number, t: (key: string, options?: { defaultValue?: string }) => string): string { diff --git a/src/modules/agents/agents-subnav.tsx b/src/modules/agents/agents-subnav.tsx index c1407c6..f5adb21 100644 --- a/src/modules/agents/agents-subnav.tsx +++ b/src/modules/agents/agents-subnav.tsx @@ -35,7 +35,7 @@ export function AgentsSubnav(): React.ReactElement { adminHasAnyPermission(profile?.permissions, [...PRD_INTEGRATION_ACCESS_ANY]); useEffect(() => { - if (adminSiteId !== null || siteOptions.length === 0) { + if (adminSiteId !== null) { return; } const boundSiteId = profile?.agent?.admin_site_id; @@ -43,14 +43,26 @@ export function AgentsSubnav(): React.ReactElement { setAdminSiteId(boundSiteId); return; } - setAdminSiteId(siteOptions[0]?.id ?? null); - }, [adminSiteId, profile?.agent?.admin_site_id, setAdminSiteId, siteOptions]); + if (profile?.site?.id) { + setAdminSiteId(profile.site.id); + return; + } + if (siteOptions.length > 0) { + setAdminSiteId(siteOptions[0]?.id ?? null); + } + }, [adminSiteId, profile?.agent?.admin_site_id, profile?.site?.id, setAdminSiteId, siteOptions]); - const selectSiteId = adminSiteId ?? siteOptions[0]?.id ?? null; + const selectSiteId = adminSiteId ?? profile?.site?.id ?? siteOptions[0]?.id ?? null; const selectedSite = useMemo(() => { const site = siteOptions.find((item) => item.id === selectSiteId); - return site ?? null; - }, [selectSiteId, siteOptions]); + if (site) { + return site; + } + if (profile?.site != null && profile.site.id === selectSiteId) { + return profile.site; + } + return null; + }, [profile?.site, selectSiteId, siteOptions]); const filteredSites = useMemo(() => { const normalized = deferredKeyword.trim().toLowerCase(); @@ -61,6 +73,16 @@ export function AgentsSubnav(): React.ReactElement { return siteOptions.filter((site) => site.name.toLowerCase().includes(normalized)); }, [deferredKeyword, siteOptions]); + const siteReadOnlyLabel = + pathname !== "/admin/agents/list" && + !canSwitchSite && + selectedSite != null ? ( +
+ {selectedSite.name} + {selectedSite.code} +
+ ) : null; + const siteSelector = pathname !== "/admin/agents/list" && canSwitchSite && siteOptions.length > 0 && selectSiteId !== null ? ( @@ -129,7 +151,7 @@ export function AgentsSubnav(): React.ReactElement { ) : null; return ( - +

{t("title", { defaultValue: "代理管理" })} diff --git a/src/modules/dashboard/agent-dashboard-console.tsx b/src/modules/dashboard/agent-dashboard-console.tsx index c9cc869..8513116 100644 --- a/src/modules/dashboard/agent-dashboard-console.tsx +++ b/src/modules/dashboard/agent-dashboard-console.tsx @@ -1,20 +1,8 @@ "use client"; -import Link from "next/link"; import { useCallback, useMemo, useState, type ReactElement } from "react"; import { useTranslation } from "react-i18next"; -import { - BarChart3, - Flame, - Landmark, - Network, - RefreshCw, - Sparkles, - Ticket, - TrendingUp, - Users, - Wallet, -} from "lucide-react"; +import { BarChart3, RefreshCw, TrendingUp, Users, Wallet } from "lucide-react"; import { getAdminDashboard } from "@/api/admin-dashboard"; import { useAsyncEffect } from "@/hooks/use-async-effect"; @@ -22,24 +10,18 @@ import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter" import { useTranslationRef } from "@/hooks/use-translation-ref"; import { useCachedPlayTypeOptions } from "@/hooks/use-cached-play-type-options"; import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog"; -import { adminHasAnyPermission } from "@/lib/admin-permissions"; -import { - PRD_AGENT_HUB_ACCESS_ANY, - PRD_PLAYERS_ACCESS_ANY, - PRD_REPORTS_VIEW_ACCESS_ANY, - PRD_SETTLEMENT_AGENT_ACCESS_ANY, - PRD_TICKETS_ACCESS_ANY, -} from "@/lib/admin-prd"; import { normalizeAdminLanguage } from "@/i18n"; import { adminWeekdayKeyForDate, formatAdminCalendarToday } from "@/lib/admin-datetime"; +import { signedMoneyClass } from "@/lib/admin-signed-money"; import { cn } from "@/lib/utils"; import { useAdminProfile } from "@/stores/admin-session"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { Button, buttonVariants } from "@/components/ui/button"; +import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; import { DashboardCurrentDrawCard } from "@/modules/dashboard/dashboard-current-draw-card"; import { DashboardAnalyticsPanel } from "@/modules/dashboard/dashboard-analytics-panel"; +import { DashboardKpiCard } from "@/modules/dashboard/dashboard-visuals"; import { formatDashboardCreditMajor, formatDashboardMoneyMinor, @@ -49,14 +31,27 @@ import type { AdminDashboardAgentOverview } from "@/types/api/admin-dashboard"; import type { DrawCurrentSnapshot } from "@/types/api/public-draw"; import { LotteryApiBizError } from "@/types/api/errors"; +function AgentMetric({ + label, + value, +}: { + label: string; + value: string; +}): ReactElement { + return ( +

+

{label}

+

{value}

+
+ ); +} + export function AgentDashboardConsole(): ReactElement { const { t, i18n } = useTranslation(["dashboard", "common", "agents"]); const tRef = useTranslationRef(["dashboard", "common"]); const formatDt = useAdminDateTimeFormatter(); const profile = useAdminProfile(); const agent = profile?.agent ?? null; - const permissions = useMemo(() => profile?.permissions ?? [], [profile?.permissions]); - const todayLabel = useMemo(() => { const locale = normalizeAdminLanguage(i18n.resolvedLanguage ?? i18n.language); const weekday = t(`date.weekdays.${adminWeekdayKeyForDate()}`, { ns: "common" }); @@ -118,47 +113,6 @@ export function AgentDashboardConsole(): ReactElement { const currency = "NPR"; const displayCurrency = overview?.currency_code ?? currency; - const quickLinks = useMemo(() => { - const links: { href: string; label: string; icon: ReactElement }[] = []; - if (adminHasAnyPermission(permissions, [...PRD_TICKETS_ACCESS_ANY])) { - links.push({ - href: "/admin/tickets", - label: t("agent.quickLinks.tickets"), - icon: , - }); - } - if (adminHasAnyPermission(permissions, [...PRD_PLAYERS_ACCESS_ANY])) { - links.push({ - href: "/admin/players", - label: t("agent.quickLinks.players"), - icon: , - }); - } - if (adminHasAnyPermission(permissions, [...PRD_REPORTS_VIEW_ACCESS_ANY])) { - links.push({ - href: "/admin/reports", - label: t("agent.quickLinks.reports"), - icon: , - }); - } - if (adminHasAnyPermission(permissions, [...PRD_AGENT_HUB_ACCESS_ANY])) { - links.push({ - href: "/admin/agents", - label: t("agent.quickLinks.agents"), - icon: , - }); - } - if (adminHasAnyPermission(permissions, [...PRD_SETTLEMENT_AGENT_ACCESS_ANY])) { - links.push({ - href: "/admin/settlement-center", - label: t("agent.quickLinks.bills"), - icon: , - }); - } - - return links; - }, [permissions, t]); - return (
@@ -191,270 +145,134 @@ export function AgentDashboardConsole(): ReactElement { ) : null} {loading ? ( -
+
{Array.from({ length: 4 }).map((_, i) => ( - + ))}
) : overview ? (
-
- - -
-
-

- {t("agent.heroEyebrow")} -

- - {t("agent.heroTitle", { name: overview.agent_name || overview.agent_code })} - -
-
- -
-
+
+ } + hint={ + overview.latest_bet_at + ? t("agent.latestBetAt", { time: formatDt(overview.latest_bet_at) }) + : t("agent.noBetToday") + } + /> + } + hint={t("agent.shareRate", { rate: overview.total_share_rate })} + valueClassName={signedMoneyClass(overview.today_profit_minor, true)} + /> + } + hint={t("agent.betOrdersTodayHint", { count: overview.bet_order_count_today })} + /> + } + hint={t("agent.pendingUnpaid", { + amount: formatDashboardMoneyMinor(overview.pending_unpaid_minor, displayCurrency), + })} + accent={overview.pending_bill_count > 0 ? "destructive" : "muted"} + /> +
+ + + + {t("agent.creditTitle")} + + +
+

+ {formatDashboardCreditMajor(overview.credit_limit, displayCurrency)} +

+

+ {t("agent.creditAvailable", { + amount: formatDashboardCreditMajor(overview.available_credit, displayCurrency), + })} +

+
+
+ + + +
+

+ {t("agent.lineMeta", { + depth: overview.depth, + childAgent: overview.can_create_child_agent ? t("agent.yes") : t("agent.no"), + player: overview.can_create_player ? t("agent.yes") : t("agent.no"), + })} +

+
+
+ +
+ + + {t("agent.sevenDayTitle")} - -
-
-

{t("agent.todayBet")}

-

- {formatDashboardMoneyMinor(overview.today_bet_minor, displayCurrency)} -

-
-
-

{t("agent.todayPayout")}

-

- {formatDashboardMoneyMinor(overview.today_payout_minor, displayCurrency)} -

-
-
-

{t("agent.todayShareProfit")}

-

- {formatDashboardSignedMoneyMinor(overview.today_profit_minor, displayCurrency)} -

-
+ +
+ {t("agent.todayBet")} + + {formatDashboardMoneyMinor(overview.seven_day_bet_minor, displayCurrency)} +
-
-
-

{t("agent.activePlayersToday")}

-

{overview.active_player_count_today}

-
-
-

{t("agent.betOrdersToday")}

-

{overview.bet_order_count_today}

-
-
-

{t("agent.pendingBills")}

-

{overview.pending_bill_count}

-
-
-
- {t("agent.shareRate", { rate: overview.total_share_rate })} - - {overview.latest_bet_at - ? t("agent.latestBetAt", { time: formatDt(overview.latest_bet_at) }) - : t("agent.noBetToday")} +
+ {t("agent.todayShareProfit")} + + {formatDashboardSignedMoneyMinor(overview.seven_day_profit_minor, displayCurrency)}
- - - {t("agent.creditTitle")} - - -
-

- {formatDashboardCreditMajor(overview.credit_limit, displayCurrency)} -

-

- {t("agent.creditAvailable", { - amount: formatDashboardCreditMajor(overview.available_credit, displayCurrency), - })} -

-
-
-
-

{t("agent.creditAllocatedLabel")}

-

- {formatDashboardCreditMajor(overview.allocated_credit, displayCurrency)} -

-
-
-

{t("agent.creditUsedLabel")}

-

- {formatDashboardCreditMajor(overview.used_credit, displayCurrency)} -

-
-
-

- {t("agent.pendingUnpaid", { - amount: formatDashboardMoneyMinor(overview.pending_unpaid_minor, displayCurrency), - })} -

-
-
-
- -
- - - {t("agent.sevenDayTitle")} - - - -

- {formatDashboardMoneyMinor(overview.seven_day_bet_minor, displayCurrency)} -

-

- {t("agent.sevenDayPayout", { - amount: formatDashboardMoneyMinor(overview.seven_day_payout_minor, displayCurrency), - })} -

-

- {t("agent.sevenDayShareProfit", { - amount: formatDashboardSignedMoneyMinor(overview.seven_day_profit_minor, displayCurrency), - })} -

-
-
- - - - - - {t("agent.teamTitle")} - + {t("agent.teamTitle")} -
-

{t("agent.directChildren")}

-

{overview.direct_child_count}

-
-
-

{t("agent.subtreeAgents")}

-

{overview.subtree_agent_count}

-
-
-

{t("agent.directPlayers")}

-

{overview.direct_player_count}

-
-
-

{t("agent.teamPlayers")}

-

{overview.team_player_count}

-
-
-
- - - - - - {t("agent.pendingBills")} - - - -

{overview.pending_bill_count}

-

- {t("agent.pendingUnpaid", { - amount: formatDashboardMoneyMinor(overview.pending_unpaid_minor, displayCurrency), - })} -

-
-
- - - - - - {t("agent.topMomentum")} - - - - {overview.top_agent_today ? ( - <> -

- {overview.top_agent_today.agent_name || overview.top_agent_today.agent_code} -

-

- {formatDashboardMoneyMinor(overview.top_agent_today.total_bet_minor, displayCurrency)} -

-

- {t("agent.topMomentumPayout", { - amount: formatDashboardMoneyMinor( - overview.top_agent_today.total_payout_minor, - displayCurrency, - ), - })} -

- - ) : ( -

{t("agent.noBetToday")}

- )} -
-
-
- -
- - - - - {t("agent.managementFocus")} - - - -
-

{t("agent.focusBet")}

-

- {formatDashboardMoneyMinor(overview.today_bet_minor, displayCurrency)} -

-
-
-

{t("agent.focusPlayers")}

-

{overview.active_player_count_today}

-
-
-

{t("agent.focusBills")}

-

{overview.pending_bill_count}

-
-
-
- - - - {t("agent.quickStatsTitle")} - - -
- {t("agent.canCreateChildAgent")} - - {overview.can_create_child_agent ? t("agent.yes") : t("agent.no")} - -
-
- {t("agent.canCreatePlayer")} - - {overview.can_create_player ? t("agent.yes") : t("agent.no")} - -
-
- {t("agent.lineDepth")} - {overview.depth} -
- {adminHasAnyPermission(permissions, [...PRD_SETTLEMENT_AGENT_ACCESS_ANY]) ? ( - - {t("agent.viewBills")} - - ) : null} + + + +
@@ -480,21 +298,6 @@ export function AgentDashboardConsole(): ReactElement { {t("warnings.drawPermission")} )} - - {quickLinks.length > 0 ? ( -
- {quickLinks.map((link) => ( - - {link.icon} - {link.label} - - ))} -
- ) : null}
); } diff --git a/src/modules/dashboard/dashboard-analytics-panel.tsx b/src/modules/dashboard/dashboard-analytics-panel.tsx index 0f77da4..fc7f64c 100644 --- a/src/modules/dashboard/dashboard-analytics-panel.tsx +++ b/src/modules/dashboard/dashboard-analytics-panel.tsx @@ -21,6 +21,7 @@ import { } from "@/components/ui/select"; import { Skeleton } from "@/components/ui/skeleton"; import { getAdminRequestLocale } from "@/lib/admin-locale"; +import { signedMoneyClass } from "@/lib/admin-signed-money"; import { cn } from "@/lib/utils"; import { DashboardKpiCard } from "@/modules/dashboard/dashboard-visuals"; import { DASHBOARD_CHART_COLORS } from "@/modules/dashboard/dashboard-chart-config"; @@ -241,6 +242,7 @@ export function DashboardAnalyticsMain({ analytics }: { analytics: DashboardAnal : t("analytics.summaryProfit") } value={formatSignedMoney(summary.approx_house_gross_minor, currency)} + valueClassName={signedMoneyClass(summary.approx_house_gross_minor, true)} hint={ profitScope === "share_profit" ? t("analytics.shareProfitHint") @@ -452,7 +454,14 @@ export function DashboardAgentRankingCard({ ) : null}
-
+
{formatRowValue(row)}
diff --git a/src/modules/dashboard/dashboard-console.tsx b/src/modules/dashboard/dashboard-console.tsx index 1bddd1d..bf46959 100644 --- a/src/modules/dashboard/dashboard-console.tsx +++ b/src/modules/dashboard/dashboard-console.tsx @@ -2,22 +2,9 @@ import dynamic from "next/dynamic"; import Link from "next/link"; -import { useCallback, useEffect, useMemo, useState, type ReactElement, type ReactNode } from "react"; +import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react"; import { useTranslation } from "react-i18next"; -import { - AlertTriangle, - ClipboardList, - Diamond, - FileSearch, - RefreshCw, - ScrollText, - Settings, - Shield, - Ticket, - Wallet, - BarChart3, - Scale, -} from "lucide-react"; +import { AlertTriangle, ClipboardList, RefreshCw, Shield, Wallet } from "lucide-react"; import { getAdminDashboardByScope } from "@/api/admin-dashboard"; import { useAdminPlayTypeCatalog } from "@/hooks/use-admin-play-type-catalog"; @@ -275,23 +262,6 @@ export function DashboardConsole(): ReactElement { }); const showAnalytics = canFinance; - const quickLinks: { href: string; label: string; icon: ReactNode }[] = [ - { href: "/admin/draws", label: t("quickLinks.createDrawPlan"), icon: }, - { href: "/admin/draws", label: t("quickLinks.drawSchedule"), icon: }, - { - href: drawId != null ? `/admin/draws/${drawId}/results` : "/admin/draws", - label: t("quickLinks.results"), - icon: , - }, - { href: "/admin/tickets", label: t("quickLinks.tickets"), icon: }, - { href: "/admin/wallet/transactions", label: t("quickLinks.walletTransactions"), icon: }, - { href: "/admin/audit-logs", label: t("quickLinks.auditLogs"), icon: }, - { href: "/admin/reports", label: t("quickLinks.reports"), icon: }, - { href: "/admin/rules/odds", label: t("quickLinks.payoutRules"), icon: }, - { href: "/admin/risk", label: t("quickLinks.riskMonitor"), icon: }, - { href: "/admin/settings", label: t("quickLinks.systemSettings"), icon: }, - ]; - return (
@@ -518,42 +488,20 @@ export function DashboardConsole(): ReactElement {
{!showAnalytics ? ( -
- - - {t("financeStructure")} - - - {loading ? ( - - ) : finance ? ( - - ) : ( - - )} - - - - - - {t("quickLinksTitle")} - - - {quickLinks.map((q) => ( - - - {q.icon} - - {q.label} - - ))} - - -
+ + + {t("financeStructure")} + + + {loading ? ( + + ) : finance ? ( + + ) : ( + + )} + + ) : null}
@@ -576,26 +524,6 @@ export function DashboardConsole(): ReactElement { )} - - - - {t("quickLinksTitle")} - - - {quickLinks.map((q) => ( - - - {q.icon} - - {q.label} - - ))} - - ) : null}
diff --git a/src/modules/dashboard/dashboard-page-client.tsx b/src/modules/dashboard/dashboard-page-client.tsx index 2703885..1c17384 100644 --- a/src/modules/dashboard/dashboard-page-client.tsx +++ b/src/modules/dashboard/dashboard-page-client.tsx @@ -2,19 +2,23 @@ import type { ReactElement } from "react"; +import { isAgentOperator, isSiteAdminOperator } from "@/lib/admin-session-variants"; import { AgentDashboardConsole } from "@/modules/dashboard/agent-dashboard-console"; import { DashboardConsole } from "@/modules/dashboard/dashboard-console"; +import { SiteDashboardConsole } from "@/modules/dashboard/site-dashboard-console"; import { useAdminProfile } from "@/stores/admin-session"; -/** 平台账号走全站仪表盘;绑定代理节点的经营账号走代理仪表盘。 */ +/** 超管/平台账号走全站仪表盘;站点管理员走站点仪表盘;代理经营账号走代理仪表盘。 */ export function DashboardPageClient(): ReactElement { const profile = useAdminProfile(); - const isAgentOperator = - profile?.agent != null && profile.is_super_admin !== true; - if (isAgentOperator) { + if (isAgentOperator(profile)) { return ; } + if (isSiteAdminOperator(profile)) { + return ; + } + return ; } diff --git a/src/modules/dashboard/dashboard-trend-charts.tsx b/src/modules/dashboard/dashboard-trend-charts.tsx index d12f3d1..3f7f1c7 100644 --- a/src/modules/dashboard/dashboard-trend-charts.tsx +++ b/src/modules/dashboard/dashboard-trend-charts.tsx @@ -12,6 +12,8 @@ import { ChartTooltip, ChartTooltipContent, } from "@/components/ui/chart"; +import { signedMoneyClass } from "@/lib/admin-signed-money"; +import { cn } from "@/lib/utils"; import { buildTrendChartConfig, DASHBOARD_CHART_COLORS } from "@/modules/dashboard/dashboard-chart-config"; import { DashboardChartEmpty } from "@/modules/dashboard/dashboard-chart-empty"; import type { AdminDashboardAnalyticsPlayRow } from "@/types/api/admin-dashboard-analytics"; @@ -332,7 +334,14 @@ export function PeriodCompareStrip({ {row.label} {row.pctText}
-
{formatMoney(row.value, currency)}
+
+ {formatMoney(row.value, currency)} +

{label}

-

+

{value}

{deltaLabel ?
{deltaLabel}
: null} diff --git a/src/modules/dashboard/site-dashboard-console.tsx b/src/modules/dashboard/site-dashboard-console.tsx new file mode 100644 index 0000000..beb6cb6 --- /dev/null +++ b/src/modules/dashboard/site-dashboard-console.tsx @@ -0,0 +1,255 @@ +"use client"; + +import { useCallback, useMemo, useState, type ReactElement } from "react"; +import { useTranslation } from "react-i18next"; +import { BarChart3, RefreshCw, TrendingUp, Users, Wallet } from "lucide-react"; + +import { getAdminDashboard } from "@/api/admin-dashboard"; +import { useAsyncEffect } from "@/hooks/use-async-effect"; +import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"; +import { useTranslationRef } from "@/hooks/use-translation-ref"; +import { useCachedPlayTypeOptions } from "@/hooks/use-cached-play-type-options"; +import { adminHasAnyPermission } from "@/lib/admin-permissions"; +import { PRD_REPORTS_VIEW_ACCESS_ANY } from "@/lib/admin-prd"; +import { normalizeAdminLanguage } from "@/i18n"; +import { adminWeekdayKeyForDate, formatAdminCalendarToday } from "@/lib/admin-datetime"; +import { signedMoneyClass } from "@/lib/admin-signed-money"; +import { cn } from "@/lib/utils"; +import { useAdminProfile } from "@/stores/admin-session"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { DashboardCurrentDrawCard } from "@/modules/dashboard/dashboard-current-draw-card"; +import { DashboardAnalyticsPanel } from "@/modules/dashboard/dashboard-analytics-panel"; +import { DashboardKpiCard } from "@/modules/dashboard/dashboard-visuals"; +import { + formatDashboardMoneyMinor, + formatDashboardSignedMoneyMinor, +} from "@/modules/dashboard/use-dashboard-analytics"; +import type { AdminDashboardSiteOverview } from "@/types/api/admin-dashboard"; +import type { DrawCurrentSnapshot } from "@/types/api/public-draw"; +import { LotteryApiBizError } from "@/types/api/errors"; + +function SiteMetric({ + label, + value, +}: { + label: string; + value: string; +}): ReactElement { + return ( +
+

{label}

+

{value}

+
+ ); +} + +export function SiteDashboardConsole(): ReactElement { + const { t, i18n } = useTranslation(["dashboard", "common"]); + const tRef = useTranslationRef(["dashboard", "common"]); + const formatDt = useAdminDateTimeFormatter(); + const profile = useAdminProfile(); + const site = profile?.site ?? null; + const permissions = useMemo(() => profile?.permissions ?? [], [profile?.permissions]); + + const todayLabel = useMemo(() => { + const locale = normalizeAdminLanguage(i18n.resolvedLanguage ?? i18n.language); + const weekday = t(`date.weekdays.${adminWeekdayKeyForDate()}`, { ns: "common" }); + + return formatAdminCalendarToday(locale, weekday); + }, [i18n.language, i18n.resolvedLanguage, t]); + + const playOptions = useCachedPlayTypeOptions(); + + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); + const [hall, setHall] = useState(null); + const [drawId, setDrawId] = useState(null); + const [overview, setOverview] = useState(null); + + const analyticsScope = useMemo( + () => ({ + siteCode: site?.code ?? overview?.site_code ?? "", + }), + [overview?.site_code, site?.code], + ); + + const canAnalytics = adminHasAnyPermission(permissions, [...PRD_REPORTS_VIEW_ACCESS_ANY]); + + const load = useCallback(async (isRefresh = false) => { + if (isRefresh) { + setRefreshing(true); + } else { + setLoading(true); + } + setError(null); + + try { + const d = await getAdminDashboard(); + setHall(d.hall); + setOverview(d.site_overview); + if (d.resolved_draw != null) { + setDrawId(d.resolved_draw.id); + } else { + setDrawId(null); + } + } catch (e) { + const msg = + e instanceof LotteryApiBizError ? e.message : tRef.current("warnings.loadFailed"); + setError(msg); + } finally { + setLoading(false); + setRefreshing(false); + } + }, [tRef]); + + useAsyncEffect(() => { + void load(false); + }, []); + + const displayCurrency = overview?.currency_code ?? "NPR"; + + return ( +
+
+
+

{t("site.title")}

+

+ {site + ? t("site.subtitle", { name: site.name || site.code }) + : todayLabel} +

+
+ +
+ + {error ? ( + + {t("notice")} + {error} + + ) : null} + + {loading ? ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ ) : overview ? ( +
+
+ } + hint={ + overview.latest_bet_at + ? t("site.latestBetAt", { time: formatDt(overview.latest_bet_at) }) + : t("site.noBetToday") + } + /> + } + hint={t("site.profitScopeHint")} + valueClassName={signedMoneyClass(overview.today_profit_minor, true)} + /> + } + hint={t("site.betOrdersTodayHint", { count: overview.bet_order_count_today })} + /> + } + hint={t("site.pendingUnpaid", { + amount: formatDashboardMoneyMinor(overview.pending_unpaid_minor, displayCurrency), + })} + accent={overview.pending_bill_count > 0 ? "destructive" : "muted"} + /> +
+ +
+ + + {t("site.sevenDayTitle")} + + +
+ {t("site.todayBet")} + + {formatDashboardMoneyMinor(overview.seven_day_bet_minor, displayCurrency)} + +
+
+ {t("site.sevenDayProfit")} + + {formatDashboardSignedMoneyMinor(overview.seven_day_profit_minor, displayCurrency)} + +
+
+
+ + + + {t("site.scaleTitle")} + + + + + {overview.top_agent_today ? ( +
+ {t("site.topAgentToday", { + name: overview.top_agent_today.agent_name || overview.top_agent_today.agent_code, + amount: formatDashboardMoneyMinor( + overview.top_agent_today.total_bet_minor, + displayCurrency, + ), + })} +
+ ) : null} +
+
+
+
+ ) : null} + + + + {canAnalytics ? ( + + ) : null} +
+ ); +} diff --git a/src/modules/draws/draw-detail-console.tsx b/src/modules/draws/draw-detail-console.tsx index 1e8b6a4..78b6caa 100644 --- a/src/modules/draws/draw-detail-console.tsx +++ b/src/modules/draws/draw-detail-console.tsx @@ -28,6 +28,7 @@ import type { AdminDrawShowData } from "@/types/api/admin-draws"; import { canManageDrawResults } from "@/lib/draw-access"; import { adminHasAnyPermission } from "@/lib/admin-permissions"; import { useAdminProfile } from "@/stores/admin-session"; +import { signedMoneyClass } from "@/lib/admin-signed-money"; import { cn } from "@/lib/utils"; import { formatAdminMinorUnits } from "@/lib/money"; @@ -309,7 +310,15 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {

{t("overviewProfitLoss")}

-

+

{formatAdminMinorUnits( financeSummary?.approx_house_gross_minor ?? data.profit_loss_minor ?? 0, financeCurrency, diff --git a/src/modules/draws/draw-finance-console.tsx b/src/modules/draws/draw-finance-console.tsx index 07141aa..1f2ade0 100644 --- a/src/modules/draws/draw-finance-console.tsx +++ b/src/modules/draws/draw-finance-console.tsx @@ -24,6 +24,7 @@ import { TableRow, } from "@/components/ui/table"; import { adminHasAnyPermission } from "@/lib/admin-permissions"; +import { signedMoneyClass } from "@/lib/admin-signed-money"; import { cn } from "@/lib/utils"; import { useAdminProfile } from "@/stores/admin-session"; import { LotteryApiBizError } from "@/types/api/errors"; @@ -140,12 +141,7 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE

{t("grossProfit")} -

= 0 ? "text-emerald-600" : "text-destructive", - )} - > +

{formatMoney(data.approx_house_gross_minor)}

diff --git a/src/modules/draws/draws-index-console.tsx b/src/modules/draws/draws-index-console.tsx index f35f7a5..afdb107 100644 --- a/src/modules/draws/draws-index-console.tsx +++ b/src/modules/draws/draws-index-console.tsx @@ -52,6 +52,7 @@ import { import { formatAdminMinorUnits } from "@/lib/money"; import { useConfirmAction } from "@/hooks/use-confirm-action"; import { useExportLabels } from "@/hooks/use-export-labels"; +import { signedMoneyClass } from "@/lib/admin-signed-money"; import { cn } from "@/lib/utils"; import { useAdminProfile } from "@/stores/admin-session"; import { LotteryApiBizError } from "@/types/api/errors"; @@ -441,7 +442,7 @@ export function DrawsIndexConsole() { {row.profit_loss_minor != null diff --git a/src/modules/integration/integration-sites-console.tsx b/src/modules/integration/integration-sites-console.tsx index 4712627..366fbd0 100644 --- a/src/modules/integration/integration-sites-console.tsx +++ b/src/modules/integration/integration-sites-console.tsx @@ -1,6 +1,6 @@ "use client"; -import { Copy, Download, Link2, Pencil, ShieldAlert } from "lucide-react"; +import { Copy, Download, Link2, Pencil, ShieldAlert, Trash2 } from "lucide-react"; import { useCallback, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useAsyncEffect } from "@/hooks/use-async-effect"; @@ -8,6 +8,7 @@ import { useTranslationRef } from "@/hooks/use-translation-ref"; import { toast } from "sonner"; import { + deleteAdminIntegrationSite, getAdminIntegrationSite, getAdminIntegrationSiteExport, getAdminIntegrationSites, @@ -243,6 +244,8 @@ export function IntegrationSitesConsole({ const [editingId, setEditingId] = useState(null); const [form, setForm] = useState(EMPTY_FORM); const [rotateTarget, setRotateTarget] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleteBusy, setDeleteBusy] = useState(false); const [rotateBusy, setRotateBusy] = useState(false); const [secretsDialog, setSecretsDialog] = useState<{ siteCode: string; @@ -388,6 +391,25 @@ export function IntegrationSitesConsole({ } } + async function confirmDelete(): Promise { + if (!deleteTarget || !canManage) return; + + setDeleteBusy(true); + try { + await deleteAdminIntegrationSite(deleteTarget.id); + toast.success(t("integrationSites.deleteSuccess", { code: deleteTarget.code })); + secretsCacheRef.current.delete(deleteTarget.id); + setDeleteTarget(null); + await load(); + } catch (error) { + toast.error( + error instanceof LotteryApiBizError ? error.message : t("integrationSites.deleteFailed"), + ); + } finally { + setDeleteBusy(false); + } + } + function openConnectivity(row: AdminIntegrationSiteRow): void { setConnectivityTarget(row); setConnectivityPlayerId("10001"); @@ -519,7 +541,13 @@ export function IntegrationSitesConsole({ {loading ? ( ) : items.length === 0 ? ( - + + {canCreate ? ( + + ) : null} + ) : (
@@ -637,6 +665,14 @@ export function IntegrationSitesConsole({ hidden: !canManage, onClick: () => setRotateTarget(row), }, + { + key: "delete", + label: t("integrationSites.delete"), + icon: Trash2, + destructive: true, + hidden: !canManage, + onClick: () => setDeleteTarget(row), + }, ]} /> @@ -850,6 +886,33 @@ export function IntegrationSitesConsole({ + !open && setDeleteTarget(null)}> + + + {t("integrationSites.deleteConfirmTitle")} + + {t("integrationSites.deleteConfirmDescription", { + code: deleteTarget?.code ?? "", + name: deleteTarget?.name ?? "", + })} + + + + + + + + + { diff --git a/src/modules/reports/reports-console.tsx b/src/modules/reports/reports-console.tsx index f96d45e..59e0004 100644 --- a/src/modules/reports/reports-console.tsx +++ b/src/modules/reports/reports-console.tsx @@ -75,6 +75,7 @@ import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog"; import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"; import { formatAdminInstant } from "@/lib/admin-datetime"; import { getAdminRequestLocale } from "@/lib/admin-locale"; +import { signedMoneyClass } from "@/lib/admin-signed-money"; import { cn } from "@/lib/utils"; import { formatAdminMinorUnits } from "@/lib/money"; import { LotteryApiBizError } from "@/types/api/errors"; @@ -461,6 +462,10 @@ function formatPlainMoney(value: number, currencyCode: string | null | undefined return formatAdminMinorUnits(value, currencyCode || "NPR"); } +function signedProfitCell(amount: number, currencyCode: string | null | undefined): string { + return cn("text-center tabular-nums", signedMoneyClass(amount, true)); +} + function formatUsagePercent(ratio: number | null | undefined): string { return ratio == null ? "-" : `${Math.round(ratio * 100)}%`; } @@ -906,6 +911,10 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa payload.items.reduce((sum, item) => sum - item.net_win_loss_minor, 0), "NPR", ), + tone: (() => { + const houseGross = payload.items.reduce((sum, item) => sum - item.net_win_loss_minor, 0); + return houseGross >= 0 ? "good" : "bad"; + })(), }, { label: t("preview.stats.players"), value: String(new Set(payload.items.map((item) => item.player_id)).size) }, ], @@ -1445,7 +1454,9 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa {summary.ticket_item_count} {formatPlainMoney(summary.total_bet_minor, summary.currency_code)} {formatPlainMoney(summary.total_payout_minor, summary.currency_code)} - {formatPlainMoney(summary.approx_house_gross_minor, summary.currency_code)} + + {formatPlainMoney(summary.approx_house_gross_minor, summary.currency_code)} + {summary.settlement_batches.length} {summary.settlement_batches.map((batch) => ( @@ -1530,7 +1541,9 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa - {formatPlainMoney(item.total_bet_minor, "NPR")} {formatPlainMoney(item.total_payout_minor, "NPR")} - {formatPlainMoney(item.approx_house_gross_minor, "NPR")} + + {formatPlainMoney(item.approx_house_gross_minor, "NPR")} + - - - @@ -1548,7 +1561,9 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa {formatPlainMoney(item.total_bet_minor, "NPR")} {formatPlainMoney(item.total_payout_minor, "NPR")} - {formatPlainMoney(item.net_win_loss_minor, "NPR")} + + {formatPlainMoney(item.net_win_loss_minor, "NPR")} + - - - @@ -1563,7 +1578,9 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa {item.dimension}D {formatPlainMoney(item.total_bet_minor, "NPR")} {formatPlainMoney(item.total_payout_minor, "NPR")} - {formatPlainMoney(item.approx_house_gross_minor, "NPR")} + + {formatPlainMoney(item.approx_house_gross_minor, "NPR")} + - - - diff --git a/src/modules/settlement/agent-settlement-report-view.tsx b/src/modules/settlement/agent-settlement-report-view.tsx index 080bcdb..2143f89 100644 --- a/src/modules/settlement/agent-settlement-report-view.tsx +++ b/src/modules/settlement/agent-settlement-report-view.tsx @@ -3,7 +3,9 @@ import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state"; import { useTranslation } from "react-i18next"; +import { SignedMoney } from "@/lib/admin-signed-money"; import { formatDashboardCreditMajor, formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics"; +import { formatSignedSettlementMoney } from "@/modules/settlement/settlement-signed-money"; import { Table, TableBody, @@ -165,15 +167,19 @@ export function AgentSettlementReportView({

); } - const stats = [ - { label: t("settlementReports.platformPnl.billNet", { defaultValue: "平台账单净额" }), value: money(root.platform_bill_net, currencyCode) }, + const stats: { label: string; amount: number; signed?: boolean }[] = [ + { + label: t("settlementReports.platformPnl.billNet", { defaultValue: "平台账单净额" }), + amount: Number(root.platform_bill_net ?? 0), + }, { label: t("settlementReports.platformPnl.rounding", { defaultValue: "尾差调整" }), - value: money(root.platform_rounding_adjustment, currencyCode), + amount: Number(root.platform_rounding_adjustment ?? 0), }, { label: t("settlementReports.platformPnl.shareProfit", { defaultValue: "占成利润(元数据)" }), - value: money(root.share_profit_meta, currencyCode), + amount: Number(root.share_profit_meta ?? 0), + signed: true, }, ]; return ( @@ -181,7 +187,15 @@ export function AgentSettlementReportView({ {stats.map((item) => (
{item.label}
-
{item.value}
+
+ {item.signed ? ( + + {formatSignedSettlementMoney(item.amount, currencyCode)} + + ) : ( + money(item.amount, currencyCode) + )} +
))} @@ -190,16 +204,16 @@ export function AgentSettlementReportView({ const items = asRows(root?.items ?? (reportType === "player_win_loss" || reportType === "agent_share" || reportType === "unpaid_bills" || reportType === "overdue" || reportType === "draw_period" ? data : null)); - const columnSets: Record = { + const columnSets: Record = { player_win_loss: [ { key: "username", header: t("settlementReports.columns.player", { defaultValue: "玩家" }) }, { key: "game_type", header: t("settlementReports.columns.gameType", { defaultValue: "玩法" }) }, - { key: "game_win_loss", header: t("settlementReports.columns.grossWinLoss", { defaultValue: "输赢" }), money: true }, + { key: "game_win_loss", header: t("settlementReports.columns.grossWinLoss", { defaultValue: "输赢" }), money: true, signed: true }, { key: "basic_rebate", header: t("settlementReports.columns.rebate", { defaultValue: "回水" }), money: true }, ], agent_share: [ { key: "agent_node_id", header: t("settlementReports.columns.agentId", { defaultValue: "代理 ID" }) }, - { key: "game_win_loss", header: t("settlementReports.columns.grossWinLoss", { defaultValue: "输赢" }), money: true }, + { key: "game_win_loss", header: t("settlementReports.columns.grossWinLoss", { defaultValue: "输赢" }), money: true, signed: true }, { key: "basic_rebate", header: t("settlementReports.columns.rebate", { defaultValue: "回水" }), money: true }, { key: "entry_count", header: t("settlementReports.columns.count", { defaultValue: "笔数" }) }, ], @@ -216,7 +230,7 @@ export function AgentSettlementReportView({ ], draw_period: [ { key: "draw_no", header: t("settlementReports.columns.drawNo", { defaultValue: "期号" }) }, - { key: "game_win_loss", header: t("settlementReports.columns.grossWinLoss", { defaultValue: "输赢" }), money: true }, + { key: "game_win_loss", header: t("settlementReports.columns.grossWinLoss", { defaultValue: "输赢" }), money: true, signed: true }, { key: "basic_rebate", header: t("settlementReports.columns.rebate", { defaultValue: "回水" }), money: true }, { key: "ticket_count", header: t("settlementReports.columns.count", { defaultValue: "笔数" }) }, ], @@ -238,7 +252,7 @@ function ReportTable({ currencyCode, }: { rows: Record[]; - columns: { key: string; header: string; money?: boolean; creditMajor?: boolean }[]; + columns: { key: string; header: string; money?: boolean; signed?: boolean; creditMajor?: boolean }[]; currencyCode: string; }): React.ReactElement { const { t } = useTranslation("common"); @@ -272,9 +286,15 @@ function ReportTable({ > {col.creditMajor ? creditMoney(row[col.key], currencyCode) - : col.money - ? money(row[col.key], currencyCode) - : String(row[col.key] ?? "—")} + : col.money && col.signed + ? ( + + {formatSignedSettlementMoney(Number(row[col.key] ?? 0), currencyCode)} + + ) + : col.money + ? money(row[col.key], currencyCode) + : String(row[col.key] ?? "—")} ))} diff --git a/src/modules/settlement/settlement-batch-details-console.tsx b/src/modules/settlement/settlement-batch-details-console.tsx index 12ef623..1f1b588 100644 --- a/src/modules/settlement/settlement-batch-details-console.tsx +++ b/src/modules/settlement/settlement-batch-details-console.tsx @@ -47,6 +47,7 @@ import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog"; import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"; import { adminHasAnyPermission } from "@/lib/admin-permissions"; import { formatAdminMinorUnits } from "@/lib/money"; +import { signedMoneyClass } from "@/lib/admin-signed-money"; import { cn } from "@/lib/utils"; import { PRD_PAYOUT_MANAGE, PRD_PAYOUT_REVIEW } from "@/lib/admin-prd"; import { useAdminProfile } from "@/stores/admin-session"; @@ -273,12 +274,7 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {

{t("platformProfit")}{" "} - + {formatAdminMinorUnits(summary.platform_profit, summary.currency_code ?? "NPR")}

diff --git a/src/modules/settlement/settlement-batches-console.tsx b/src/modules/settlement/settlement-batches-console.tsx index d19c1df..352145d 100644 --- a/src/modules/settlement/settlement-batches-console.tsx +++ b/src/modules/settlement/settlement-batches-console.tsx @@ -51,6 +51,7 @@ import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/c import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog"; import { adminHasAnyPermission } from "@/lib/admin-permissions"; import { formatAdminMinorUnits } from "@/lib/money"; +import { signedMoneyClass } from "@/lib/admin-signed-money"; import { cn } from "@/lib/utils"; import { PRD_PAYOUT_MANAGE, PRD_PAYOUT_REVIEW } from "@/lib/admin-prd"; import { useAdminProfile } from "@/stores/admin-session"; @@ -270,7 +271,7 @@ export function SettlementBatchesConsole() { {formatAdminMinorUnits(row.platform_profit, row.currency_code ?? "NPR")} diff --git a/src/modules/settlement/settlement-bills-table.tsx b/src/modules/settlement/settlement-bills-table.tsx index aef45ac..1a7a0f2 100644 --- a/src/modules/settlement/settlement-bills-table.tsx +++ b/src/modules/settlement/settlement-bills-table.tsx @@ -8,9 +8,12 @@ import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state import { AdminLoadingState } from "@/components/admin/admin-loading-state"; import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu"; import { AdminStatusBadge } from "@/components/admin/admin-status-badge"; +import { signedMoneyClass } from "@/lib/admin-signed-money"; import { cn } from "@/lib/utils"; import { formatSettlementPeriodSpan } from "@/lib/agent-settlement-period-range"; -import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics"; +import { + formatSignedSettlementMoney, +} from "@/modules/settlement/settlement-signed-money"; import { describeBillPaymentDirection, } from "@/modules/settlement/settlement-bill-display"; @@ -76,26 +79,6 @@ function billTypeTone(row: SettlementBillRow): string { return "border-border/70 bg-muted/25 text-muted-foreground"; } -function signedMoneyClass(amount: number, emphasize = false): string { - if (amount < 0) { - return cn("text-destructive", emphasize && "font-medium"); - } - if (amount > 0) { - return cn("text-emerald-700", emphasize && "font-medium"); - } - - return "text-muted-foreground"; -} - -function formatSignedMoney(amount: number, currencyCode: string): string { - if (amount === 0) { - return formatDashboardMoneyMinor(0, currencyCode); - } - - const prefix = amount < 0 ? "−" : "+"; - return `${prefix}${formatDashboardMoneyMinor(Math.abs(amount), currencyCode)}`; -} - function unpaidMoneyClass(row: SettlementBillRow): string { if (row.unpaid_amount <= 0) { return "text-muted-foreground"; @@ -253,7 +236,7 @@ export function SettlementBillsTable({ )} > {row.gross_win_loss != null ? ( -
{formatSignedMoney(row.gross_win_loss, currencyCode)}
+
{formatSignedSettlementMoney(row.gross_win_loss, currencyCode)}
) : ( "—" )} diff --git a/src/modules/settlement/settlement-center-shell.tsx b/src/modules/settlement/settlement-center-shell.tsx index e0e13e2..8504a04 100644 --- a/src/modules/settlement/settlement-center-shell.tsx +++ b/src/modules/settlement/settlement-center-shell.tsx @@ -10,6 +10,7 @@ import { toast } from "sonner"; import { getSettlementPeriods, type SettlementPeriodRow } from "@/api/admin-agent-settlement"; import { getAdminIntegrationSites } from "@/api/admin-integration-sites"; import { AdminLoadingState } from "@/components/admin/admin-loading-state"; +import { AdminNoIntegrationSiteState } from "@/components/admin/admin-no-integration-site-state"; import { AgentBillDetail } from "@/modules/settlement/agent-bill-detail"; import { SettlementCenterPeriodDetail } from "@/modules/settlement/settlement-center-period-detail"; import { @@ -284,7 +285,9 @@ export function SettlementCenterShell(): React.ReactElement { return (
- {siteId === null ? ( + {siteId === null && siteOptions.length === 0 && boundAgent === null ? ( + + ) : siteId === null ? (

{t("empty.noSite", { defaultValue: "请选择站点。" })}

) : !periodsReady ? ( diff --git a/src/modules/settlement/settlement-signed-money.ts b/src/modules/settlement/settlement-signed-money.ts index d965a4c..199e3ee 100644 --- a/src/modules/settlement/settlement-signed-money.ts +++ b/src/modules/settlement/settlement-signed-money.ts @@ -1,17 +1,6 @@ import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics"; -import { cn } from "@/lib/utils"; -/** 结算金额正负着色:负红、正绿、零灰 */ -export function signedSettlementMoneyClass(amount: number, emphasize = false): string { - if (amount < 0) { - return cn("text-destructive", emphasize && "font-medium"); - } - if (amount > 0) { - return cn("text-emerald-700 dark:text-emerald-400", emphasize && "font-medium"); - } - - return "text-muted-foreground"; -} +export { signedMoneyClass as signedSettlementMoneyClass } from "@/lib/admin-signed-money"; export function formatSignedSettlementMoney(amount: number, currencyCode: string): string { if (amount === 0) { diff --git a/src/types/api/admin-agent-line.ts b/src/types/api/admin-agent-line.ts index 0248ea2..1f6466d 100644 --- a/src/types/api/admin-agent-line.ts +++ b/src/types/api/admin-agent-line.ts @@ -1,6 +1,6 @@ export type AdminAgentLineProvisionPayload = { site_code: string; - code: string; + code?: string; name: string; username: string; password: string; diff --git a/src/types/api/admin-auth.ts b/src/types/api/admin-auth.ts index a36f477..b52727a 100644 --- a/src/types/api/admin-auth.ts +++ b/src/types/api/admin-auth.ts @@ -35,6 +35,8 @@ export type AdminProfile = { delegation_ceiling?: string[]; /** 平台账号可访问站点;代理账号为 undefined,见 agent.site_code */ accessible_sites?: { id: number; code: string; name: string }[]; + /** 站点管理员主站点上下文;代理/超管为 null */ + site?: { id: number; code: string; name: string } | null; }; /** `POST /api/v1/admin/auth/login` 成功信封内的 `data` */ diff --git a/src/types/api/admin-dashboard.ts b/src/types/api/admin-dashboard.ts index 2acba98..438a5e4 100644 --- a/src/types/api/admin-dashboard.ts +++ b/src/types/api/admin-dashboard.ts @@ -49,6 +49,36 @@ export type AdminDashboardCapabilities = { wallet_transfer_view: boolean; }; +/** 站点管理员首页摘要(`GET /api/v1/admin/dashboard` → `site_overview`) */ +export type AdminDashboardSiteOverview = { + admin_site_id: number; + site_code: string; + site_name: string; + agent_count: number; + player_count: number; + active_player_count_today: number; + bet_order_count_today: number; + today_bet_minor: number; + today_payout_minor: number; + today_profit_minor: number; + seven_day_bet_minor: number; + seven_day_payout_minor: number; + seven_day_profit_minor: number; + profit_scope?: "house_gross"; + currency_code: string | null; + pending_bill_count: number; + pending_unpaid_minor: number; + latest_bet_at: string | null; + top_agent_today: { + agent_node_id: number; + agent_code: string; + agent_name: string; + total_bet_minor: number; + total_payout_minor: number; + approx_house_gross_minor: number; + } | null; +}; + /** 代理经营账号首页摘要(`GET /api/v1/admin/dashboard` → `agent_overview`) */ export type AdminDashboardAgentOverview = { agent_node_id: number; @@ -147,4 +177,5 @@ export type AdminDashboardData = { warnings: AdminDashboardWarning[]; capabilities: AdminDashboardCapabilities; agent_overview: AdminDashboardAgentOverview | null; + site_overview: AdminDashboardSiteOverview | null; }; diff --git a/src/types/api/admin-integration-site.ts b/src/types/api/admin-integration-site.ts index db79c34..6c9bee4 100644 --- a/src/types/api/admin-integration-site.ts +++ b/src/types/api/admin-integration-site.ts @@ -12,6 +12,7 @@ export type AdminIntegrationSiteRow = { has_wallet_api_key: boolean; sso_secret_masked: string | null; wallet_api_key_masked: string | null; + is_default: boolean; updated_at: string | null; };