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
This commit is contained in:
2026-06-12 20:47:53 +08:00
parent 24fd7c10bd
commit 6ea0a6feec
48 changed files with 1573 additions and 629 deletions

View File

@@ -28,3 +28,8 @@ This version has breaking changes — APIs, conventions, and file structure may
- 玩家详情:按 `funding_mode` 切换 Tab信用流水 / 钱包流水;信用盘隐藏转账单) - 玩家详情:按 `funding_mode` 切换 Tab信用流水 / 钱包流水;信用盘隐藏转账单)
新增涉及玩家资金的页面时,先读 `src/lib/admin-player-display.ts` 新增涉及玩家资金的页面时,先读 `src/lib/admin-player-display.ts`
## Learned Workspace Facts
- 无接入站时依赖站点的页面展示 `<AdminNoIntegrationSiteState />`;仅 `profile.is_super_admin` 显示创建入口。
- 超管判定用登录态 `is_super_admin`,勿用站点角色或 `admin_user_site_roles` 绑定推断。

View File

@@ -35,6 +35,10 @@ export async function putAdminIntegrationSite(
return adminRequest.put<AdminIntegrationSiteDetail>(`${A}/integration-sites/${id}`, body); return adminRequest.put<AdminIntegrationSiteDetail>(`${A}/integration-sites/${id}`, body);
} }
export async function deleteAdminIntegrationSite(id: number): Promise<null> {
return adminRequest.delete<null>(`${A}/integration-sites/${id}`);
}
export async function postAdminIntegrationSiteRotateSecrets( export async function postAdminIntegrationSiteRotateSecrets(
id: number, id: number,
): Promise<AdminIntegrationSiteWithSecrets> { ): Promise<AdminIntegrationSiteWithSecrets> {

View File

@@ -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 { export const metadata: Metadata = buildPageMetadata("agents", "subnav.provision");
redirect("/admin/agents");
export default function AgentProvisionPage() {
return (
<ModuleScaffold embedded>
<AdminPermissionGate requiredAny={[...PRD_AGENT_LINE_PROVISION_ACCESS_ANY]}>
<AgentLineProvisionWizard />
</AdminPermissionGate>
</ModuleScaffold>
);
} }

View File

@@ -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 (
<AdminNoResourceState message={t("integrationSites.emptyPlatformHint")}>
{canCreate ? (
<Button nativeButton={false} size="sm" render={<Link href="/admin/config/integration-sites" />}>
{t("integrationSites.createSite")}
</Button>
) : null}
</AdminNoResourceState>
);
}

View File

@@ -202,7 +202,15 @@ function ChartTooltipContent({
.map((item, index) => { .map((item, index) => {
const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}` const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key) 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 ( return (
<div <div
@@ -212,56 +220,50 @@ function ChartTooltipContent({
indicator === "dot" && "items-center" indicator === "dot" && "items-center"
)} )}
> >
{formatter && item?.value !== undefined && item.name ? ( {itemConfig?.icon ? (
formatter(item.value, item.name, item, index, item.payload) <itemConfig.icon />
) : ( ) : (
<> !hideIndicator && (
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"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={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div <div
className={cn( className={cn("shrink-0 rounded-[2px]", {
"flex flex-1 justify-between leading-none", "h-2.5 w-2.5": indicator === "dot",
nestLabel ? "items-end" : "items-center" "w-1": indicator === "line",
)} "w-0 border-[1.5px] border-dashed bg-transparent":
> indicator === "dashed",
<div className="grid gap-1.5"> "my-0.5": nestLabel && indicator === "dashed",
{nestLabel ? tooltipLabel : null} })}
<span className="text-muted-foreground"> style={
{itemConfig?.label ?? item.name} indicator === "dashed"
</span> ? { borderColor: indicatorColor }
</div> : {
{item.value != null && ( backgroundColor: indicatorColor,
<span className="font-mono font-medium text-foreground tabular-nums"> borderColor: indicatorColor,
{typeof item.value === "number" }
? item.value.toLocaleString() }
: String(item.value)} />
</span> )
)}
</div>
</>
)} )}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label ?? item.name}
</span>
</div>
{item.value != null && (
<span className="font-mono font-medium text-foreground tabular-nums">
{formattedValue ??
(typeof item.value === "number"
? item.value.toLocaleString()
: String(item.value))}
</span>
)}
</div>
</div> </div>
) )
})} })}

View File

@@ -42,6 +42,7 @@ import neReconcile from "@/i18n/locales/ne/reconcile.json";
import neReports from "@/i18n/locales/ne/reports.json"; import neReports from "@/i18n/locales/ne/reports.json";
import neWallet from "@/i18n/locales/ne/wallet.json"; import neWallet from "@/i18n/locales/ne/wallet.json";
import neAgents from "@/i18n/locales/ne/agents.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 zhAudit from "@/i18n/locales/zh/audit.json";
import zhAdminUsers from "@/i18n/locales/zh/adminUsers.json"; import zhAdminUsers from "@/i18n/locales/zh/adminUsers.json";
import zhAuth from "@/i18n/locales/zh/auth.json"; import zhAuth from "@/i18n/locales/zh/auth.json";
@@ -103,7 +104,7 @@ const resources = {
settlement: neSettlement, settlement: neSettlement,
wallet: neWallet, wallet: neWallet,
agents: neAgents, agents: neAgents,
settlementCenter: enSettlementCenter, settlementCenter: neSettlementCenter,
}, },
zh: { zh: {
common: zhCommon, common: zhCommon,

View File

@@ -176,8 +176,7 @@
}, },
"lineProvision": { "lineProvision": {
"title": "Create level-1 agent", "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.", "description": "Creates the level-1 agent, admin login, and line settings (share, credit, rebate, settlement cycle) in one step.",
"code": "Agent code",
"name": "Level-1 agent name", "name": "Level-1 agent name",
"username": "Admin login", "username": "Admin login",
"password": "Initial password", "password": "Initial password",

View File

@@ -111,6 +111,10 @@
"loading": "Loading…", "loading": "Loading…",
"comingSoon": "Feature under development" "comingSoon": "Feature under development"
}, },
"integrationSites": {
"emptyPlatformHint": "No integration sites yet. Create a site before managing agents, players, or settlement.",
"createSite": "Create integration site"
},
"errors": { "errors": {
"loadFailed": "Failed to load" "loadFailed": "Failed to load"
}, },

View File

@@ -72,6 +72,13 @@
"rotateConfirmTitle": "Rotate secrets?", "rotateConfirmTitle": "Rotate secrets?",
"rotateConfirmDescription": "New SSO and wallet keys will be generated for {{code}}. Old keys stop working immediately.", "rotateConfirmDescription": "New SSO and wallet keys will be generated for {{code}}. Old keys stop working immediately.",
"rotateConfirm": "Rotate", "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", "secretsTitle": "Save these secrets now",
"secretsDescription": "Secrets for {{code}} are shown only once.", "secretsDescription": "Secrets for {{code}} are shown only once.",
"secretsDismiss": "I have saved them", "secretsDismiss": "I have saved them",

View File

@@ -156,9 +156,35 @@
"riskMonitor": "Risk monitor", "riskMonitor": "Risk monitor",
"systemSettings": "System settings" "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": { "agent": {
"title": "Operations overview", "title": "Operations overview",
"subtitle": "Your line scope · {{name}}", "subtitle": "{{name}} · your line",
"heroEyebrow": "Today's line cockpit", "heroEyebrow": "Today's line cockpit",
"heroTitle": "{{name}} live operations", "heroTitle": "{{name}} live operations",
"creditTitle": "Credit limit", "creditTitle": "Credit limit",
@@ -176,6 +202,7 @@
"teamPlayers": "Players in line", "teamPlayers": "Players in line",
"activePlayersToday": "Active players today", "activePlayersToday": "Active players today",
"betOrdersToday": "Bet orders today", "betOrdersToday": "Bet orders today",
"betOrdersTodayHint": "{{count}} orders today",
"todayBet": "Today's bet", "todayBet": "Today's bet",
"todayPayout": "Today's payout", "todayPayout": "Today's payout",
"todayProfit": "Today's profit", "todayProfit": "Today's profit",
@@ -202,6 +229,7 @@
"yes": "Yes", "yes": "Yes",
"no": "No", "no": "No",
"viewBills": "View bills", "viewBills": "View bills",
"lineMeta": "Depth {{depth}} · child agents {{childAgent}} · players {{player}}",
"viewLine": "Agent line", "viewLine": "Agent line",
"quickLinks": { "quickLinks": {
"tickets": "Tickets", "tickets": "Tickets",

View File

@@ -15,7 +15,8 @@
"closeDialogShare": "{{count}} ledger entries", "closeDialogShare": "{{count}} ledger entries",
"closeDialogUnsettled": "{{count}} tickets still unsettled", "closeDialogUnsettled": "{{count}} tickets still unsettled",
"closeDialogIrreversible": "Cannot undo. Use adjustments or reversals to fix errors.", "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": { "periodDetail": {
"back": "Back to periods", "back": "Back to periods",
@@ -152,7 +153,8 @@
"adjustment": "Adjustment", "adjustment": "Adjustment",
"reversal": "Reversal", "reversal": "Reversal",
"bad_debt": "Bad debt", "bad_debt": "Bad debt",
"share_ledger": "Share ledger" "share_ledger": "Share ledger",
"freezeAmount": "Hold {{amount}}"
} }
}, },
"columns": { "columns": {
@@ -176,7 +178,13 @@
"adjustmentType": "Type", "adjustmentType": "Type",
"originalBill": "Original bill", "originalBill": "Original bill",
"reason": "Reason", "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": { "billStatus": {
"pending_confirm": "Pending confirm", "pending_confirm": "Pending confirm",
@@ -302,21 +310,72 @@
"agent": "Agent bills", "agent": "Agent bills",
"pendingConfirm": "Pending confirm", "pendingConfirm": "Pending confirm",
"awaitingPayment": "Awaiting payment" "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": { "panels": {
"overview": { "title": "Overview" }, "overview": {
"ledger": { "title": "Account ledger" }, "title": "Overview"
"bills": { "title": "Bills" }, },
"creditLedger": { "title": "Credit ledger" }, "ledger": {
"playerBills": { "title": "Player bills" }, "title": "Account ledger"
"agentBills": { "title": "Agent bills" }, },
"pendingConfirm": { "title": "Pending confirm" }, "bills": {
"awaiting": { "title": "Awaiting payment" }, "title": "Bills"
"payments": { "title": "Payment log" }, },
"adjustments": { "title": "Adjust / reverse" }, "creditLedger": {
"reports": { "title": "Period reports" }, "title": "Credit ledger"
"badDebt": { "title": "Bad debt" } },
"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": { "empty": {
"noSite": "Select a site.", "noSite": "Select a site.",

View File

@@ -163,6 +163,8 @@
"account": "खाता सेटिङ", "account": "खाता सेटिङ",
"integration": "मुख्य साइट एकीकरण", "integration": "मुख्य साइट एकीकरण",
"agents": "एजेन्ट लाइन", "agents": "एजेन्ट लाइन",
"agent_list": "एजेन्ट सूची",
"settlement_center": "सेटलमेन्ट केन्द्र",
"config": "सञ्चालन कन्फिगरेसन" "config": "सञ्चालन कन्फिगरेसन"
}, },
"sidebar": { "sidebar": {

View File

@@ -153,6 +153,32 @@
"riskMonitor": "जोखिम निगरानी", "riskMonitor": "जोखिम निगरानी",
"systemSettings": "प्रणाली सेटिङ" "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": { "warnings": {
"drawPermission": "यो खातासँग ड्रअ/ड्यासबोर्ड हेर्ने अनुमति छैन। वित्तीय र जोखिम डाटा फिर्ता आएन।", "drawPermission": "यो खातासँग ड्रअ/ड्यासबोर्ड हेर्ने अनुमति छैन। वित्तीय र जोखिम डाटा फिर्ता आएन।",
"walletPermission": "यो खातासँग वालेट मिलान हेर्ने अनुमति छैन। असामान्य ट्रान्सफर संख्या फिर्ता आएन।", "walletPermission": "यो खातासँग वालेट मिलान हेर्ने अनुमति छैन। असामान्य ट्रान्सफर संख्या फिर्ता आएन।",

View File

@@ -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"
}
}

View File

@@ -301,15 +301,12 @@
}, },
"lineProvision": { "lineProvision": {
"title": "创建一级代理", "title": "创建一级代理",
"description": "将一级代理绑定到已有接入站点,并配置后台登录账号与占成、授信、回水、结算周期。代理编码创建后不可修改。", "description": "将一级代理绑定到已有接入站点,并配置后台登录账号与占成、授信、回水、结算周期。",
"siteCode": "接入站点", "siteCode": "接入站点",
"siteCodePlaceholder": "选择站点", "siteCodePlaceholder": "选择站点",
"siteRequired": "请选择接入站点", "siteRequired": "请选择接入站点",
"codeRequired": "请填写代理编码",
"codePatternInvalid": "代理编码仅支持字母、数字、下划线和中划线,且需以字母或数字开头",
"noUnboundSite": "暂无未绑定一级代理的站点", "noUnboundSite": "暂无未绑定一级代理的站点",
"openIntegrationSites": "前往接入站点", "openIntegrationSites": "前往接入站点",
"code": "代理编码",
"name": "一级代理名称", "name": "一级代理名称",
"username": "后台登录账号", "username": "后台登录账号",
"password": "初始密码", "password": "初始密码",

View File

@@ -111,6 +111,10 @@
"loading": "加载中…", "loading": "加载中…",
"comingSoon": "功能开发中" "comingSoon": "功能开发中"
}, },
"integrationSites": {
"emptyPlatformHint": "暂无接入站点。请先创建站点,再进行代理、玩家、结算等业务操作。",
"createSite": "去创建接入站点"
},
"errors": { "errors": {
"loadFailed": "加载失败" "loadFailed": "加载失败"
}, },

View File

@@ -72,6 +72,13 @@
"rotateConfirmTitle": "确认重置密钥?", "rotateConfirmTitle": "确认重置密钥?",
"rotateConfirmDescription": "将重新生成站点 {{code}} 的 SSO 与钱包密钥,旧密钥立即失效。", "rotateConfirmDescription": "将重新生成站点 {{code}} 的 SSO 与钱包密钥,旧密钥立即失效。",
"rotateConfirm": "确认重置", "rotateConfirm": "确认重置",
"delete": "删除站点",
"deleteSuccess": "已删除站点 {{code}}",
"deleteFailed": "删除站点失败",
"deleteConfirmTitle": "确认删除站点?",
"deleteConfirmDescription": "将永久删除站点 {{code}}{{name}})及其代理链、账期、玩家与站点后台账号,此操作不可恢复。",
"deleteConfirm": "确认删除",
"deleting": "删除中…",
"secretsTitle": "请妥善保存密钥", "secretsTitle": "请妥善保存密钥",
"secretsDescription": "站点 {{code}} 的密钥仅显示一次,关闭后无法再次查看完整内容。", "secretsDescription": "站点 {{code}} 的密钥仅显示一次,关闭后无法再次查看完整内容。",
"secretsDismiss": "我已保存", "secretsDismiss": "我已保存",

View File

@@ -156,9 +156,35 @@
"riskMonitor": "风控监控", "riskMonitor": "风控监控",
"systemSettings": "系统设置" "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": { "agent": {
"title": "经营概览", "title": "经营概览",
"subtitle": "本线路数据范围 · {{name}}", "subtitle": "{{name}} · 本线路",
"heroEyebrow": "今日经营驾驶舱", "heroEyebrow": "今日经营驾驶舱",
"heroTitle": "{{name}} 的线路动态", "heroTitle": "{{name}} 的线路动态",
"creditTitle": "授信额度", "creditTitle": "授信额度",
@@ -176,6 +202,7 @@
"teamPlayers": "线路玩家数", "teamPlayers": "线路玩家数",
"activePlayersToday": "今日活跃玩家", "activePlayersToday": "今日活跃玩家",
"betOrdersToday": "今日下注单数", "betOrdersToday": "今日下注单数",
"betOrdersTodayHint": "今日 {{count}} 单",
"todayBet": "今日下注", "todayBet": "今日下注",
"todayPayout": "今日派彩", "todayPayout": "今日派彩",
"todayProfit": "今日盈亏", "todayProfit": "今日盈亏",
@@ -202,6 +229,7 @@
"yes": "是", "yes": "是",
"no": "否", "no": "否",
"viewBills": "查看账单", "viewBills": "查看账单",
"lineMeta": "层级 {{depth}} · 可开下级 {{childAgent}} · 可开玩家 {{player}}",
"viewLine": "代理线路", "viewLine": "代理线路",
"quickLinks": { "quickLinks": {
"tickets": "注单查询", "tickets": "注单查询",

View File

@@ -159,7 +159,6 @@
"paid": "已收付", "paid": "已收付",
"unpaid": "未结", "unpaid": "未结",
"status": "状态", "status": "状态",
"billId": "账单 ID",
"payer": "付款方", "payer": "付款方",
"payee": "收款方", "payee": "收款方",
"amount": "金额", "amount": "金额",
@@ -282,7 +281,8 @@
"player": "玩家账单", "player": "玩家账单",
"agent": "代理账单", "agent": "代理账单",
"pendingConfirm": "待确认", "pendingConfirm": "待确认",
"awaitingPayment": "待收付" "awaitingPayment": "待收付",
"all": "全部"
}, },
"quickFilter": { "quickFilter": {
"title": "当前想看哪一层结算", "title": "当前想看哪一层结算",
@@ -318,22 +318,49 @@
}, },
"hierarchyHint": "同一账期会生成多笔账单:玩家先与直属代理结,代理扣除本级占成后再向上级缴纳。因此「输赢」可能相同,但「结算金额」会逐级减少。", "hierarchyHint": "同一账期会生成多笔账单:玩家先与直属代理结,代理扣除本级占成后再向上级缴纳。因此「输赢」可能相同,但「结算金额」会逐级减少。",
"emptyFiltered": "当前筛选下暂无账单,请改为「全部状态」或重置筛选。", "emptyFiltered": "当前筛选下暂无账单,请改为「全部状态」或重置筛选。",
"emptyClosed": "本期已关账但暂无账单。常见原因:账期内无信用盘玩家的已结算注单,或占成流水不在本账期时间范围内。" "emptyClosed": "本期已关账但暂无账单。常见原因:账期内无信用盘玩家的已结算注单,或占成流水不在本账期时间范围内。",
"intro": "关账后生成的占成账单。可按类型或状态筛选,详情内确认或登记收付。"
}, },
"panels": { "panels": {
"workbench": { "title": "工作台" }, "workbench": {
"overview": { "title": "结算概览" }, "title": "工作台"
"ledger": { "title": "账务流水" }, },
"bills": { "title": "全部账单" }, "overview": {
"creditLedger": { "title": "信用流水" }, "title": "结算概览"
"playerBills": { "title": "玩家账单" }, },
"agentBills": { "title": "代理账单" }, "ledger": {
"pendingConfirm": { "title": "待确认账单" }, "title": "账务流水"
"awaiting": { "title": "待收付账单" }, },
"payments": { "title": "收付记录" }, "bills": {
"adjustments": { "title": "调账 / 冲正" }, "title": "全部账单"
"reports": { "title": "账期报表" }, },
"badDebt": { "title": "坏账核销" } "creditLedger": {
"title": "信用流水"
},
"playerBills": {
"title": "玩家账单"
},
"agentBills": {
"title": "代理账单"
},
"pendingConfirm": {
"title": "待确认账单"
},
"awaiting": {
"title": "待收付账单"
},
"payments": {
"title": "收付记录"
},
"adjustments": {
"title": "调账 / 冲正"
},
"reports": {
"title": "账期报表"
},
"badDebt": {
"title": "坏账核销"
}
}, },
"empty": { "empty": {
"noSite": "请选择站点。", "noSite": "请选择站点。",
@@ -356,5 +383,20 @@
"loadAdjustments": "调账记录加载失败", "loadAdjustments": "调账记录加载失败",
"loadBadDebt": "坏账记录加载失败", "loadBadDebt": "坏账记录加载失败",
"loadCreditLedger": "信用流水加载失败" "loadCreditLedger": "信用流水加载失败"
},
"header": {
"subtitle": "信用盘结算",
"statusRunning": "账期进行中",
"statusIdle": "无进行中账期",
"statusCompleted": "账期已结清"
},
"subnav": {
"label": "结算中心导航"
},
"workbench": {
"viewPeriod": "账期",
"closePreset": "关账 · {{label}}",
"closeNoData": "关账失败:账期内无占成流水,请先完成信用盘开奖结算。",
"openPeriodPipeline": "开账 {{range}} · 占成流水 {{share}} 笔"
} }
} }

View File

@@ -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;
}

View File

@@ -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 (
<span className={cn(signedMoneyClass(amount, emphasize), "tabular-nums", className)}>
{children}
</span>
);
}
/** 报表 / 结算字段是否应按正负着色 */
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")
);
}

View File

@@ -1,10 +1,15 @@
import type { AdminRoleRow } from "@/types/api/index"; import type { AdminRoleRow } from "@/types/api/index";
export const PLATFORM_SUPER_ADMIN_SLUG = "super_admin"; export const PLATFORM_SUPER_ADMIN_SLUG = "super_admin";
export const PLATFORM_SITE_ADMIN_SLUG = "site_admin";
export const PLATFORM_AGENT_SLUG = "agent"; export const PLATFORM_AGENT_SLUG = "agent";
export function isPlatformFixedRole(role: Pick<AdminRoleRow, "slug">): boolean { export function isPlatformFixedRole(role: Pick<AdminRoleRow, "slug">): 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<AdminRoleRow, "slug">): boolean { export function isPlatformSuperAdminRole(role: Pick<AdminRoleRow, "slug">): boolean {

View File

@@ -27,11 +27,14 @@ import type { AdminAgentLineProvisionResult } from "@/types/api/admin-agent-line
type AgentLineProvisionWizardProps = { type AgentLineProvisionWizardProps = {
embedded?: boolean; embedded?: boolean;
/** 预选接入站点(如代理线路页当前选中的站点) */
defaultSiteCode?: string;
onSuccess?: (result: AdminAgentLineProvisionResult) => void | Promise<void>; onSuccess?: (result: AdminAgentLineProvisionResult) => void | Promise<void>;
}; };
export function AgentLineProvisionWizard({ export function AgentLineProvisionWizard({
embedded = false, embedded = false,
defaultSiteCode,
onSuccess, onSuccess,
}: AgentLineProvisionWizardProps): React.ReactElement { }: AgentLineProvisionWizardProps): React.ReactElement {
const { t } = useTranslation(["agents", "common"]); const { t } = useTranslation(["agents", "common"]);
@@ -40,7 +43,6 @@ export function AgentLineProvisionWizard({
const [sites, setSites] = useState<AdminIntegrationSiteRow[]>([]); const [sites, setSites] = useState<AdminIntegrationSiteRow[]>([]);
const [form, setForm] = useState({ const [form, setForm] = useState({
site_code: "", site_code: "",
code: "",
name: "", name: "",
username: "", username: "",
password: "", password: "",
@@ -64,24 +66,22 @@ export function AgentLineProvisionWizard({
[sites], [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<void> { async function onSubmit(e: React.FormEvent): Promise<void> {
e.preventDefault(); e.preventDefault();
if (!form.site_code.trim()) { if (!form.site_code.trim()) {
toast.error(t("agents:lineProvision.siteRequired", { defaultValue: "请选择接入站点" })); toast.error(t("agents:lineProvision.siteRequired", { defaultValue: "请选择接入站点" }));
return; 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()) { if (!form.name.trim()) {
toast.error(t("agents:nameRequired", { defaultValue: "请填写代理名称" })); toast.error(t("agents:nameRequired", { defaultValue: "请填写代理名称" }));
return; return;
@@ -152,7 +152,6 @@ export function AgentLineProvisionWizard({
try { try {
const result = await postAdminAgentLine({ const result = await postAdminAgentLine({
site_code: form.site_code.trim().toLowerCase(), site_code: form.site_code.trim().toLowerCase(),
code: form.code.trim().toLowerCase(),
name: form.name.trim(), name: form.name.trim(),
username: form.username.trim(), username: form.username.trim(),
password: form.password, password: form.password,
@@ -165,7 +164,6 @@ export function AgentLineProvisionWizard({
toast.success(t("agents:lineProvision.success", { defaultValue: "一级代理已创建" })); toast.success(t("agents:lineProvision.success", { defaultValue: "一级代理已创建" }));
setForm((f) => ({ setForm((f) => ({
site_code: "", site_code: "",
code: "",
name: "", name: "",
username: "", username: "",
password: "", password: "",
@@ -200,7 +198,7 @@ export function AgentLineProvisionWizard({
<p className="mb-4 max-w-xl text-sm text-muted-foreground"> <p className="mb-4 max-w-xl text-sm text-muted-foreground">
{t("agents:lineProvision.description", { {t("agents:lineProvision.description", {
defaultValue: defaultValue:
"将一级代理绑定到已有接入站点,并配置后台登录账号与占成、授信、回水。代理编码创建后不可修改。", "将一级代理绑定到已有接入站点,并配置后台登录账号与占成、授信、回水。",
})}{" "} })}{" "}
<Link <Link
href="/admin/config/integration-sites" href="/admin/config/integration-sites"
@@ -247,15 +245,6 @@ export function AgentLineProvisionWizard({
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="grid gap-2">
<Label>{t("agents:lineProvision.code", { defaultValue: "代理编码" })}</Label>
<Input
value={form.code}
onChange={(e) => setForm((f) => ({ ...f, code: e.target.value }))}
required
pattern="[a-z0-9][a-z0-9_-]*"
/>
</div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label>{t("agents:lineProvision.name", { defaultValue: "一级代理名称" })}</Label> <Label>{t("agents:lineProvision.name", { defaultValue: "一级代理名称" })}</Label>
<Input <Input

View File

@@ -17,9 +17,11 @@ import {
AgentLineDetailPanel, AgentLineDetailPanel,
type AgentDetailTab, type AgentDetailTab,
} from "@/modules/agents/agent-line-detail-panel"; } from "@/modules/agents/agent-line-detail-panel";
import { AgentLineProvisionWizard } from "@/modules/agents/agent-line-provision-wizard";
import { AgentLineSidebar } from "@/modules/agents/agent-line-sidebar"; import { AgentLineSidebar } from "@/modules/agents/agent-line-sidebar";
import { AgentProfileFields } from "@/modules/agents/agent-profile-fields"; import { AgentProfileFields } from "@/modules/agents/agent-profile-fields";
import { AdminLoadingState } from "@/components/admin/admin-loading-state"; import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { AdminNoIntegrationSiteState } from "@/components/admin/admin-no-integration-site-state";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@@ -47,6 +49,7 @@ import {
PRD_USERS_MANAGE, PRD_USERS_MANAGE,
} from "@/lib/admin-prd"; } from "@/lib/admin-prd";
import { useAdminSiteCodeOptions } from "@/hooks/use-admin-site-code-options"; import { useAdminSiteCodeOptions } from "@/hooks/use-admin-site-code-options";
import { isSiteAdminOperator } from "@/lib/admin-session-variants";
import { useAdminProfile } from "@/stores/admin-session"; import { useAdminProfile } from "@/stores/admin-session";
import { useAgentManagementSiteStore } from "@/stores/agent-management-site"; import { useAgentManagementSiteStore } from "@/stores/agent-management-site";
import type { AgentNodeRow, AgentParentCaps, AgentProfileRow } from "@/types/api/admin-agent"; import type { AgentNodeRow, AgentParentCaps, AgentProfileRow } from "@/types/api/admin-agent";
@@ -102,7 +105,7 @@ export function AgentsConsole(): React.ReactElement {
boundAgent === null && boundAgent === null &&
(isSuperAdmin || (isSuperAdmin ||
adminHasAnyPermission(profile?.permissions, [...PRD_AGENT_LINE_PROVISION_ACCESS_ANY])); adminHasAnyPermission(profile?.permissions, [...PRD_AGENT_LINE_PROVISION_ACCESS_ANY]));
const { sites: siteOptions } = useAdminSiteCodeOptions(); const { sites: siteOptions, loading: sitesLoading } = useAdminSiteCodeOptions();
const adminSiteId = useAgentManagementSiteStore((s) => s.adminSiteId); const adminSiteId = useAgentManagementSiteStore((s) => s.adminSiteId);
const setAdminSiteId = useAgentManagementSiteStore((s) => s.setAdminSiteId); const setAdminSiteId = useAgentManagementSiteStore((s) => s.setAdminSiteId);
const [tree, setTree] = useState<AgentNodeRow[]>([]); const [tree, setTree] = useState<AgentNodeRow[]>([]);
@@ -241,10 +244,17 @@ export function AgentsConsole(): React.ReactElement {
[flatNodes], [flatNodes],
); );
const visibleAgentRows = flatNodes; const visibleAgentRows = flatNodes;
const selectedSiteLabel = useMemo( const boundSite = profile?.site ?? null;
() => siteOptions.find((site) => site.id === adminSiteId)?.name ?? null, const selectedSiteLabel = useMemo(() => {
[adminSiteId, siteOptions], 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 activeSiteCode = useMemo(() => {
const fromAgent = boundAgent?.site_code?.trim(); const fromAgent = boundAgent?.site_code?.trim();
if (fromAgent) { if (fromAgent) {
@@ -254,8 +264,11 @@ export function AgentsConsole(): React.ReactElement {
if (fromSite) { if (fromSite) {
return fromSite; return fromSite;
} }
if (boundSite != null && boundSite.id === adminSiteId) {
return boundSite.code.trim();
}
return flatNodes.find((node) => node.depth === 0)?.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( const rootNode = useMemo(
() => flatNodes.find((node) => node.is_root || node.depth === 0) ?? null, () => flatNodes.find((node) => node.is_root || node.depth === 0) ?? null,
[flatNodes], [flatNodes],
@@ -319,11 +332,23 @@ export function AgentsConsole(): React.ReactElement {
setAdminSiteId(profile.agent.admin_site_id); setAdminSiteId(profile.agent.admin_site_id);
return; return;
} }
if (profile?.site?.id) {
setAdminSiteId(profile.site.id);
return;
}
if (siteOptions.length > 0 && isSuperAdmin) { if (siteOptions.length > 0 && isSuperAdmin) {
setAdminSiteId(siteOptions[0]?.id ?? null); 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(() => { useEffect(() => {
if (!Number.isInteger(selectedNodeIdFromUrl) || selectedNodeIdFromUrl <= 0) { 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 <AdminNoIntegrationSiteState canCreate={isSuperAdmin} />;
}
if (canViewAgents && loading && tree.length === 0 && adminSiteId !== null) {
return <AdminLoadingState label={t("listTitle", { defaultValue: "代理列表" })} />; return <AdminLoadingState label={t("listTitle", { defaultValue: "代理列表" })} />;
} }
const showSiteAdminAwaitingRoot =
!loading &&
flatNodes.length === 0 &&
!canProvisionLine &&
isSiteAdminOperator(profile);
if (showSiteAdminAwaitingRoot) {
return (
<div className="rounded-2xl border border-border/70 bg-card px-5 py-10 text-center shadow-sm">
<p className="text-sm font-medium text-foreground">
{t("lineUi.awaitingRootAgentTitle", {
defaultValue: "本站尚未开通一级代理",
})}
</p>
<p className="mx-auto mt-2 max-w-md text-sm text-muted-foreground">
{t("lineUi.awaitingRootAgentHint", {
defaultValue:
"一级代理需由平台超级管理员在「开通一级代理」中创建。开通后您可在此管理下级代理、占成与授信。",
})}
</p>
</div>
);
}
const showProvisionEmpty =
!loading && flatNodes.length === 0 && canProvisionLine;
if (showProvisionEmpty) {
return (
<div className="flex min-h-[32rem] flex-col gap-0">
<AgentLineProvisionWizard
embedded
defaultSiteCode={activeSiteCode}
onSuccess={async () => {
await loadTree(adminSiteId);
}}
/>
</div>
);
}
return ( return (
<div className="flex min-h-[32rem] flex-col gap-0"> <div className="flex min-h-[32rem] flex-col gap-0">
<ConfirmDialog /> <ConfirmDialog />
@@ -861,9 +936,13 @@ export function AgentsConsole(): React.ReactElement {
</div> </div>
) : ( ) : (
<div className="rounded-2xl border border-border/70 bg-card px-5 py-8 text-sm text-muted-foreground shadow-sm"> <div className="rounded-2xl border border-border/70 bg-card px-5 py-8 text-sm text-muted-foreground shadow-sm">
{t("lineUi.provisionOnlyHint", { {flatNodes.length === 0
defaultValue: "当前账号仅可开通一级代理线路,请前往「开通一级代理」页面创建新线路。", ? t("lineUi.noRootAgentHint", {
})} defaultValue: "该站点尚未开通一级代理,请联系平台管理员在「开通一级代理」中创建线路。",
})
: t("lineUi.provisionOnlyHint", {
defaultValue: "当前账号仅可开通一级代理线路,请前往「开通一级代理」页面创建新线路。",
})}
</div> </div>
)} )}

View File

@@ -12,6 +12,7 @@ import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge"; import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { formatAdminCreditMajorDecimal } from "@/lib/money";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -44,7 +45,7 @@ function formatCredit(value: number | null | undefined): string {
return "-"; 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 { function statusLabel(status: number, t: (key: string, options?: { defaultValue?: string }) => string): string {

View File

@@ -35,7 +35,7 @@ export function AgentsSubnav(): React.ReactElement {
adminHasAnyPermission(profile?.permissions, [...PRD_INTEGRATION_ACCESS_ANY]); adminHasAnyPermission(profile?.permissions, [...PRD_INTEGRATION_ACCESS_ANY]);
useEffect(() => { useEffect(() => {
if (adminSiteId !== null || siteOptions.length === 0) { if (adminSiteId !== null) {
return; return;
} }
const boundSiteId = profile?.agent?.admin_site_id; const boundSiteId = profile?.agent?.admin_site_id;
@@ -43,14 +43,26 @@ export function AgentsSubnav(): React.ReactElement {
setAdminSiteId(boundSiteId); setAdminSiteId(boundSiteId);
return; return;
} }
setAdminSiteId(siteOptions[0]?.id ?? null); if (profile?.site?.id) {
}, [adminSiteId, profile?.agent?.admin_site_id, setAdminSiteId, siteOptions]); 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 selectedSite = useMemo(() => {
const site = siteOptions.find((item) => item.id === selectSiteId); const site = siteOptions.find((item) => item.id === selectSiteId);
return site ?? null; if (site) {
}, [selectSiteId, siteOptions]); return site;
}
if (profile?.site != null && profile.site.id === selectSiteId) {
return profile.site;
}
return null;
}, [profile?.site, selectSiteId, siteOptions]);
const filteredSites = useMemo(() => { const filteredSites = useMemo(() => {
const normalized = deferredKeyword.trim().toLowerCase(); const normalized = deferredKeyword.trim().toLowerCase();
@@ -61,6 +73,16 @@ export function AgentsSubnav(): React.ReactElement {
return siteOptions.filter((site) => site.name.toLowerCase().includes(normalized)); return siteOptions.filter((site) => site.name.toLowerCase().includes(normalized));
}, [deferredKeyword, siteOptions]); }, [deferredKeyword, siteOptions]);
const siteReadOnlyLabel =
pathname !== "/admin/agents/list" &&
!canSwitchSite &&
selectedSite != null ? (
<div className="flex h-10 min-w-[200px] items-center justify-end gap-2 rounded-md border border-border/70 bg-background px-3 text-sm">
<span className="min-w-0 truncate font-medium text-foreground">{selectedSite.name}</span>
<span className="shrink-0 text-xs text-muted-foreground">{selectedSite.code}</span>
</div>
) : null;
const siteSelector = const siteSelector =
pathname !== "/admin/agents/list" && canSwitchSite && siteOptions.length > 0 && selectSiteId !== null ? ( pathname !== "/admin/agents/list" && canSwitchSite && siteOptions.length > 0 && selectSiteId !== null ? (
<Popover open={sitePickerOpen} onOpenChange={setSitePickerOpen}> <Popover open={sitePickerOpen} onOpenChange={setSitePickerOpen}>
@@ -129,7 +151,7 @@ export function AgentsSubnav(): React.ReactElement {
) : null; ) : null;
return ( return (
<AdminSubnavBar trailing={siteSelector}> <AdminSubnavBar trailing={siteSelector ?? siteReadOnlyLabel}>
<div className="pb-1"> <div className="pb-1">
<p className="text-sm font-medium text-foreground"> <p className="text-sm font-medium text-foreground">
{t("title", { defaultValue: "代理管理" })} {t("title", { defaultValue: "代理管理" })}

View File

@@ -1,20 +1,8 @@
"use client"; "use client";
import Link from "next/link";
import { useCallback, useMemo, useState, type ReactElement } from "react"; import { useCallback, useMemo, useState, type ReactElement } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import { BarChart3, RefreshCw, TrendingUp, Users, Wallet } from "lucide-react";
BarChart3,
Flame,
Landmark,
Network,
RefreshCw,
Sparkles,
Ticket,
TrendingUp,
Users,
Wallet,
} from "lucide-react";
import { getAdminDashboard } from "@/api/admin-dashboard"; import { getAdminDashboard } from "@/api/admin-dashboard";
import { useAsyncEffect } from "@/hooks/use-async-effect"; 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 { useTranslationRef } from "@/hooks/use-translation-ref";
import { useCachedPlayTypeOptions } from "@/hooks/use-cached-play-type-options"; import { useCachedPlayTypeOptions } from "@/hooks/use-cached-play-type-options";
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog"; 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 { normalizeAdminLanguage } from "@/i18n";
import { adminWeekdayKeyForDate, formatAdminCalendarToday } from "@/lib/admin-datetime"; import { adminWeekdayKeyForDate, formatAdminCalendarToday } from "@/lib/admin-datetime";
import { signedMoneyClass } from "@/lib/admin-signed-money";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useAdminProfile } from "@/stores/admin-session"; import { useAdminProfile } from "@/stores/admin-session";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; 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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { DashboardCurrentDrawCard } from "@/modules/dashboard/dashboard-current-draw-card"; import { DashboardCurrentDrawCard } from "@/modules/dashboard/dashboard-current-draw-card";
import { DashboardAnalyticsPanel } from "@/modules/dashboard/dashboard-analytics-panel"; import { DashboardAnalyticsPanel } from "@/modules/dashboard/dashboard-analytics-panel";
import { DashboardKpiCard } from "@/modules/dashboard/dashboard-visuals";
import { import {
formatDashboardCreditMajor, formatDashboardCreditMajor,
formatDashboardMoneyMinor, formatDashboardMoneyMinor,
@@ -49,14 +31,27 @@ import type { AdminDashboardAgentOverview } from "@/types/api/admin-dashboard";
import type { DrawCurrentSnapshot } from "@/types/api/public-draw"; import type { DrawCurrentSnapshot } from "@/types/api/public-draw";
import { LotteryApiBizError } from "@/types/api/errors"; import { LotteryApiBizError } from "@/types/api/errors";
function AgentMetric({
label,
value,
}: {
label: string;
value: string;
}): ReactElement {
return (
<div className="rounded-lg border bg-muted/30 px-3 py-2.5">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="mt-1 text-base font-semibold tabular-nums text-foreground">{value}</p>
</div>
);
}
export function AgentDashboardConsole(): ReactElement { export function AgentDashboardConsole(): ReactElement {
const { t, i18n } = useTranslation(["dashboard", "common", "agents"]); const { t, i18n } = useTranslation(["dashboard", "common", "agents"]);
const tRef = useTranslationRef(["dashboard", "common"]); const tRef = useTranslationRef(["dashboard", "common"]);
const formatDt = useAdminDateTimeFormatter(); const formatDt = useAdminDateTimeFormatter();
const profile = useAdminProfile(); const profile = useAdminProfile();
const agent = profile?.agent ?? null; const agent = profile?.agent ?? null;
const permissions = useMemo(() => profile?.permissions ?? [], [profile?.permissions]);
const todayLabel = useMemo(() => { const todayLabel = useMemo(() => {
const locale = normalizeAdminLanguage(i18n.resolvedLanguage ?? i18n.language); const locale = normalizeAdminLanguage(i18n.resolvedLanguage ?? i18n.language);
const weekday = t(`date.weekdays.${adminWeekdayKeyForDate()}`, { ns: "common" }); const weekday = t(`date.weekdays.${adminWeekdayKeyForDate()}`, { ns: "common" });
@@ -118,47 +113,6 @@ export function AgentDashboardConsole(): ReactElement {
const currency = "NPR"; const currency = "NPR";
const displayCurrency = overview?.currency_code ?? currency; 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: <Ticket className="size-4" />,
});
}
if (adminHasAnyPermission(permissions, [...PRD_PLAYERS_ACCESS_ANY])) {
links.push({
href: "/admin/players",
label: t("agent.quickLinks.players"),
icon: <Users className="size-4" />,
});
}
if (adminHasAnyPermission(permissions, [...PRD_REPORTS_VIEW_ACCESS_ANY])) {
links.push({
href: "/admin/reports",
label: t("agent.quickLinks.reports"),
icon: <BarChart3 className="size-4" />,
});
}
if (adminHasAnyPermission(permissions, [...PRD_AGENT_HUB_ACCESS_ANY])) {
links.push({
href: "/admin/agents",
label: t("agent.quickLinks.agents"),
icon: <Network className="size-4" />,
});
}
if (adminHasAnyPermission(permissions, [...PRD_SETTLEMENT_AGENT_ACCESS_ANY])) {
links.push({
href: "/admin/settlement-center",
label: t("agent.quickLinks.bills"),
icon: <Wallet className="size-4" />,
});
}
return links;
}, [permissions, t]);
return ( return (
<div className="flex min-w-0 w-full max-w-none flex-col gap-5"> <div className="flex min-w-0 w-full max-w-none flex-col gap-5">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
@@ -191,270 +145,134 @@ export function AgentDashboardConsole(): ReactElement {
) : null} ) : null}
{loading ? ( {loading ? (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4"> <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => ( {Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-28 rounded-xl" /> <Skeleton key={i} className="h-24 rounded-xl" />
))} ))}
</div> </div>
) : overview ? ( ) : overview ? (
<section className="space-y-4"> <section className="space-y-4">
<div className="grid min-w-0 grid-cols-1 gap-4 xl:grid-cols-[1.35fr_0.95fr]"> <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<Card className="overflow-hidden border-slate-200 bg-[radial-gradient(circle_at_top_left,_rgba(16,185,129,0.18),_transparent_42%),linear-gradient(135deg,_#0f172a,_#111827_55%,_#1f2937)] text-white shadow-[0_20px_60px_-25px_rgba(15,23,42,0.65)]"> <DashboardKpiCard
<CardHeader className="pb-3"> label={t("agent.todayBet")}
<div className="flex items-start justify-between gap-3"> value={formatDashboardMoneyMinor(overview.today_bet_minor, displayCurrency)}
<div> icon={<TrendingUp className="size-4" />}
<p className="text-xs uppercase tracking-[0.24em] text-emerald-200/80"> hint={
{t("agent.heroEyebrow")} overview.latest_bet_at
</p> ? t("agent.latestBetAt", { time: formatDt(overview.latest_bet_at) })
<CardTitle className="mt-2 text-2xl font-semibold"> : t("agent.noBetToday")
{t("agent.heroTitle", { name: overview.agent_name || overview.agent_code })} }
</CardTitle> />
</div> <DashboardKpiCard
<div className="rounded-full border border-white/15 bg-white/10 p-2"> label={t("agent.todayShareProfit")}
<Sparkles className="size-4 text-emerald-200" /> value={formatDashboardSignedMoneyMinor(overview.today_profit_minor, displayCurrency)}
</div> icon={<BarChart3 className="size-4" />}
</div> hint={t("agent.shareRate", { rate: overview.total_share_rate })}
valueClassName={signedMoneyClass(overview.today_profit_minor, true)}
/>
<DashboardKpiCard
label={t("agent.activePlayersToday")}
value={overview.active_player_count_today}
icon={<Users className="size-4" />}
hint={t("agent.betOrdersTodayHint", { count: overview.bet_order_count_today })}
/>
<DashboardKpiCard
label={t("agent.pendingBills")}
value={overview.pending_bill_count}
icon={<Wallet className="size-4" />}
hint={t("agent.pendingUnpaid", {
amount: formatDashboardMoneyMinor(overview.pending_unpaid_minor, displayCurrency),
})}
accent={overview.pending_bill_count > 0 ? "destructive" : "muted"}
/>
</div>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold">{t("agent.creditTitle")}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div>
<p className="text-2xl font-semibold tabular-nums">
{formatDashboardCreditMajor(overview.credit_limit, displayCurrency)}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{t("agent.creditAvailable", {
amount: formatDashboardCreditMajor(overview.available_credit, displayCurrency),
})}
</p>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<AgentMetric
label={t("agent.creditAllocatedLabel")}
value={formatDashboardCreditMajor(overview.allocated_credit, displayCurrency)}
/>
<AgentMetric
label={t("agent.creditUsedLabel")}
value={formatDashboardCreditMajor(overview.used_credit, displayCurrency)}
/>
<AgentMetric
label={t("agent.pendingBills")}
value={String(overview.pending_bill_count)}
/>
</div>
<p className="text-xs text-muted-foreground">
{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"),
})}
</p>
</CardContent>
</Card>
<div className="grid gap-3 md:grid-cols-2">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">{t("agent.sevenDayTitle")}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-2 text-sm">
<div className="grid gap-3 sm:grid-cols-3"> <div className="flex items-center justify-between gap-3">
<div className="rounded-2xl border border-white/10 bg-white/8 p-4 backdrop-blur"> <span className="text-muted-foreground">{t("agent.todayBet")}</span>
<p className="text-xs text-slate-300">{t("agent.todayBet")}</p> <span className="font-semibold tabular-nums">
<p className="mt-2 text-2xl font-semibold tabular-nums"> {formatDashboardMoneyMinor(overview.seven_day_bet_minor, displayCurrency)}
{formatDashboardMoneyMinor(overview.today_bet_minor, displayCurrency)} </span>
</p>
</div>
<div className="rounded-2xl border border-white/10 bg-white/8 p-4 backdrop-blur">
<p className="text-xs text-slate-300">{t("agent.todayPayout")}</p>
<p className="mt-2 text-2xl font-semibold tabular-nums">
{formatDashboardMoneyMinor(overview.today_payout_minor, displayCurrency)}
</p>
</div>
<div className="rounded-2xl border border-white/10 bg-white/8 p-4 backdrop-blur">
<p className="text-xs text-slate-300">{t("agent.todayShareProfit")}</p>
<p className="mt-2 text-2xl font-semibold tabular-nums">
{formatDashboardSignedMoneyMinor(overview.today_profit_minor, displayCurrency)}
</p>
</div>
</div> </div>
<div className="grid gap-3 sm:grid-cols-3"> <div className="flex items-center justify-between gap-3">
<div className="rounded-xl border border-white/10 px-4 py-3"> <span className="text-muted-foreground">{t("agent.todayShareProfit")}</span>
<p className="text-[11px] text-slate-300">{t("agent.activePlayersToday")}</p> <span
<p className="mt-1 text-lg font-semibold tabular-nums">{overview.active_player_count_today}</p> className={cn(
</div> "font-semibold tabular-nums",
<div className="rounded-xl border border-white/10 px-4 py-3"> signedMoneyClass(overview.seven_day_profit_minor, true),
<p className="text-[11px] text-slate-300">{t("agent.betOrdersToday")}</p> )}
<p className="mt-1 text-lg font-semibold tabular-nums">{overview.bet_order_count_today}</p> >
</div> {formatDashboardSignedMoneyMinor(overview.seven_day_profit_minor, displayCurrency)}
<div className="rounded-xl border border-white/10 px-4 py-3">
<p className="text-[11px] text-slate-300">{t("agent.pendingBills")}</p>
<p className="mt-1 text-lg font-semibold tabular-nums">{overview.pending_bill_count}</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-slate-300">
<span>{t("agent.shareRate", { rate: overview.total_share_rate })}</span>
<span>
{overview.latest_bet_at
? t("agent.latestBetAt", { time: formatDt(overview.latest_bet_at) })
: t("agent.noBetToday")}
</span> </span>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<Card className="border-slate-200 bg-[linear-gradient(180deg,_rgba(248,250,252,0.98),_rgba(241,245,249,0.92))]">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold">{t("agent.creditTitle")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<p className="text-3xl font-semibold tabular-nums text-slate-900">
{formatDashboardCreditMajor(overview.credit_limit, displayCurrency)}
</p>
<p className="mt-1 text-xs text-slate-500">
{t("agent.creditAvailable", {
amount: formatDashboardCreditMajor(overview.available_credit, displayCurrency),
})}
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-2xl border bg-white px-4 py-3">
<p className="text-xs text-slate-500">{t("agent.creditAllocatedLabel")}</p>
<p className="mt-1 text-lg font-semibold tabular-nums">
{formatDashboardCreditMajor(overview.allocated_credit, displayCurrency)}
</p>
</div>
<div className="rounded-2xl border bg-white px-4 py-3">
<p className="text-xs text-slate-500">{t("agent.creditUsedLabel")}</p>
<p className="mt-1 text-lg font-semibold tabular-nums">
{formatDashboardCreditMajor(overview.used_credit, displayCurrency)}
</p>
</div>
</div>
<p className="text-xs text-slate-500">
{t("agent.pendingUnpaid", {
amount: formatDashboardMoneyMinor(overview.pending_unpaid_minor, displayCurrency),
})}
</p>
</CardContent>
</Card>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<Card> <Card>
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-semibold"> <CardTitle className="text-sm font-semibold">{t("agent.teamTitle")}</CardTitle>
<TrendingUp className="size-4 text-emerald-600" />
{t("agent.sevenDayTitle")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-1 text-sm">
<p className="text-xl font-semibold tabular-nums">
{formatDashboardMoneyMinor(overview.seven_day_bet_minor, displayCurrency)}
</p>
<p className="text-xs text-muted-foreground">
{t("agent.sevenDayPayout", {
amount: formatDashboardMoneyMinor(overview.seven_day_payout_minor, displayCurrency),
})}
</p>
<p className="text-xs text-muted-foreground">
{t("agent.sevenDayShareProfit", {
amount: formatDashboardSignedMoneyMinor(overview.seven_day_profit_minor, displayCurrency),
})}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<Users className="size-4 text-sky-600" />
{t("agent.teamTitle")}
</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="grid grid-cols-2 gap-3 text-sm"> <CardContent className="grid grid-cols-2 gap-3 text-sm">
<div> <AgentMetric
<p className="text-xs text-muted-foreground">{t("agent.directChildren")}</p> label={t("agent.directChildren")}
<p className="text-xl font-semibold tabular-nums">{overview.direct_child_count}</p> value={String(overview.direct_child_count)}
</div> />
<div> <AgentMetric
<p className="text-xs text-muted-foreground">{t("agent.subtreeAgents")}</p> label={t("agent.directPlayers")}
<p className="text-xl font-semibold tabular-nums">{overview.subtree_agent_count}</p> value={String(overview.direct_player_count)}
</div> />
<div> <AgentMetric
<p className="text-xs text-muted-foreground">{t("agent.directPlayers")}</p> label={t("agent.subtreeAgents")}
<p className="text-xl font-semibold tabular-nums">{overview.direct_player_count}</p> value={String(overview.subtree_agent_count)}
</div> />
<div> <AgentMetric
<p className="text-xs text-muted-foreground">{t("agent.teamPlayers")}</p> label={t("agent.teamPlayers")}
<p className="text-xl font-semibold tabular-nums">{overview.team_player_count}</p> value={String(overview.team_player_count)}
</div> />
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<Wallet className="size-4 text-amber-600" />
{t("agent.pendingBills")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-1">
<p className="text-2xl font-semibold tabular-nums">{overview.pending_bill_count}</p>
<p className="text-xs text-muted-foreground">
{t("agent.pendingUnpaid", {
amount: formatDashboardMoneyMinor(overview.pending_unpaid_minor, displayCurrency),
})}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<Flame className="size-4 text-rose-600" />
{t("agent.topMomentum")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-1">
{overview.top_agent_today ? (
<>
<p className="text-sm font-semibold text-slate-900">
{overview.top_agent_today.agent_name || overview.top_agent_today.agent_code}
</p>
<p className="text-xl font-semibold tabular-nums">
{formatDashboardMoneyMinor(overview.top_agent_today.total_bet_minor, displayCurrency)}
</p>
<p className="text-xs text-muted-foreground">
{t("agent.topMomentumPayout", {
amount: formatDashboardMoneyMinor(
overview.top_agent_today.total_payout_minor,
displayCurrency,
),
})}
</p>
</>
) : (
<p className="text-sm text-muted-foreground">{t("agent.noBetToday")}</p>
)}
</CardContent>
</Card>
</div>
<div className="grid gap-4 xl:grid-cols-[1.15fr_0.85fr]">
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
<Landmark className="size-4 text-slate-700" />
{t("agent.managementFocus")}
</CardTitle>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-3">
<div className="rounded-2xl border bg-slate-50 px-4 py-3">
<p className="text-xs text-slate-500">{t("agent.focusBet")}</p>
<p className="mt-1 text-lg font-semibold tabular-nums">
{formatDashboardMoneyMinor(overview.today_bet_minor, displayCurrency)}
</p>
</div>
<div className="rounded-2xl border bg-slate-50 px-4 py-3">
<p className="text-xs text-slate-500">{t("agent.focusPlayers")}</p>
<p className="mt-1 text-lg font-semibold tabular-nums">{overview.active_player_count_today}</p>
</div>
<div className="rounded-2xl border bg-slate-50 px-4 py-3">
<p className="text-xs text-slate-500">{t("agent.focusBills")}</p>
<p className="mt-1 text-lg font-semibold tabular-nums">{overview.pending_bill_count}</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">{t("agent.quickStatsTitle")}</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">{t("agent.canCreateChildAgent")}</span>
<span className="font-medium">
{overview.can_create_child_agent ? t("agent.yes") : t("agent.no")}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">{t("agent.canCreatePlayer")}</span>
<span className="font-medium">
{overview.can_create_player ? t("agent.yes") : t("agent.no")}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">{t("agent.lineDepth")}</span>
<span className="font-medium tabular-nums">{overview.depth}</span>
</div>
{adminHasAnyPermission(permissions, [...PRD_SETTLEMENT_AGENT_ACCESS_ANY]) ? (
<Link
href="/admin/settlement-center"
className={cn(buttonVariants({ variant: "link", size: "sm" }), "h-auto px-0")}
>
{t("agent.viewBills")}
</Link>
) : null}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@@ -480,21 +298,6 @@ export function AgentDashboardConsole(): ReactElement {
<AlertDescription>{t("warnings.drawPermission")}</AlertDescription> <AlertDescription>{t("warnings.drawPermission")}</AlertDescription>
</Alert> </Alert>
)} )}
{quickLinks.length > 0 ? (
<section className="flex flex-wrap gap-2">
{quickLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-8 gap-1.5")}
>
{link.icon}
{link.label}
</Link>
))}
</section>
) : null}
</div> </div>
); );
} }

View File

@@ -21,6 +21,7 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { getAdminRequestLocale } from "@/lib/admin-locale"; import { getAdminRequestLocale } from "@/lib/admin-locale";
import { signedMoneyClass } from "@/lib/admin-signed-money";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { DashboardKpiCard } from "@/modules/dashboard/dashboard-visuals"; import { DashboardKpiCard } from "@/modules/dashboard/dashboard-visuals";
import { DASHBOARD_CHART_COLORS } from "@/modules/dashboard/dashboard-chart-config"; import { DASHBOARD_CHART_COLORS } from "@/modules/dashboard/dashboard-chart-config";
@@ -241,6 +242,7 @@ export function DashboardAnalyticsMain({ analytics }: { analytics: DashboardAnal
: t("analytics.summaryProfit") : t("analytics.summaryProfit")
} }
value={formatSignedMoney(summary.approx_house_gross_minor, currency)} value={formatSignedMoney(summary.approx_house_gross_minor, currency)}
valueClassName={signedMoneyClass(summary.approx_house_gross_minor, true)}
hint={ hint={
profitScope === "share_profit" profitScope === "share_profit"
? t("analytics.shareProfitHint") ? t("analytics.shareProfitHint")
@@ -452,7 +454,14 @@ export function DashboardAgentRankingCard({
) : null} ) : null}
</div> </div>
</div> </div>
<div className="shrink-0 text-right text-xs font-semibold tabular-nums"> <div
className={cn(
"shrink-0 text-right text-xs font-semibold tabular-nums",
rankingMetric === "profit"
? signedMoneyClass(row.approx_house_gross_minor, true)
: undefined,
)}
>
{formatRowValue(row)} {formatRowValue(row)}
</div> </div>
</div> </div>

View File

@@ -2,22 +2,9 @@
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import Link from "next/link"; 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 { useTranslation } from "react-i18next";
import { import { AlertTriangle, ClipboardList, RefreshCw, Shield, Wallet } from "lucide-react";
AlertTriangle,
ClipboardList,
Diamond,
FileSearch,
RefreshCw,
ScrollText,
Settings,
Shield,
Ticket,
Wallet,
BarChart3,
Scale,
} from "lucide-react";
import { getAdminDashboardByScope } from "@/api/admin-dashboard"; import { getAdminDashboardByScope } from "@/api/admin-dashboard";
import { useAdminPlayTypeCatalog } from "@/hooks/use-admin-play-type-catalog"; import { useAdminPlayTypeCatalog } from "@/hooks/use-admin-play-type-catalog";
@@ -275,23 +262,6 @@ export function DashboardConsole(): ReactElement {
}); });
const showAnalytics = canFinance; const showAnalytics = canFinance;
const quickLinks: { href: string; label: string; icon: ReactNode }[] = [
{ href: "/admin/draws", label: t("quickLinks.createDrawPlan"), icon: <Diamond className="size-4" /> },
{ href: "/admin/draws", label: t("quickLinks.drawSchedule"), icon: <Ticket className="size-4" /> },
{
href: drawId != null ? `/admin/draws/${drawId}/results` : "/admin/draws",
label: t("quickLinks.results"),
icon: <FileSearch className="size-4" />,
},
{ href: "/admin/tickets", label: t("quickLinks.tickets"), icon: <Shield className="size-4" /> },
{ href: "/admin/wallet/transactions", label: t("quickLinks.walletTransactions"), icon: <Wallet className="size-4" /> },
{ href: "/admin/audit-logs", label: t("quickLinks.auditLogs"), icon: <ScrollText className="size-4" /> },
{ href: "/admin/reports", label: t("quickLinks.reports"), icon: <BarChart3 className="size-4" /> },
{ href: "/admin/rules/odds", label: t("quickLinks.payoutRules"), icon: <Scale className="size-4" /> },
{ href: "/admin/risk", label: t("quickLinks.riskMonitor"), icon: <Shield className="size-4" /> },
{ href: "/admin/settings", label: t("quickLinks.systemSettings"), icon: <Settings className="size-4" /> },
];
return ( return (
<div className="flex min-w-0 w-full max-w-none flex-col gap-5"> <div className="flex min-w-0 w-full max-w-none flex-col gap-5">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
@@ -518,42 +488,20 @@ export function DashboardConsole(): ReactElement {
</div> </div>
{!showAnalytics ? ( {!showAnalytics ? (
<div className="grid min-w-0 grid-cols-1 gap-4 md:grid-cols-2"> <Card className="admin-list-card min-w-0 py-0">
<Card className="admin-list-card min-w-0 py-0"> <CardHeader className="border-b border-border/60 px-4 py-3 pb-0">
<CardHeader className="border-b border-border/60 px-4 py-3 pb-0"> <CardTitle className="text-sm font-semibold">{t("financeStructure")}</CardTitle>
<CardTitle className="text-sm font-semibold">{t("financeStructure")}</CardTitle> </CardHeader>
</CardHeader> <CardContent className="px-4 py-4">
<CardContent className="px-4 py-4"> {loading ? (
{loading ? ( <Skeleton className="h-52 w-full" />
<Skeleton className="h-52 w-full" /> ) : finance ? (
) : finance ? ( <FinanceStructureChart finance={finance} formatMoney={formatMoneyMinor} />
<FinanceStructureChart finance={finance} formatMoney={formatMoneyMinor} /> ) : (
) : ( <AdminNoResourceState className="py-10 text-center text-xs text-muted-foreground" />
<AdminNoResourceState className="py-10 text-center text-xs text-muted-foreground" /> )}
)} </CardContent>
</CardContent> </Card>
</Card>
<Card className="admin-list-card min-w-0 py-0">
<CardHeader className="border-b border-border/60 px-4 py-3 pb-0">
<CardTitle className="text-sm font-semibold">{t("quickLinksTitle")}</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-2 px-4 py-4 sm:grid-cols-3">
{quickLinks.map((q) => (
<Link
key={q.href + q.label}
href={q.href}
className="flex min-w-0 flex-col items-center gap-2 rounded-lg border border-border/70 bg-muted/10 px-2 py-3 text-center text-[11px] font-medium leading-snug text-foreground transition-colors hover:border-primary/30 hover:bg-muted/30"
>
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-card text-primary shadow-sm">
{q.icon}
</span>
<span className="line-clamp-2">{q.label}</span>
</Link>
))}
</CardContent>
</Card>
</div>
) : null} ) : null}
</div> </div>
@@ -576,26 +524,6 @@ export function DashboardConsole(): ReactElement {
)} )}
</CardContent> </CardContent>
</Card> </Card>
<Card className="admin-list-card py-0">
<CardHeader className="border-b border-border/60 px-4 py-3 pb-0">
<CardTitle className="text-sm font-semibold">{t("quickLinksTitle")}</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-2 px-4 py-4 sm:grid-cols-3">
{quickLinks.map((q) => (
<Link
key={q.href + q.label}
href={q.href}
className="flex min-w-0 flex-col items-center gap-2 rounded-lg border border-border/70 bg-muted/10 px-2 py-3 text-center text-[11px] font-medium leading-snug text-foreground transition-colors hover:border-primary/30 hover:bg-muted/30"
>
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-card text-primary shadow-sm">
{q.icon}
</span>
<span className="line-clamp-2">{q.label}</span>
</Link>
))}
</CardContent>
</Card>
</aside> </aside>
) : null} ) : null}
</section> </section>

View File

@@ -2,19 +2,23 @@
import type { ReactElement } from "react"; import type { ReactElement } from "react";
import { isAgentOperator, isSiteAdminOperator } from "@/lib/admin-session-variants";
import { AgentDashboardConsole } from "@/modules/dashboard/agent-dashboard-console"; import { AgentDashboardConsole } from "@/modules/dashboard/agent-dashboard-console";
import { DashboardConsole } from "@/modules/dashboard/dashboard-console"; import { DashboardConsole } from "@/modules/dashboard/dashboard-console";
import { SiteDashboardConsole } from "@/modules/dashboard/site-dashboard-console";
import { useAdminProfile } from "@/stores/admin-session"; import { useAdminProfile } from "@/stores/admin-session";
/** 平台账号走全站仪表盘;绑定代理节点的经营账号走代理仪表盘。 */ /** 超管/平台账号走全站仪表盘;站点管理员走站点仪表盘;代理经营账号走代理仪表盘。 */
export function DashboardPageClient(): ReactElement { export function DashboardPageClient(): ReactElement {
const profile = useAdminProfile(); const profile = useAdminProfile();
const isAgentOperator =
profile?.agent != null && profile.is_super_admin !== true;
if (isAgentOperator) { if (isAgentOperator(profile)) {
return <AgentDashboardConsole />; return <AgentDashboardConsole />;
} }
if (isSiteAdminOperator(profile)) {
return <SiteDashboardConsole />;
}
return <DashboardConsole />; return <DashboardConsole />;
} }

View File

@@ -12,6 +12,8 @@ import {
ChartTooltip, ChartTooltip,
ChartTooltipContent, ChartTooltipContent,
} from "@/components/ui/chart"; } 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 { buildTrendChartConfig, DASHBOARD_CHART_COLORS } from "@/modules/dashboard/dashboard-chart-config";
import { DashboardChartEmpty } from "@/modules/dashboard/dashboard-chart-empty"; import { DashboardChartEmpty } from "@/modules/dashboard/dashboard-chart-empty";
import type { AdminDashboardAnalyticsPlayRow } from "@/types/api/admin-dashboard-analytics"; import type { AdminDashboardAnalyticsPlayRow } from "@/types/api/admin-dashboard-analytics";
@@ -332,7 +334,14 @@ export function PeriodCompareStrip({
<span className="text-sm font-medium text-foreground">{row.label}</span> <span className="text-sm font-medium text-foreground">{row.label}</span>
<span className="text-xs tabular-nums text-muted-foreground">{row.pctText}</span> <span className="text-xs tabular-nums text-muted-foreground">{row.pctText}</span>
</div> </div>
<div className="mb-1 text-sm font-semibold tabular-nums">{formatMoney(row.value, currency)}</div> <div
className={cn(
"mb-1 text-sm font-semibold tabular-nums",
row.key === "profit" ? signedMoneyClass(row.value, true) : undefined,
)}
>
{formatMoney(row.value, currency)}
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted"> <div className="h-2 overflow-hidden rounded-full bg-muted">
<div <div
className="h-full rounded-full transition-[width] duration-500" className="h-full rounded-full transition-[width] duration-500"

View File

@@ -186,6 +186,7 @@ export function DashboardKpiCard({
hint, hint,
icon, icon,
accent = "primary", accent = "primary",
valueClassName,
sparklineValues, sparklineValues,
deltaLabel, deltaLabel,
}: { }: {
@@ -194,6 +195,8 @@ export function DashboardKpiCard({
hint?: ReactNode; hint?: ReactNode;
icon: ReactNode; icon: ReactNode;
accent?: DashboardKpiAccent; accent?: DashboardKpiAccent;
/** 覆盖主数值颜色(如盈亏红绿) */
valueClassName?: string;
sparklineValues?: number[]; sparklineValues?: number[];
deltaLabel?: ReactNode; deltaLabel?: ReactNode;
}): ReactElement { }): ReactElement {
@@ -210,7 +213,12 @@ export function DashboardKpiCard({
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-xs font-medium text-muted-foreground">{label}</p> <p className="text-xs font-medium text-muted-foreground">{label}</p>
<p className="mt-1 truncate text-xl font-bold tabular-nums tracking-tight text-foreground"> <p
className={cn(
"mt-1 truncate text-xl font-bold tabular-nums tracking-tight",
valueClassName ?? "text-foreground",
)}
>
{value} {value}
</p> </p>
{deltaLabel ? <div className="mt-1 text-xs font-medium tabular-nums">{deltaLabel}</div> : null} {deltaLabel ? <div className="mt-1 text-xs font-medium tabular-nums">{deltaLabel}</div> : null}

View File

@@ -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 (
<div className="rounded-lg border bg-muted/30 px-3 py-2.5">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="mt-1 text-base font-semibold tabular-nums text-foreground">{value}</p>
</div>
);
}
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<string | null>(null);
const [hall, setHall] = useState<DrawCurrentSnapshot | null>(null);
const [drawId, setDrawId] = useState<number | null>(null);
const [overview, setOverview] = useState<AdminDashboardSiteOverview | null>(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 (
<div className="flex min-w-0 w-full max-w-none flex-col gap-5">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<h1 className="admin-list-title">{t("site.title")}</h1>
<p className="mt-0.5 text-xs text-muted-foreground">
{site
? t("site.subtitle", { name: site.name || site.code })
: todayLabel}
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="h-8"
disabled={loading || refreshing}
onClick={() => void load(true)}
>
<RefreshCw className={cn("size-3.5", refreshing && "animate-spin")} />
{t("actions.refresh", { ns: "common" })}
</Button>
</div>
{error ? (
<Alert className="border-amber-200 bg-amber-50 dark:border-amber-900/60 dark:bg-amber-950/30">
<AlertTitle>{t("notice")}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
{loading ? (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-24 rounded-xl" />
))}
</div>
) : overview ? (
<section className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<DashboardKpiCard
label={t("site.todayBet")}
value={formatDashboardMoneyMinor(overview.today_bet_minor, displayCurrency)}
icon={<TrendingUp className="size-4" />}
hint={
overview.latest_bet_at
? t("site.latestBetAt", { time: formatDt(overview.latest_bet_at) })
: t("site.noBetToday")
}
/>
<DashboardKpiCard
label={t("site.todayProfit")}
value={formatDashboardSignedMoneyMinor(overview.today_profit_minor, displayCurrency)}
icon={<BarChart3 className="size-4" />}
hint={t("site.profitScopeHint")}
valueClassName={signedMoneyClass(overview.today_profit_minor, true)}
/>
<DashboardKpiCard
label={t("site.activePlayersToday")}
value={overview.active_player_count_today}
icon={<Users className="size-4" />}
hint={t("site.betOrdersTodayHint", { count: overview.bet_order_count_today })}
/>
<DashboardKpiCard
label={t("site.pendingBills")}
value={overview.pending_bill_count}
icon={<Wallet className="size-4" />}
hint={t("site.pendingUnpaid", {
amount: formatDashboardMoneyMinor(overview.pending_unpaid_minor, displayCurrency),
})}
accent={overview.pending_bill_count > 0 ? "destructive" : "muted"}
/>
</div>
<div className="grid gap-3 md:grid-cols-2">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">{t("site.sevenDayTitle")}</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">{t("site.todayBet")}</span>
<span className="font-semibold tabular-nums">
{formatDashboardMoneyMinor(overview.seven_day_bet_minor, displayCurrency)}
</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">{t("site.sevenDayProfit")}</span>
<span
className={cn(
"font-semibold tabular-nums",
signedMoneyClass(overview.seven_day_profit_minor, true),
)}
>
{formatDashboardSignedMoneyMinor(overview.seven_day_profit_minor, displayCurrency)}
</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">{t("site.scaleTitle")}</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-3 text-sm">
<SiteMetric label={t("site.agentCount")} value={String(overview.agent_count)} />
<SiteMetric label={t("site.playerCount")} value={String(overview.player_count)} />
{overview.top_agent_today ? (
<div className="col-span-2 rounded-lg border bg-muted/20 px-3 py-2.5 text-xs text-muted-foreground">
{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,
),
})}
</div>
) : null}
</CardContent>
</Card>
</div>
</section>
) : null}
<DashboardCurrentDrawCard
key={`${hall?.draw_no ?? "empty"}:${loading ? "loading" : "ready"}`}
hall={hall}
drawId={drawId}
loading={loading}
/>
{canAnalytics ? (
<DashboardAnalyticsPanel
enabled={canAnalytics}
playOptions={playOptions}
scope={analyticsScope}
/>
) : null}
</div>
);
}

View File

@@ -28,6 +28,7 @@ import type { AdminDrawShowData } from "@/types/api/admin-draws";
import { canManageDrawResults } from "@/lib/draw-access"; import { canManageDrawResults } from "@/lib/draw-access";
import { adminHasAnyPermission } from "@/lib/admin-permissions"; import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { useAdminProfile } from "@/stores/admin-session"; import { useAdminProfile } from "@/stores/admin-session";
import { signedMoneyClass } from "@/lib/admin-signed-money";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { formatAdminMinorUnits } from "@/lib/money"; import { formatAdminMinorUnits } from "@/lib/money";
@@ -309,7 +310,15 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
</div> </div>
<div className="rounded-lg border bg-muted/20 px-3 py-2.5"> <div className="rounded-lg border bg-muted/20 px-3 py-2.5">
<p className="text-xs font-medium text-muted-foreground">{t("overviewProfitLoss")}</p> <p className="text-xs font-medium text-muted-foreground">{t("overviewProfitLoss")}</p>
<p className="mt-1 font-mono text-sm tabular-nums"> <p
className={cn(
"mt-1 font-mono text-sm tabular-nums",
signedMoneyClass(
financeSummary?.approx_house_gross_minor ?? data.profit_loss_minor ?? 0,
true,
),
)}
>
{formatAdminMinorUnits( {formatAdminMinorUnits(
financeSummary?.approx_house_gross_minor ?? data.profit_loss_minor ?? 0, financeSummary?.approx_house_gross_minor ?? data.profit_loss_minor ?? 0,
financeCurrency, financeCurrency,

View File

@@ -24,6 +24,7 @@ import {
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { adminHasAnyPermission } from "@/lib/admin-permissions"; import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { signedMoneyClass } from "@/lib/admin-signed-money";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useAdminProfile } from "@/stores/admin-session"; import { useAdminProfile } from "@/stores/admin-session";
import { LotteryApiBizError } from "@/types/api/errors"; import { LotteryApiBizError } from "@/types/api/errors";
@@ -140,12 +141,7 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
</div> </div>
<div> <div>
<span className="text-muted-foreground">{t("grossProfit")}</span> <span className="text-muted-foreground">{t("grossProfit")}</span>
<p <p className={cn("tabular-nums font-semibold", signedMoneyClass(data.approx_house_gross_minor, true))}>
className={cn(
"tabular-nums font-semibold",
data.approx_house_gross_minor >= 0 ? "text-emerald-600" : "text-destructive",
)}
>
{formatMoney(data.approx_house_gross_minor)} {formatMoney(data.approx_house_gross_minor)}
</p> </p>
</div> </div>

View File

@@ -52,6 +52,7 @@ import {
import { formatAdminMinorUnits } from "@/lib/money"; import { formatAdminMinorUnits } from "@/lib/money";
import { useConfirmAction } from "@/hooks/use-confirm-action"; import { useConfirmAction } from "@/hooks/use-confirm-action";
import { useExportLabels } from "@/hooks/use-export-labels"; import { useExportLabels } from "@/hooks/use-export-labels";
import { signedMoneyClass } from "@/lib/admin-signed-money";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useAdminProfile } from "@/stores/admin-session"; import { useAdminProfile } from "@/stores/admin-session";
import { LotteryApiBizError } from "@/types/api/errors"; import { LotteryApiBizError } from "@/types/api/errors";
@@ -441,7 +442,7 @@ export function DrawsIndexConsole() {
<TableCell <TableCell
className={cn( className={cn(
"text-center text-xs tabular-nums", "text-center text-xs tabular-nums",
(row.profit_loss_minor ?? 0) < 0 ? "text-destructive" : "text-emerald-600", signedMoneyClass(row.profit_loss_minor ?? 0, true),
)} )}
> >
{row.profit_loss_minor != null {row.profit_loss_minor != null

View File

@@ -1,6 +1,6 @@
"use client"; "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 { useCallback, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useAsyncEffect } from "@/hooks/use-async-effect"; import { useAsyncEffect } from "@/hooks/use-async-effect";
@@ -8,6 +8,7 @@ import { useTranslationRef } from "@/hooks/use-translation-ref";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
deleteAdminIntegrationSite,
getAdminIntegrationSite, getAdminIntegrationSite,
getAdminIntegrationSiteExport, getAdminIntegrationSiteExport,
getAdminIntegrationSites, getAdminIntegrationSites,
@@ -243,6 +244,8 @@ export function IntegrationSitesConsole({
const [editingId, setEditingId] = useState<number | null>(null); const [editingId, setEditingId] = useState<number | null>(null);
const [form, setForm] = useState<FormState>(EMPTY_FORM); const [form, setForm] = useState<FormState>(EMPTY_FORM);
const [rotateTarget, setRotateTarget] = useState<AdminIntegrationSiteRow | null>(null); const [rotateTarget, setRotateTarget] = useState<AdminIntegrationSiteRow | null>(null);
const [deleteTarget, setDeleteTarget] = useState<AdminIntegrationSiteRow | null>(null);
const [deleteBusy, setDeleteBusy] = useState(false);
const [rotateBusy, setRotateBusy] = useState(false); const [rotateBusy, setRotateBusy] = useState(false);
const [secretsDialog, setSecretsDialog] = useState<{ const [secretsDialog, setSecretsDialog] = useState<{
siteCode: string; siteCode: string;
@@ -388,6 +391,25 @@ export function IntegrationSitesConsole({
} }
} }
async function confirmDelete(): Promise<void> {
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 { function openConnectivity(row: AdminIntegrationSiteRow): void {
setConnectivityTarget(row); setConnectivityTarget(row);
setConnectivityPlayerId("10001"); setConnectivityPlayerId("10001");
@@ -519,7 +541,13 @@ export function IntegrationSitesConsole({
{loading ? ( {loading ? (
<AdminLoadingState minHeight="8rem" label={t("integrationSites.loading")} /> <AdminLoadingState minHeight="8rem" label={t("integrationSites.loading")} />
) : items.length === 0 ? ( ) : items.length === 0 ? (
<AdminNoResourceState /> <AdminNoResourceState message={t("integrationSites.empty")}>
{canCreate ? (
<Button type="button" size="sm" onClick={openCreate}>
{t("integrationSites.create")}
</Button>
) : null}
</AdminNoResourceState>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table> <Table>
@@ -637,6 +665,14 @@ export function IntegrationSitesConsole({
hidden: !canManage, hidden: !canManage,
onClick: () => setRotateTarget(row), onClick: () => setRotateTarget(row),
}, },
{
key: "delete",
label: t("integrationSites.delete"),
icon: Trash2,
destructive: true,
hidden: !canManage,
onClick: () => setDeleteTarget(row),
},
]} ]}
/> />
</TableCell> </TableCell>
@@ -850,6 +886,33 @@ export function IntegrationSitesConsole({
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<Dialog open={deleteTarget !== null} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("integrationSites.deleteConfirmTitle")}</DialogTitle>
<DialogDescription>
{t("integrationSites.deleteConfirmDescription", {
code: deleteTarget?.code ?? "",
name: deleteTarget?.name ?? "",
})}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setDeleteTarget(null)}>
{t("integrationSites.cancel")}
</Button>
<Button
type="button"
variant="destructive"
disabled={deleteBusy}
onClick={() => void confirmDelete()}
>
{deleteBusy ? t("integrationSites.deleting") : t("integrationSites.deleteConfirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog <Dialog
open={connectivityTarget !== null} open={connectivityTarget !== null}
onOpenChange={(open) => { onOpenChange={(open) => {

View File

@@ -75,6 +75,7 @@ import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"; import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { formatAdminInstant } from "@/lib/admin-datetime"; import { formatAdminInstant } from "@/lib/admin-datetime";
import { getAdminRequestLocale } from "@/lib/admin-locale"; import { getAdminRequestLocale } from "@/lib/admin-locale";
import { signedMoneyClass } from "@/lib/admin-signed-money";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { formatAdminMinorUnits } from "@/lib/money"; import { formatAdminMinorUnits } from "@/lib/money";
import { LotteryApiBizError } from "@/types/api/errors"; import { LotteryApiBizError } from "@/types/api/errors";
@@ -461,6 +462,10 @@ function formatPlainMoney(value: number, currencyCode: string | null | undefined
return formatAdminMinorUnits(value, currencyCode || "NPR"); 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 { function formatUsagePercent(ratio: number | null | undefined): string {
return ratio == null ? "-" : `${Math.round(ratio * 100)}%`; 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), payload.items.reduce((sum, item) => sum - item.net_win_loss_minor, 0),
"NPR", "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) }, { 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
<TableCell className="text-center">{summary.ticket_item_count}</TableCell> <TableCell className="text-center">{summary.ticket_item_count}</TableCell>
<TableCell className="text-center">{formatPlainMoney(summary.total_bet_minor, summary.currency_code)}</TableCell> <TableCell className="text-center">{formatPlainMoney(summary.total_bet_minor, summary.currency_code)}</TableCell>
<TableCell className="text-center">{formatPlainMoney(summary.total_payout_minor, summary.currency_code)}</TableCell> <TableCell className="text-center">{formatPlainMoney(summary.total_payout_minor, summary.currency_code)}</TableCell>
<TableCell className="text-center">{formatPlainMoney(summary.approx_house_gross_minor, summary.currency_code)}</TableCell> <TableCell className={signedProfitCell(summary.approx_house_gross_minor, summary.currency_code)}>
{formatPlainMoney(summary.approx_house_gross_minor, summary.currency_code)}
</TableCell>
<TableCell>{summary.settlement_batches.length}</TableCell> <TableCell>{summary.settlement_batches.length}</TableCell>
</TableRow> </TableRow>
{summary.settlement_batches.map((batch) => ( {summary.settlement_batches.map((batch) => (
@@ -1530,7 +1541,9 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
<TableCell>-</TableCell> <TableCell>-</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.total_bet_minor, "NPR")}</TableCell> <TableCell className="text-center">{formatPlainMoney(item.total_bet_minor, "NPR")}</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.total_payout_minor, "NPR")}</TableCell> <TableCell className="text-center">{formatPlainMoney(item.total_payout_minor, "NPR")}</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.approx_house_gross_minor, "NPR")}</TableCell> <TableCell className={signedProfitCell(item.approx_house_gross_minor, "NPR")}>
{formatPlainMoney(item.approx_house_gross_minor, "NPR")}
</TableCell>
<TableCell>-</TableCell> <TableCell>-</TableCell>
<TableCell>-</TableCell> <TableCell>-</TableCell>
<TableCell>-</TableCell> <TableCell>-</TableCell>
@@ -1548,7 +1561,9 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
</TableCell> </TableCell>
<TableCell className="text-center">{formatPlainMoney(item.total_bet_minor, "NPR")}</TableCell> <TableCell className="text-center">{formatPlainMoney(item.total_bet_minor, "NPR")}</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.total_payout_minor, "NPR")}</TableCell> <TableCell className="text-center">{formatPlainMoney(item.total_payout_minor, "NPR")}</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.net_win_loss_minor, "NPR")}</TableCell> <TableCell className={signedProfitCell(item.net_win_loss_minor, "NPR")}>
{formatPlainMoney(item.net_win_loss_minor, "NPR")}
</TableCell>
<TableCell>-</TableCell> <TableCell>-</TableCell>
<TableCell>-</TableCell> <TableCell>-</TableCell>
<TableCell>-</TableCell> <TableCell>-</TableCell>
@@ -1563,7 +1578,9 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
<TableCell>{item.dimension}D</TableCell> <TableCell>{item.dimension}D</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.total_bet_minor, "NPR")}</TableCell> <TableCell className="text-center">{formatPlainMoney(item.total_bet_minor, "NPR")}</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.total_payout_minor, "NPR")}</TableCell> <TableCell className="text-center">{formatPlainMoney(item.total_payout_minor, "NPR")}</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.approx_house_gross_minor, "NPR")}</TableCell> <TableCell className={signedProfitCell(item.approx_house_gross_minor, "NPR")}>
{formatPlainMoney(item.approx_house_gross_minor, "NPR")}
</TableCell>
<TableCell>-</TableCell> <TableCell>-</TableCell>
<TableCell>-</TableCell> <TableCell>-</TableCell>
<TableCell>-</TableCell> <TableCell>-</TableCell>

View File

@@ -3,7 +3,9 @@
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state"; import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { SignedMoney } from "@/lib/admin-signed-money";
import { formatDashboardCreditMajor, formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics"; import { formatDashboardCreditMajor, formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
import { formatSignedSettlementMoney } from "@/modules/settlement/settlement-signed-money";
import { import {
Table, Table,
TableBody, TableBody,
@@ -165,15 +167,19 @@ export function AgentSettlementReportView({
</p> </p>
); );
} }
const stats = [ const stats: { label: string; amount: number; signed?: boolean }[] = [
{ label: t("settlementReports.platformPnl.billNet", { defaultValue: "平台账单净额" }), value: money(root.platform_bill_net, currencyCode) }, {
label: t("settlementReports.platformPnl.billNet", { defaultValue: "平台账单净额" }),
amount: Number(root.platform_bill_net ?? 0),
},
{ {
label: t("settlementReports.platformPnl.rounding", { defaultValue: "尾差调整" }), 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: "占成利润(元数据)" }), label: t("settlementReports.platformPnl.shareProfit", { defaultValue: "占成利润(元数据)" }),
value: money(root.share_profit_meta, currencyCode), amount: Number(root.share_profit_meta ?? 0),
signed: true,
}, },
]; ];
return ( return (
@@ -181,7 +187,15 @@ export function AgentSettlementReportView({
{stats.map((item) => ( {stats.map((item) => (
<div key={item.label} className="rounded-md border border-border/60 px-3 py-2"> <div key={item.label} className="rounded-md border border-border/60 px-3 py-2">
<div className="text-xs text-muted-foreground">{item.label}</div> <div className="text-xs text-muted-foreground">{item.label}</div>
<div className="mt-1 text-sm font-semibold tabular-nums">{item.value}</div> <div className="mt-1 text-sm font-semibold tabular-nums">
{item.signed ? (
<SignedMoney amount={item.amount} emphasize>
{formatSignedSettlementMoney(item.amount, currencyCode)}
</SignedMoney>
) : (
money(item.amount, currencyCode)
)}
</div>
</div> </div>
))} ))}
</div> </div>
@@ -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 items = asRows(root?.items ?? (reportType === "player_win_loss" || reportType === "agent_share" || reportType === "unpaid_bills" || reportType === "overdue" || reportType === "draw_period" ? data : null));
const columnSets: Record<string, { key: string; header: string; money?: boolean }[]> = { const columnSets: Record<string, { key: string; header: string; money?: boolean; signed?: boolean }[]> = {
player_win_loss: [ player_win_loss: [
{ key: "username", header: t("settlementReports.columns.player", { defaultValue: "玩家" }) }, { key: "username", header: t("settlementReports.columns.player", { defaultValue: "玩家" }) },
{ key: "game_type", header: t("settlementReports.columns.gameType", { 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 }, { key: "basic_rebate", header: t("settlementReports.columns.rebate", { defaultValue: "回水" }), money: true },
], ],
agent_share: [ agent_share: [
{ key: "agent_node_id", header: t("settlementReports.columns.agentId", { defaultValue: "代理 ID" }) }, { 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: "basic_rebate", header: t("settlementReports.columns.rebate", { defaultValue: "回水" }), money: true },
{ key: "entry_count", header: t("settlementReports.columns.count", { defaultValue: "笔数" }) }, { key: "entry_count", header: t("settlementReports.columns.count", { defaultValue: "笔数" }) },
], ],
@@ -216,7 +230,7 @@ export function AgentSettlementReportView({
], ],
draw_period: [ draw_period: [
{ key: "draw_no", header: t("settlementReports.columns.drawNo", { defaultValue: "期号" }) }, { 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: "basic_rebate", header: t("settlementReports.columns.rebate", { defaultValue: "回水" }), money: true },
{ key: "ticket_count", header: t("settlementReports.columns.count", { defaultValue: "笔数" }) }, { key: "ticket_count", header: t("settlementReports.columns.count", { defaultValue: "笔数" }) },
], ],
@@ -238,7 +252,7 @@ function ReportTable({
currencyCode, currencyCode,
}: { }: {
rows: Record<string, unknown>[]; rows: Record<string, unknown>[];
columns: { key: string; header: string; money?: boolean; creditMajor?: boolean }[]; columns: { key: string; header: string; money?: boolean; signed?: boolean; creditMajor?: boolean }[];
currencyCode: string; currencyCode: string;
}): React.ReactElement { }): React.ReactElement {
const { t } = useTranslation("common"); const { t } = useTranslation("common");
@@ -272,9 +286,15 @@ function ReportTable({
> >
{col.creditMajor {col.creditMajor
? creditMoney(row[col.key], currencyCode) ? creditMoney(row[col.key], currencyCode)
: col.money : col.money && col.signed
? money(row[col.key], currencyCode) ? (
: String(row[col.key] ?? "—")} <SignedMoney amount={Number(row[col.key] ?? 0)} emphasize>
{formatSignedSettlementMoney(Number(row[col.key] ?? 0), currencyCode)}
</SignedMoney>
)
: col.money
? money(row[col.key], currencyCode)
: String(row[col.key] ?? "—")}
</TableCell> </TableCell>
))} ))}
</TableRow> </TableRow>

View File

@@ -47,6 +47,7 @@ import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"; import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { adminHasAnyPermission } from "@/lib/admin-permissions"; import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { formatAdminMinorUnits } from "@/lib/money"; import { formatAdminMinorUnits } from "@/lib/money";
import { signedMoneyClass } from "@/lib/admin-signed-money";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { PRD_PAYOUT_MANAGE, PRD_PAYOUT_REVIEW } from "@/lib/admin-prd"; import { PRD_PAYOUT_MANAGE, PRD_PAYOUT_REVIEW } from "@/lib/admin-prd";
import { useAdminProfile } from "@/stores/admin-session"; import { useAdminProfile } from "@/stores/admin-session";
@@ -273,12 +274,7 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
</p> </p>
<p> <p>
<span className="text-muted-foreground">{t("platformProfit")}</span>{" "} <span className="text-muted-foreground">{t("platformProfit")}</span>{" "}
<span <span className={cn("font-mono tabular-nums", signedMoneyClass(summary.platform_profit, true))}>
className={cn(
"font-mono tabular-nums",
summary.platform_profit < 0 ? "text-destructive" : "text-emerald-700",
)}
>
{formatAdminMinorUnits(summary.platform_profit, summary.currency_code ?? "NPR")} {formatAdminMinorUnits(summary.platform_profit, summary.currency_code ?? "NPR")}
</span> </span>
</p> </p>

View File

@@ -51,6 +51,7 @@ import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/c
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog"; import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
import { adminHasAnyPermission } from "@/lib/admin-permissions"; import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { formatAdminMinorUnits } from "@/lib/money"; import { formatAdminMinorUnits } from "@/lib/money";
import { signedMoneyClass } from "@/lib/admin-signed-money";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { PRD_PAYOUT_MANAGE, PRD_PAYOUT_REVIEW } from "@/lib/admin-prd"; import { PRD_PAYOUT_MANAGE, PRD_PAYOUT_REVIEW } from "@/lib/admin-prd";
import { useAdminProfile } from "@/stores/admin-session"; import { useAdminProfile } from "@/stores/admin-session";
@@ -270,7 +271,7 @@ export function SettlementBatchesConsole() {
<TableCell <TableCell
className={cn( className={cn(
"text-center font-mono text-xs tabular-nums", "text-center font-mono text-xs tabular-nums",
row.platform_profit < 0 ? "text-destructive" : "text-emerald-700", signedMoneyClass(row.platform_profit, true),
)} )}
> >
{formatAdminMinorUnits(row.platform_profit, row.currency_code ?? "NPR")} {formatAdminMinorUnits(row.platform_profit, row.currency_code ?? "NPR")}

View File

@@ -8,9 +8,12 @@ import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state
import { AdminLoadingState } from "@/components/admin/admin-loading-state"; import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu"; import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge"; import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { signedMoneyClass } from "@/lib/admin-signed-money";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { formatSettlementPeriodSpan } from "@/lib/agent-settlement-period-range"; 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 { import {
describeBillPaymentDirection, describeBillPaymentDirection,
} from "@/modules/settlement/settlement-bill-display"; } 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"; 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 { function unpaidMoneyClass(row: SettlementBillRow): string {
if (row.unpaid_amount <= 0) { if (row.unpaid_amount <= 0) {
return "text-muted-foreground"; return "text-muted-foreground";
@@ -253,7 +236,7 @@ export function SettlementBillsTable({
)} )}
> >
{row.gross_win_loss != null ? ( {row.gross_win_loss != null ? (
<div>{formatSignedMoney(row.gross_win_loss, currencyCode)}</div> <div>{formatSignedSettlementMoney(row.gross_win_loss, currencyCode)}</div>
) : ( ) : (
"—" "—"
)} )}

View File

@@ -10,6 +10,7 @@ import { toast } from "sonner";
import { getSettlementPeriods, type SettlementPeriodRow } from "@/api/admin-agent-settlement"; import { getSettlementPeriods, type SettlementPeriodRow } from "@/api/admin-agent-settlement";
import { getAdminIntegrationSites } from "@/api/admin-integration-sites"; import { getAdminIntegrationSites } from "@/api/admin-integration-sites";
import { AdminLoadingState } from "@/components/admin/admin-loading-state"; 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 { AgentBillDetail } from "@/modules/settlement/agent-bill-detail";
import { SettlementCenterPeriodDetail } from "@/modules/settlement/settlement-center-period-detail"; import { SettlementCenterPeriodDetail } from "@/modules/settlement/settlement-center-period-detail";
import { import {
@@ -284,7 +285,9 @@ export function SettlementCenterShell(): React.ReactElement {
return ( return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4"> <div className="mx-auto flex w-full max-w-7xl flex-col gap-4">
{siteId === null ? ( {siteId === null && siteOptions.length === 0 && boundAgent === null ? (
<AdminNoIntegrationSiteState canCreate={profile?.is_super_admin === true} />
) : siteId === null ? (
<p className="text-sm text-muted-foreground">{t("empty.noSite", { defaultValue: "请选择站点。" })}</p> <p className="text-sm text-muted-foreground">{t("empty.noSite", { defaultValue: "请选择站点。" })}</p>
) : !periodsReady ? ( ) : !periodsReady ? (
<AdminLoadingState /> <AdminLoadingState />

View File

@@ -1,17 +1,6 @@
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics"; import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
import { cn } from "@/lib/utils";
/** 结算金额正负着色:负红、正绿、零灰 */ export { signedMoneyClass as signedSettlementMoneyClass } from "@/lib/admin-signed-money";
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 function formatSignedSettlementMoney(amount: number, currencyCode: string): string { export function formatSignedSettlementMoney(amount: number, currencyCode: string): string {
if (amount === 0) { if (amount === 0) {

View File

@@ -1,6 +1,6 @@
export type AdminAgentLineProvisionPayload = { export type AdminAgentLineProvisionPayload = {
site_code: string; site_code: string;
code: string; code?: string;
name: string; name: string;
username: string; username: string;
password: string; password: string;

View File

@@ -35,6 +35,8 @@ export type AdminProfile = {
delegation_ceiling?: string[]; delegation_ceiling?: string[];
/** 平台账号可访问站点;代理账号为 undefined见 agent.site_code */ /** 平台账号可访问站点;代理账号为 undefined见 agent.site_code */
accessible_sites?: { id: number; code: string; name: string }[]; accessible_sites?: { id: number; code: string; name: string }[];
/** 站点管理员主站点上下文;代理/超管为 null */
site?: { id: number; code: string; name: string } | null;
}; };
/** `POST /api/v1/admin/auth/login` 成功信封内的 `data` */ /** `POST /api/v1/admin/auth/login` 成功信封内的 `data` */

View File

@@ -49,6 +49,36 @@ export type AdminDashboardCapabilities = {
wallet_transfer_view: boolean; 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` */ /** 代理经营账号首页摘要(`GET /api/v1/admin/dashboard` → `agent_overview` */
export type AdminDashboardAgentOverview = { export type AdminDashboardAgentOverview = {
agent_node_id: number; agent_node_id: number;
@@ -147,4 +177,5 @@ export type AdminDashboardData = {
warnings: AdminDashboardWarning[]; warnings: AdminDashboardWarning[];
capabilities: AdminDashboardCapabilities; capabilities: AdminDashboardCapabilities;
agent_overview: AdminDashboardAgentOverview | null; agent_overview: AdminDashboardAgentOverview | null;
site_overview: AdminDashboardSiteOverview | null;
}; };

View File

@@ -12,6 +12,7 @@ export type AdminIntegrationSiteRow = {
has_wallet_api_key: boolean; has_wallet_api_key: boolean;
sso_secret_masked: string | null; sso_secret_masked: string | null;
wallet_api_key_masked: string | null; wallet_api_key_masked: string | null;
is_default: boolean;
updated_at: string | null; updated_at: string | null;
}; };