diff --git a/AGENTS.md b/AGENTS.md
index e006961..ab21acd 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -34,18 +34,19 @@ This version has breaking changes — APIs, conventions, and file structure may
- 占成/授信/回水/上限等数值字段用 `AdminNumericStepper`(± 步进 + 可手输),勿单独裸 `input[type=number]`。
- 对外文档(接入 + 后台运营手册)禁用 RBAC slug(如 `prd.settlement.agent.manage`);对客户称「贵司」;排版忌 AI 感,正文对比度与字号可读优先。
- 文档 i18n:`useTranslation` 须显式 `ns`;`returnObjects` 列表用 `Array.isArray` 守卫;避免节名与表头 key 冲突(如 `billStatus`)。
+- 运营页少堆 `text-xs` 说明与多层免责条;口径/默认范围合并进各模块一行 `summary`,勿叠加 filterPanel/queryHint/disclaimer 等小字(对账、报表中心已按此精简)。
+- i18n 按语言懒加载,不要一次 import 三语全套。
## Learned Workspace Facts
- 无接入站时依赖站点的页面展示 `
/`,勿只查 code 选择器。
-- Docs 侧栏粘性定位用 CSS 变量 `--docs-sticky-top`(含顶栏/header 偏移)。
- Tanumo 联调/生产默认:H5 `https://front.tanumo.com`、API `https://lotterylaravel.tanumo.com`、管理端/文档 `https://lotteryadmin.tanumo.com`。
-- 接入文档 SSO:无「登录换票」;主站 JWT 直传 H5/iframe,`GET /api/v1/player/me` 自动建档;iframe 约定 token 在顶层 `data.token`;勿引用 main-site/monorepo 等内部仓库路径。
- 风控页默认:风险池仅显示有占用/高风险;单号详情可看占用来源;组合明细在注单详情页。
+- 模块边界:对账中心=主站↔彩票钱包划转;报表中心=钱包盘经营分析;结算中心=信用账期占成/收付。
+- 报表中心:默认近 30 天;盈亏类按 `draws.business_date`;金额读 API `currency_code`;导出仅后端 `report-jobs` 全量(按筛选,无需先预览);注单盈亏≠信用账期结算。
+- 对账中心:校验 `transfer_orders` vs 彩票 `wallet_txns`;差异 B 侧引用=彩票 `wallet_txns` 流水号;补单/冲正在钱包转账单页。
diff --git a/src/i18n/locales/en/dashboard.json b/src/i18n/locales/en/dashboard.json
index 7fbecfd..dff0128 100644
--- a/src/i18n/locales/en/dashboard.json
+++ b/src/i18n/locales/en/dashboard.json
@@ -77,6 +77,7 @@
"todayPayout": "Today's payout",
"todayProfit": "Today's profit",
"todayBusinessDateHint": "Business date {{date}}",
+ "todayPayoutHint": "Payout {{amount}}",
"drawNoHint": "Draw {{drawNo}}",
"orderAndTicket": "{{orders}} orders · {{tickets}} items",
"marginRate": "Gross margin ~{{rate}}%",
@@ -94,6 +95,9 @@
"batchPendingDraws": "Draws involved",
"batchPendingDrawsCount": "{{count}} draws pending",
"platformLockedAndCap": "Site locked {{locked}} / cap {{cap}}",
+ "platformLockedLabel": "Locked",
+ "platformCapLabel": "Cap",
+ "platformCapUnset": "Not set",
"platformCapNotConfigured": "Site locked {{locked}} · cap not configured",
"platformOrderAndTicket": "Site-wide {{orders}} orders · {{tickets}} lines",
"platformBetTotal": "Lifetime bet",
@@ -162,6 +166,8 @@
"todayBet": "Today's bets",
"todayProfit": "Today's P/L",
"sevenDayTitle": "Last 7 days",
+ "sevenDayBet": "7-day bets",
+ "sevenDayPayout": "7-day payout",
"sevenDayProfit": "7-day P/L",
"profitScopeHint": "Site scope: bets minus payouts",
"activePlayersToday": "Active players today",
@@ -174,6 +180,8 @@
"agentCount": "Agent nodes",
"playerCount": "Players",
"topAgentToday": "Top agent today: {{name}} ({{amount}})",
+ "topAgentTodayLabel": "Top agent today",
+ "overviewEmpty": "No site operations data. Confirm the integration site is bound.",
"quickLinks": {
"tickets": "Tickets",
"players": "Players",
@@ -194,6 +202,7 @@
"creditAllocatedLabel": "Allocated credit",
"creditUsedLabel": "Used credit",
"shareRate": "Total share {{rate}}%",
+ "shareProfitScopeHint": "Share profit at this node (from bet share_snapshot)",
"settlementCycle": "Cycle {{cycle}}",
"teamTitle": "Team size",
"directChildren": "Direct child agents",
@@ -208,8 +217,11 @@
"todayProfit": "Today's profit",
"todayShareProfit": "Today's share profit",
"sevenDayTitle": "Last 7 days",
+ "sevenDayBet": "7-day bets",
"sevenDayPayout": "Payout {{amount}}",
+ "sevenDayPayoutLabel": "7-day payout",
"sevenDayProfit": "Profit {{amount}}",
+ "sevenDayShareProfitLabel": "7-day share profit",
"sevenDayShareProfit": "Share profit {{amount}}",
"pendingBills": "Open agent bills",
"pendingUnpaid": "Unpaid total {{amount}}",
@@ -230,6 +242,7 @@
"no": "No",
"viewBills": "View bills",
"lineMeta": "Depth {{depth}} · child agents {{childAgent}} · players {{player}}",
+ "overviewEmpty": "No line operations data. Confirm the agent binding is valid.",
"viewLine": "Agent line",
"quickLinks": {
"tickets": "Tickets",
@@ -242,6 +255,7 @@
"warnings": {
"drawPermission": "This account has no draw/dashboard view permission. Finance and risk data were not returned.",
"walletPermission": "This account has no wallet reconciliation permission. Abnormal transfer count was not returned.",
+ "analyticsUnavailable": "Trend and ranking analytics are unavailable. KPI cards above remain visible.",
"loadFailed": "Failed to load. Check the API and login state.",
"apiResourceMissing": "Dashboard analytics API is not registered. Run: php artisan lottery:admin-auth-sync (or apply the latest migration), then refresh."
}
diff --git a/src/i18n/locales/en/reports.json b/src/i18n/locales/en/reports.json
index d23c019..a262e3b 100644
--- a/src/i18n/locales/en/reports.json
+++ b/src/i18n/locales/en/reports.json
@@ -13,7 +13,7 @@
"exportSuccess": "Exported {{report}} ({{format}})",
"exportServerSuccess": "Job {{jobNo}} created and downloaded {{report}} ({{format}})",
"exportFailed": "Export failed",
- "exportHint": "Server exports use the current filters to build a full file. Completed jobs can be downloaded again below.",
+ "exportHint": "Export uses the current filters to build the full dataset. Preview query is not required.",
"exportServerHint": "Full export via server job (preview query not required)",
"exportClientHint": "Export current preview page (run query first)",
"validation": {
@@ -281,19 +281,15 @@
},
"daily_profit": {
"title": "Daily P&L summary",
- "summary": "Summarize bet amount, payout, and house P&L by business date. Refund and standalone net amount are not included yet."
+ "summary": "Betting P&L by business date; defaults to the last 30 days. Ticket amounts — not credit-line period settlement."
},
"player_win_loss": {
"title": "Player win/loss report",
- "summary": "Track player win/loss over a selected period for finance and support review."
- },
- "profit_reports": {
- "disclaimer": "These reports use ticket bet/win amounts (betting results), not credit-line period settlement. For share, rebate, and collections, use Settlement Center → Period reports."
+ "summary": "Win/loss by player and business date; defaults to the last 30 days. Ticket amounts — not credit-line settlement."
},
"player_transfer": {
"title": "Player transfer report",
- "summary": "Review player transfers in, transfers out, reversals, and exception handling.",
- "disclaimer": "Main-site wallet transfer orders only (wallet-mode players). Credit-line players have no such transfers — use Settlement Center for period ledger."
+ "summary": "Wallet-mode main-site transfers by record time; defaults to the last 30 days."
},
"hot_number_risk": {
"title": "Hot number risk report",
@@ -301,7 +297,7 @@
},
"play_dimension": {
"title": "Play dimension report",
- "summary": "Break down betting volume, payout, rebate, and P&L structure by play."
+ "summary": "P&L by play and business date; defaults to the last 30 days. Ticket amounts — not credit-line settlement."
},
"sold_out_number": {
"title": "Sold-out number report",
@@ -309,12 +305,11 @@
},
"rebate_commission": {
"title": "Commission / rebate report",
- "summary": "Summarize commission, rebate, and matched rules by play and period.",
- "disclaimer": "Wallet-mode instant rebate/commission — not agent credit-line period settlement. Use Agent → Settlement bills for credit-line reports."
+ "summary": "Wallet-mode instant rebate by business date; defaults to the last 30 days. Not credit-line period settlement."
},
"admin_audit": {
"title": "Admin operation audit report",
- "summary": "Export key admin operation traces by operator and period."
+ "summary": "Admin actions by operator and record time; defaults to the last 30 days."
}
}
}
diff --git a/src/i18n/locales/ne/dashboard.json b/src/i18n/locales/ne/dashboard.json
index 8a5bff4..6cd5a7a 100644
--- a/src/i18n/locales/ne/dashboard.json
+++ b/src/i18n/locales/ne/dashboard.json
@@ -75,6 +75,7 @@
"todayPayout": "आजको भुक्तानी",
"todayProfit": "आजको नाफा/नोक्सान",
"todayBusinessDateHint": "व्यापार मिति {{date}}",
+ "todayPayoutHint": "भुक्तानी {{amount}}",
"drawNoHint": "ड्रअ {{drawNo}}",
"orderAndTicket": "{{orders}} अर्डर · {{tickets}} वस्तु",
"marginRate": "सकल मार्जिन ~{{rate}}%",
@@ -92,6 +93,9 @@
"batchPendingDraws": "सम्बन्धित ड्रअ",
"batchPendingDrawsCount": "{{count}} ड्रअ पेन्डिङ",
"platformLockedAndCap": "साइट लक {{locked}} / क्याप {{cap}}",
+ "platformLockedLabel": "लक",
+ "platformCapLabel": "क्याप",
+ "platformCapUnset": "सेट छैन",
"platformCapNotConfigured": "साइट लक {{locked}} · क्याप कन्फिगर गरिएको छैन",
"platformOrderAndTicket": "साइटव्यापी {{orders}} अर्डर · {{tickets}} लाइन",
"platformBetTotal": "जम्मा बेट",
@@ -159,6 +163,8 @@
"todayBet": "आजको बाजी",
"todayProfit": "आजको नाफा/नोक्सान",
"sevenDayTitle": "पछिल्लो ७ दिन",
+ "sevenDayBet": "७-दिने बाजी",
+ "sevenDayPayout": "७-दिने भुक्तानी",
"sevenDayProfit": "७-दिने नाफा/नोक्सान",
"profitScopeHint": "साइट दायरा: बाजी माइनस भुक्तानी",
"activePlayersToday": "आज सक्रिय खेलाडी",
@@ -171,6 +177,8 @@
"agentCount": "एजेन्ट नोड",
"playerCount": "खेलाडी संख्या",
"topAgentToday": "आजको शीर्ष एजेन्ट: {{name}} ({{amount}})",
+ "topAgentTodayLabel": "आजको शीर्ष एजेन्ट",
+ "overviewEmpty": "साइट सञ्चालन डाटा छैन। कृपया साइट बाइन्डिङ जाँच गर्नुहोस्।",
"quickLinks": {
"tickets": "टिकट",
"players": "खेलाडी",
@@ -179,9 +187,41 @@
"bills": "सेटलमेन्ट"
}
},
+ "agent": {
+ "title": "सञ्चालन सारांश",
+ "subtitle": "{{name}} · यो लाइन",
+ "creditTitle": "क्रेडिट सीमा",
+ "creditAvailable": "उपलब्ध {{amount}}",
+ "creditAllocatedLabel": "बाँडिएको क्रेडिट",
+ "creditUsedLabel": "प्रयोग भएको क्रेडिट",
+ "shareRate": "कुल शेयर {{rate}}%",
+ "shareProfitScopeHint": "यस नोडको शेयर नाफा (share_snapshot)",
+ "teamTitle": "टोली परिमाण",
+ "directChildren": "प्रत्यक्ष सन्तान एजेन्ट",
+ "directPlayers": "प्रत्यक्ष खेलाडी",
+ "subtreeAgents": "लाइन एजेन्ट",
+ "teamPlayers": "लाइन खेलाडी",
+ "activePlayersToday": "आज सक्रिय खेलाडी",
+ "betOrdersTodayHint": "आज {{count}} अर्डर",
+ "todayBet": "आजको बाजी",
+ "todayShareProfit": "आजको शेयर नाफा",
+ "sevenDayTitle": "पछिल्लो ७ दिन",
+ "sevenDayBet": "७-दिने बाजी",
+ "sevenDayPayoutLabel": "७-दिने भुक्तानी",
+ "sevenDayShareProfitLabel": "७-दिने शेयर नाफा",
+ "pendingBills": "बाँकी बिल",
+ "pendingUnpaid": "नतिरेको {{amount}}",
+ "latestBetAt": "पछिल्लो बाजी {{time}}",
+ "noBetToday": "आज अहिलेसम्म बाजी छैन",
+ "yes": "हो",
+ "no": "होइन",
+ "lineMeta": "गहिराइ {{depth}} · सन्तान एजेन्ट {{childAgent}} · खेलाडी {{player}}",
+ "overviewEmpty": "लाइन सञ्चालन डाटा छैन। एजेन्ट बाइन्डिङ जाँच गर्नुहोस्।"
+ },
"warnings": {
"drawPermission": "यो खातासँग ड्रअ/ड्यासबोर्ड हेर्ने अनुमति छैन। वित्तीय र जोखिम डाटा फिर्ता आएन।",
"walletPermission": "यो खातासँग वालेट मिलान हेर्ने अनुमति छैन। असामान्य ट्रान्सफर संख्या फिर्ता आएन।",
+ "analyticsUnavailable": "ट्रेन्ड/र्याङ्किङ विश्लेषण उपलब्ध छैन। माथिको KPI हेर्न सकिन्छ।",
"loadFailed": "लोड असफल भयो। API र लगइन अवस्था जाँच गर्नुहोस्।"
}
}
diff --git a/src/i18n/locales/ne/reports.json b/src/i18n/locales/ne/reports.json
index bf3208c..a7164e4 100644
--- a/src/i18n/locales/ne/reports.json
+++ b/src/i18n/locales/ne/reports.json
@@ -13,7 +13,7 @@
"exportSuccess": "{{report}} ({{format}}) निर्यात भयो",
"exportServerSuccess": "कार्य {{jobNo}} सिर्जना भई {{report}} ({{format}}) डाउनलोड भयो",
"exportFailed": "निर्यात असफल भयो",
- "exportHint": "सर्भर निर्यातले हालका फिल्टरअनुसार पूर्ण फाइल बनाउँछ। सम्पन्न कार्य तलबाट पुन: डाउनलोड गर्न सकिन्छ।",
+ "exportHint": "हालका फिल्टरअनुसार पूर्ण डाटा निर्यात; पूर्वावलोकन क्वेरी अनिवार्य छैन।",
"exportServerHint": "पूर्ण निर्यात सर्भर कार्यबाट (पूर्वावलोकन क्वेरी अनिवार्य छैन)",
"exportClientHint": "हालको पूर्वावलोकन पृष्ठ निर्यात (पहिले क्वेरी चलाउनुहोस्)",
"validation": {
@@ -278,19 +278,15 @@
},
"daily_profit": {
"title": "दैनिक P&L सारांश",
- "summary": "व्यावसायिक मितिअनुसार बेट रकम, पेआउट र हाउस P&L सारांश गर्नुहोस्। रिफन्ड र छुट्टै नेट रकम अहिले समावेश छैन।"
+ "summary": "व्यावसायिक मितिअनुसार बेट P&L; मिति नचयेमा पछिल्लो ३० दिन। टिकट रकम — क्रेडिट-लाइन सेटलमेन्ट होइन।"
},
"player_win_loss": {
"title": "खेलाडी जित/हार रिपोर्ट",
- "summary": "चयन गरिएको अवधिमा खेलाडीको जित/हार वित्त र सपोर्टका लागि हेर्नुहोस्।"
- },
- "profit_reports": {
- "disclaimer": "यी रिपोर्ट टिकट बेट/जित रकम (बेटिङ नतिजा) मा आधारित छन्, क्रेडिट-लाइन अवधि सेटलमेन्ट होइन। शेयर, रिबेट र सङ्कलनका लागि सेटलमेन्ट सेन्टर → अवधि रिपोर्ट प्रयोग गर्नुहोस्।"
+ "summary": "खेलाडी र व्यावसायिक मितिअनुसार जित/हार; मिति नचयेमा पछिल्लो ३० दिन।"
},
"player_transfer": {
"title": "खेलाडी ट्रान्सफर रिपोर्ट",
- "summary": "खेलाडी ट्रान्सफर इन, आउट, रिभर्सल र अपवाद रेकर्ड हेर्नुहोस्।",
- "disclaimer": "मुख्य साइट वालेट ट्रान्सफर मात्र (वालेट-मोड खेलाडी)। क्रेडिट-लाइन खेलाडीमा यस्तो ट्रान्सफर हुँदैन — अवधि लेजरका लागि सेटलमेन्ट सेन्टर प्रयोग गर्नुहोस्।"
+ "summary": "वालेट-मोड मुख्य साइट ट्रान्सफर, रेकर्ड समय अनुसार; मिति नचयेमा पछिल्लो ३० दिन।"
},
"hot_number_risk": {
"title": "हट नम्बर जोखिम रिपोर्ट",
@@ -298,7 +294,7 @@
},
"play_dimension": {
"title": "खेल आयाम रिपोर्ट",
- "summary": "खेल अनुसार बेट भोल्युम, पेआउट, रिबेट र P&L संरचना छुट्याउनुहोस्।"
+ "summary": "खेल र व्यावसायिक मितिअनुसार P&L; मिति नचयेमा पछिल्लो ३० दिन।"
},
"sold_out_number": {
"title": "सोल्ड-आउट नम्बर रिपोर्ट",
@@ -306,11 +302,11 @@
},
"rebate_commission": {
"title": "कमिसन / रिबेट रिपोर्ट",
- "summary": "खेल र अवधिअनुसार कमिसन, रिबेट र मिलेको नियम सारांश गर्नुहोस्।"
+ "summary": "वालेट-मोड तत्काल रिबेट, व्यावसायिक मितिअनुसार; मिति नचयेमा पछिल्लो ३० दिन।"
},
"admin_audit": {
"title": "एडमिन अपरेशन अडिट रिपोर्ट",
- "summary": "अपरेटर र अवधिअनुसार मुख्य एडमिन अपरेशन ट्रेस निर्यात गर्नुहोस्।"
+ "summary": "अपरेटर र रेकर्ड समय अनुसार; मिति नचयेमा पछिल्लो ३० दिन।"
}
}
}
diff --git a/src/i18n/locales/zh/dashboard.json b/src/i18n/locales/zh/dashboard.json
index 0ca9ed2..adf1a12 100644
--- a/src/i18n/locales/zh/dashboard.json
+++ b/src/i18n/locales/zh/dashboard.json
@@ -77,6 +77,7 @@
"todayPayout": "今日派彩",
"todayProfit": "今日盈亏",
"todayBusinessDateHint": "业务日 {{date}}",
+ "todayPayoutHint": "派彩 {{amount}}",
"drawNoHint": "期号 {{drawNo}}",
"orderAndTicket": "{{orders}} 单 · {{tickets}} 笔",
"marginRate": "毛利率约 {{rate}}%",
@@ -94,6 +95,9 @@
"batchPendingDraws": "涉及期数",
"batchPendingDrawsCount": "{{count}} 期待审",
"platformLockedAndCap": "全站已占用 {{locked}} / 封顶 {{cap}}",
+ "platformLockedLabel": "已占用",
+ "platformCapLabel": "封顶",
+ "platformCapUnset": "未配置",
"platformCapNotConfigured": "全站已占用 {{locked}} · 尚未配置封顶",
"platformOrderAndTicket": "全站 {{orders}} 单 · {{tickets}} 笔",
"platformBetTotal": "累计投注",
@@ -162,6 +166,8 @@
"todayBet": "今日下注",
"todayProfit": "今日盈亏",
"sevenDayTitle": "近 7 天走势",
+ "sevenDayBet": "近 7 天下注",
+ "sevenDayPayout": "近 7 天派彩",
"sevenDayProfit": "近 7 天盈亏",
"profitScopeHint": "站点口径:投注减派彩(不含占成拆分)",
"activePlayersToday": "今日活跃玩家",
@@ -174,6 +180,8 @@
"agentCount": "代理节点",
"playerCount": "玩家总数",
"topAgentToday": "今日投注最高代理:{{name}}({{amount}})",
+ "topAgentTodayLabel": "今日投注最高代理",
+ "overviewEmpty": "暂无站点经营数据,请确认已绑定接入站点。",
"quickLinks": {
"tickets": "注单查询",
"players": "玩家管理",
@@ -194,6 +202,7 @@
"creditAllocatedLabel": "已下发额度",
"creditUsedLabel": "已占用额度",
"shareRate": "总占成 {{rate}}%",
+ "shareProfitScopeHint": "本级占成口径(按注单 share_snapshot)",
"settlementCycle": "账期 {{cycle}}",
"teamTitle": "团队规模",
"directChildren": "直属下级代理",
@@ -208,8 +217,11 @@
"todayProfit": "今日盈亏",
"todayShareProfit": "今日本级占成",
"sevenDayTitle": "近 7 天走势",
+ "sevenDayBet": "近 7 天下注",
"sevenDayPayout": "派彩 {{amount}}",
+ "sevenDayPayoutLabel": "近 7 天派彩",
"sevenDayProfit": "盈亏 {{amount}}",
+ "sevenDayShareProfitLabel": "近 7 日本级占成",
"sevenDayShareProfit": "本级占成 {{amount}}",
"pendingBills": "待结代理账单",
"pendingUnpaid": "未结合计 {{amount}}",
@@ -230,6 +242,7 @@
"no": "否",
"viewBills": "查看账单",
"lineMeta": "层级 {{depth}} · 可开下级 {{childAgent}} · 可开玩家 {{player}}",
+ "overviewEmpty": "暂无线路经营数据,请确认代理绑定有效。",
"viewLine": "代理线路",
"quickLinks": {
"tickets": "注单查询",
@@ -242,6 +255,7 @@
"warnings": {
"drawPermission": "当前账号无开奖/仪表盘查看权限,财务与风控数据未返回。",
"walletPermission": "当前账号无钱包对账查看权限,异常转账计数未返回。",
+ "analyticsUnavailable": "暂无趋势与排行分析权限,上方 KPI 仍可查看。",
"loadFailed": "加载失败,请检查 API 与登录状态。",
"apiResourceMissing": "仪表盘分析接口未注册。请在服务端执行:php artisan lottery:admin-auth-sync,或运行最新数据库迁移后重试。"
}
diff --git a/src/i18n/locales/zh/reports.json b/src/i18n/locales/zh/reports.json
index 156abb4..fae9a92 100644
--- a/src/i18n/locales/zh/reports.json
+++ b/src/i18n/locales/zh/reports.json
@@ -13,7 +13,7 @@
"exportSuccess": "已导出 {{report}}({{format}})",
"exportServerSuccess": "已生成任务 {{jobNo}} 并下载 {{report}}({{format}})",
"exportFailed": "导出失败",
- "exportHint": "服务端导出会按当前筛选条件生成全量文件;任务完成后可在下方列表再次下载。",
+ "exportHint": "导出按当前筛选条件生成全量数据,无需先查询预览。",
"exportServerHint": "全量导出走服务端任务(无需先查询预览)",
"exportClientHint": "导出当前预览页数据(需先查询)",
"validation": {
@@ -281,19 +281,15 @@
},
"daily_profit": {
"title": "每日盈亏汇总",
- "summary": "按业务日汇总投注、派彩与平台盈亏,当前不包含退款与单独净额字段。"
+ "summary": "按业务日汇总投注与盈亏;未选日期默认近 30 天。注单口径,非信用账期结算。"
},
"player_win_loss": {
"title": "玩家输赢报表",
- "summary": "按玩家和时间段追踪输赢表现,适合客服与财务复核。"
- },
- "profit_reports": {
- "disclaimer": "本组报表按注单下注/中奖金额统计投注结果,不等同于信用占成盘账期结算。占成、回水与收付请使用「结算中心 → 账期报表」。"
+ "summary": "按业务日与玩家汇总输赢;未选日期默认近 30 天。注单口径,非信用账期结算。"
},
"player_transfer": {
"title": "玩家转入转出报表",
- "summary": "集中查看玩家转入、转出、冲正和异常处理记录。",
- "disclaimer": "仅统计主站钱包划转单(钱包盘玩家)。信用盘玩家无此类转账,请使用结算中心查看账期账务。"
+ "summary": "钱包盘主站划转记录,按创建时间筛选;未选日期默认近 30 天。"
},
"hot_number_risk": {
"title": "热门号码风险报表",
@@ -301,7 +297,7 @@
},
"play_dimension": {
"title": "玩法维度报表",
- "summary": "按玩法拆分投注量、派彩、回水和盈亏结构。"
+ "summary": "按玩法与业务日拆分盈亏;未选日期默认近 30 天。注单口径,非信用账期结算。"
},
"sold_out_number": {
"title": "售罄号码报表",
@@ -309,12 +305,11 @@
},
"rebate_commission": {
"title": "佣金/回水报表",
- "summary": "按玩法与时间段汇总佣金、回水与配置命中情况。",
- "disclaimer": "本报表为钱包盘「下注立减回水/佣金」口径,不属于信用占成盘账期结算。占成盘请使用「代理 → 代理账单」中的账期报表。"
+ "summary": "钱包盘下注立减回水,按业务日汇总;未选日期默认近 30 天。非信用占成账期。"
},
"admin_audit": {
"title": "后台操作审计报表",
- "summary": "按操作人和时间段导出关键后台操作留痕。"
+ "summary": "按操作人与记录创建时间筛选;未选日期默认近 30 天。"
}
}
}
diff --git a/src/lib/admin-datetime.ts b/src/lib/admin-datetime.ts
index 1ce6849..8ea8cf4 100644
--- a/src/lib/admin-datetime.ts
+++ b/src/lib/admin-datetime.ts
@@ -52,6 +52,15 @@ export function formatAdminCalendarToday(locale: AdminApiLocale, weekdayLabel: s
return `${datePart} ${weekdayLabel}`;
}
+/** 与后端 `now()->toDateString()` 对齐的业务日(本地日历日 YYYY-MM-DD)。 */
+export function formatAdminBusinessDateIso(date: Date = new Date()): string {
+ const y = date.getFullYear();
+ const m = String(date.getMonth() + 1).padStart(2, "0");
+ const day = String(date.getDate()).padStart(2, "0");
+
+ return `${y}-${m}-${day}`;
+}
+
const NAIVE_SCHEDULE_CLOCK_RE =
/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/;
diff --git a/src/lib/admin-prd.ts b/src/lib/admin-prd.ts
index 809b3fb..e44c67a 100644
--- a/src/lib/admin-prd.ts
+++ b/src/lib/admin-prd.ts
@@ -76,6 +76,12 @@ export const PRD_RISK_ACCESS_ANY = [
export const PRD_REPORTS_VIEW_ACCESS_ANY = [PRD_REPORT_VIEW] as const;
export const PRD_REPORTS_EXPORT_ACCESS_ANY = [PRD_REPORT_EXPORT] as const;
+/** 仪表盘趋势/排行(后端 analytics 需 dashboard.view;站点另有 report.view) */
+export const PRD_DASHBOARD_ANALYTICS_ACCESS_ANY = [
+ PRD_DASHBOARD_VIEW,
+ ...PRD_REPORTS_VIEW_ACCESS_ANY,
+] as const;
+
/** 系统设置(与后端 admin.settings.* 资源口径一致) */
export const PRD_SETTINGS_ACCESS_ANY = [
PRD_WALLET_RECONCILE_MANAGE,
diff --git a/src/lib/admin-signed-money.tsx b/src/lib/admin-signed-money.tsx
index 4862e70..1498b2f 100644
--- a/src/lib/admin-signed-money.tsx
+++ b/src/lib/admin-signed-money.tsx
@@ -16,6 +16,28 @@ export function signedMoneyClass(amount: number, emphasize = false): string {
return cn("text-muted-foreground", emphasize && "font-medium");
}
+/** 环比趋势色:升/降何者为「好」因指标而异 */
+export function signedTrendClassName(
+ series: number[],
+ mode: "higherBetter" | "lowerBetter" | "signed",
+): string {
+ if (series.length < 2) {
+ return "text-muted-foreground";
+ }
+ const last = series[series.length - 1] ?? 0;
+ const prev = series[series.length - 2] ?? 0;
+ if (mode === "signed") {
+ return signedMoneyClass(last - prev, false);
+ }
+ const improved = mode === "higherBetter" ? last > prev : last < prev;
+ if (last === prev) {
+ return "text-muted-foreground";
+ }
+ return improved
+ ? "text-emerald-600 dark:text-emerald-400"
+ : "text-destructive";
+}
+
export function SignedMoney({
amount,
children,
diff --git a/src/lib/report-export-map.ts b/src/lib/report-export-map.ts
index 503c031..cdf3ecd 100644
--- a/src/lib/report-export-map.ts
+++ b/src/lib/report-export-map.ts
@@ -34,19 +34,6 @@ export const REPORT_UI_TO_JOB_TYPE: Record = {
admin_audit: "audit_operation_report",
};
-/** Report types with full server-side export (POST /admin/report-jobs). */
-export const REPORT_UI_SERVER_FULL_EXPORT = new Set([
- "draw_profit",
- "daily_profit",
- "player_win_loss",
- "player_transfer",
- "hot_number_risk",
- "play_dimension",
- "sold_out_number",
- "rebate_commission",
- "admin_audit",
-]);
-
export function buildReportJobParameters(
key: ReportUiKey,
filters: ReportFilterSnapshot,
diff --git a/src/modules/dashboard/agent-dashboard-console.tsx b/src/modules/dashboard/agent-dashboard-console.tsx
index 8513116..cdac8d2 100644
--- a/src/modules/dashboard/agent-dashboard-console.tsx
+++ b/src/modules/dashboard/agent-dashboard-console.tsx
@@ -10,40 +10,45 @@ import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { useCachedPlayTypeOptions } from "@/hooks/use-cached-play-type-options";
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
+import { adminHasAnyPermission } from "@/lib/admin-permissions";
+import { PRD_DASHBOARD_ANALYTICS_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 { adminWeekdayKeyForDate, formatAdminBusinessDateIso, formatAdminCalendarToday } from "@/lib/admin-datetime";
import { cn } from "@/lib/utils";
import { useAdminProfile } from "@/stores/admin-session";
+import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
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 {
+ DashboardKpiCard,
+ DashboardScopeMetric,
+ DashboardSignedStatRow,
+ DashboardStatRow,
+} from "@/modules/dashboard/dashboard-visuals";
import {
formatDashboardCreditMajor,
formatDashboardMoneyMinor,
- formatDashboardSignedMoneyMinor,
} from "@/modules/dashboard/use-dashboard-analytics";
-import type { AdminDashboardAgentOverview } from "@/types/api/admin-dashboard";
+import type { AdminDashboardAgentOverview, AdminDashboardWarning } from "@/types/api/admin-dashboard";
import type { DrawCurrentSnapshot } from "@/types/api/public-draw";
import { LotteryApiBizError } from "@/types/api/errors";
-function AgentMetric({
- label,
- value,
-}: {
- label: string;
- value: string;
-}): ReactElement {
- return (
-
- {label}
- {value}
-
- );
+function buildTodayBetHint(
+ businessDate: string,
+ latestBetAt: string | null,
+ t: (key: string, opts?: Record) => string,
+ formatDt: (iso: string) => string,
+): string {
+ const dateHint = t("todayBusinessDateHint", { date: businessDate });
+ if (latestBetAt) {
+ return `${dateHint} · ${t("agent.latestBetAt", { time: formatDt(latestBetAt) })}`;
+ }
+
+ return `${dateHint} · ${t("agent.noBetToday")}`;
}
export function AgentDashboardConsole(): ReactElement {
@@ -52,6 +57,9 @@ export function AgentDashboardConsole(): ReactElement {
const formatDt = useAdminDateTimeFormatter();
const profile = useAdminProfile();
const agent = profile?.agent ?? null;
+ const permissions = useMemo(() => profile?.permissions ?? [], [profile?.permissions]);
+ const businessDateToday = useMemo(() => formatAdminBusinessDateIso(), []);
+
const todayLabel = useMemo(() => {
const locale = normalizeAdminLanguage(i18n.resolvedLanguage ?? i18n.language);
const weekday = t(`date.weekdays.${adminWeekdayKeyForDate()}`, { ns: "common" });
@@ -65,10 +73,10 @@ export function AgentDashboardConsole(): ReactElement {
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState(null);
+ const [apiWarnings, setApiWarnings] = useState([]);
const [hall, setHall] = useState(null);
const [drawId, setDrawId] = useState(null);
const [overview, setOverview] = useState(null);
- const [canFinance, setCanFinance] = useState(false);
const analyticsScope = useMemo(
() => ({
@@ -78,6 +86,8 @@ export function AgentDashboardConsole(): ReactElement {
[agent?.id, agent?.site_code],
);
+ const canAnalytics = adminHasAnyPermission(permissions, [...PRD_DASHBOARD_ANALYTICS_ACCESS_ANY]);
+
const load = useCallback(async (isRefresh = false) => {
if (isRefresh) {
setRefreshing(true);
@@ -90,7 +100,7 @@ export function AgentDashboardConsole(): ReactElement {
const d = await getAdminDashboard();
setHall(d.hall);
setOverview(d.agent_overview);
- setCanFinance(d.capabilities.draw_finance_risk);
+ setApiWarnings(d.warnings ?? []);
if (d.resolved_draw != null) {
setDrawId(d.resolved_draw.id);
} else {
@@ -110,8 +120,7 @@ export function AgentDashboardConsole(): ReactElement {
void load(false);
}, []);
- const currency = "NPR";
- const displayCurrency = overview?.currency_code ?? currency;
+ const displayCurrency = overview?.currency_code ?? "NPR";
return (
@@ -144,31 +153,36 @@ export function AgentDashboardConsole(): ReactElement {
) : null}
+ {!loading && apiWarnings.length > 0 ? (
+
+ {t("notice")}
+ {apiWarnings.map((w) => w.message).join(" ")}
+
+ ) : null}
+
{loading ? (
-
+
{Array.from({ length: 4 }).map((_, i) => (
))}
) : overview ? (
-
+
}
- hint={
- overview.latest_bet_at
- ? t("agent.latestBetAt", { time: formatDt(overview.latest_bet_at) })
- : t("agent.noBetToday")
- }
+ hint={buildTodayBetHint(businessDateToday, overview.latest_bet_at, t, formatDt)}
/>
}
- hint={t("agent.shareRate", { rate: overview.total_share_rate })}
- valueClassName={signedMoneyClass(overview.today_profit_minor, true)}
+ hint={`${t("agent.shareRate", { rate: overview.total_share_rate })} · ${t("todayPayoutHint", {
+ amount: formatDashboardMoneyMinor(overview.today_payout_minor, displayCurrency),
+ })}`}
/>
-
+
{formatDashboardCreditMajor(overview.credit_limit, displayCurrency)}
@@ -202,19 +216,15 @@ export function AgentDashboardConsole(): ReactElement {
})}
-
-
+
-
-
{t("agent.lineMeta", {
@@ -231,24 +241,21 @@ export function AgentDashboardConsole(): ReactElement {
{t("agent.sevenDayTitle")}
-
-
- {t("agent.todayBet")}
-
- {formatDashboardMoneyMinor(overview.seven_day_bet_minor, displayCurrency)}
-
-
-
- {t("agent.todayShareProfit")}
-
- {formatDashboardSignedMoneyMinor(overview.seven_day_profit_minor, displayCurrency)}
-
-
+
+
+
+
+ {t("agent.shareProfitScopeHint")}
@@ -257,19 +264,19 @@ export function AgentDashboardConsole(): ReactElement {
{t("agent.teamTitle")}
-
-
-
-
@@ -277,7 +284,11 @@ export function AgentDashboardConsole(): ReactElement {
- ) : null}
+ ) : (
+
+ {t("agent.overviewEmpty")}
+
+ )}
- {canFinance ? (
+ {canAnalytics ? (
- ) : (
-
- {t("notice")}
- {t("warnings.drawPermission")}
-
- )}
+ ) : !loading ? (
+ {t("warnings.analyticsUnavailable")}
+ ) : null}
);
}
diff --git a/src/modules/dashboard/dashboard-analytics-panel.tsx b/src/modules/dashboard/dashboard-analytics-panel.tsx
index fc7f64c..8dbe338 100644
--- a/src/modules/dashboard/dashboard-analytics-panel.tsx
+++ b/src/modules/dashboard/dashboard-analytics-panel.tsx
@@ -21,7 +21,7 @@ import {
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { getAdminRequestLocale } from "@/lib/admin-locale";
-import { signedMoneyClass } from "@/lib/admin-signed-money";
+import { signedMoneyClass, signedTrendClassName } from "@/lib/admin-signed-money";
import { cn } from "@/lib/utils";
import { DashboardKpiCard } from "@/modules/dashboard/dashboard-visuals";
import { DASHBOARD_CHART_COLORS } from "@/modules/dashboard/dashboard-chart-config";
@@ -56,16 +56,8 @@ function computeDeltaPercent(series: number[]): string | null {
return `${sign} ${Math.abs(pct).toFixed(1)}%`;
}
-function deltaClassName(series: number[]): string {
- if (series.length < 2) {
- return "text-muted-foreground";
- }
- const last = series[series.length - 1];
- const prev = series[series.length - 2];
- if (last >= prev) {
- return "text-emerald-600 dark:text-emerald-400";
- }
- return "text-destructive";
+function deltaClassName(series: number[], mode: "higherBetter" | "lowerBetter" | "signed"): string {
+ return signedTrendClassName(series, mode);
}
export function DashboardAnalyticsMain({ analytics }: { analytics: DashboardAnalyticsState }): ReactNode {
@@ -190,13 +182,13 @@ export function DashboardAnalyticsMain({ analytics }: { analytics: DashboardAnal
) : null}
{loading ? (
-
+
{Array.from({ length: 3 }).map((_, i) => (
))}
) : summary ? (
-
+
+
{computeDeltaPercent(sparklines.bet)}
) : undefined
@@ -229,7 +221,7 @@ export function DashboardAnalyticsMain({ analytics }: { analytics: DashboardAnal
sparklineValues={sparklines.payout}
deltaLabel={
computeDeltaPercent(sparklines.payout) ? (
-
+
{computeDeltaPercent(sparklines.payout)}
) : undefined
@@ -241,8 +233,8 @@ export function DashboardAnalyticsMain({ analytics }: { analytics: DashboardAnal
? t("analytics.summaryShareProfit")
: t("analytics.summaryProfit")
}
- value={formatSignedMoney(summary.approx_house_gross_minor, currency)}
- valueClassName={signedMoneyClass(summary.approx_house_gross_minor, true)}
+ signedAmountMinor={summary.approx_house_gross_minor}
+ currencyCode={currency}
hint={
profitScope === "share_profit"
? t("analytics.shareProfitHint")
@@ -256,7 +248,7 @@ export function DashboardAnalyticsMain({ analytics }: { analytics: DashboardAnal
sparklineValues={sparklines.profit}
deltaLabel={
computeDeltaPercent(sparklines.profit) ? (
-
+
{computeDeltaPercent(sparklines.profit)}
) : undefined
diff --git a/src/modules/dashboard/dashboard-console.tsx b/src/modules/dashboard/dashboard-console.tsx
index bf46959..d089d74 100644
--- a/src/modules/dashboard/dashboard-console.tsx
+++ b/src/modules/dashboard/dashboard-console.tsx
@@ -4,7 +4,7 @@ import dynamic from "next/dynamic";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
-import { AlertTriangle, ClipboardList, RefreshCw, Shield, Wallet } from "lucide-react";
+import { AlertTriangle, BarChart3, ClipboardList, RefreshCw, Shield, TrendingUp, Wallet } from "lucide-react";
import { getAdminDashboardByScope } from "@/api/admin-dashboard";
import { useAdminPlayTypeCatalog } from "@/hooks/use-admin-play-type-catalog";
@@ -18,6 +18,9 @@ import {
} from "@/modules/dashboard/dashboard-analytics-panel";
import { DashboardCurrentDrawCard } from "@/modules/dashboard/dashboard-current-draw-card";
import { useDashboardAnalytics } from "@/modules/dashboard/use-dashboard-analytics";
+import {
+ formatDashboardMoneyMinor,
+} from "@/modules/dashboard/use-dashboard-analytics";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { Button, buttonVariants } from "@/components/ui/button";
@@ -39,12 +42,18 @@ import type {
AdminDashboardLifetimeFinance,
AdminDashboardPlatformRisk,
AdminDashboardResultBatchQueue,
+ AdminDashboardTodayFinance,
+ AdminDashboardWarning,
} from "@/types/api/admin-dashboard";
import type { AdminDrawFinanceSummaryData } from "@/types/api/admin-draw-finance";
import type { AdminRiskPoolRow } from "@/types/api/admin-risk";
import type { DrawCurrentSnapshot } from "@/types/api/public-draw";
// recharts 图表组件懒加载,避免 ~200KB 进入主 bundle
+const DashboardKpiCard = dynamic(
+ () => import("@/modules/dashboard/dashboard-visuals").then((m) => ({ default: m.DashboardKpiCard })),
+ { ssr: false },
+);
const DashboardPanelCard = dynamic(
() => import("@/modules/dashboard/dashboard-visuals").then((m) => ({ default: m.DashboardPanelCard })),
{ ssr: false },
@@ -172,6 +181,8 @@ export function DashboardConsole(): ReactElement {
const [lifetimeFinance, setLifetimeFinance] = useState(
null,
);
+ const [todayFinance, setTodayFinance] = useState(null);
+ const [apiWarnings, setApiWarnings] = useState([]);
const [platformRisk, setPlatformRisk] = useState(null);
const [riskLocked, setRiskLocked] = useState(0);
const [riskCap, setRiskCap] = useState(0);
@@ -193,6 +204,8 @@ export function DashboardConsole(): ReactElement {
setDrawPanel(null);
setResultBatchQueue(null);
setLifetimeFinance(null);
+ setTodayFinance(null);
+ setApiWarnings([]);
setPlatformRisk(null);
setDrawId(null);
setRiskLocked(0);
@@ -214,6 +227,8 @@ export function DashboardConsole(): ReactElement {
}
setResultBatchQueue(d.result_batch_queue);
setLifetimeFinance(d.lifetime_finance);
+ setTodayFinance(d.today_finance);
+ setApiWarnings(d.warnings ?? []);
setPlatformRisk(d.platform_risk);
if (d.draw != null) {
setDrawPanel(d.draw);
@@ -239,7 +254,10 @@ export function DashboardConsole(): ReactElement {
}, []);
const currency =
- lifetimeFinance?.currency_code ?? finance?.currency_code ?? null;
+ todayFinance?.currency_code
+ ?? lifetimeFinance?.currency_code
+ ?? finance?.currency_code
+ ?? null;
const canFinance = capabilities?.draw_finance_risk ?? false;
const platformLocked = coerceAdminMinor(platformRisk?.locked_amount);
const platformCap = coerceAdminMinor(platformRisk?.cap_amount);
@@ -296,6 +314,13 @@ export function DashboardConsole(): ReactElement {
) : null}
+ {!loading && apiWarnings.length > 0 ? (
+
+ {t("notice")}
+ {apiWarnings.map((w) => w.message).join(" ")}
+
+ ) : null}
+
+
+ {canFinance && !loading && todayFinance ? (
+
+ }
+ hint={t("todayBusinessDateHint", { date: todayFinance.business_date })}
+ />
+ }
+ hint={t("todayPayoutHint", {
+ amount: formatDashboardMoneyMinor(todayFinance.total_payout_minor, currency),
+ })}
+ />
+ }
+ hint={
+ lifetimeFinance
+ ? t("lifetimeActivityHint", {
+ draws: lifetimeFinance.draw_count,
+ days: lifetimeFinance.business_day_count,
+ })
+ : undefined
+ }
+ value={lifetimeFinance ? undefined : "—"}
+ />
+
+ ) : null}
+
@@ -345,16 +406,6 @@ export function DashboardConsole(): ReactElement {
href="/admin/risk"
title={t("riskCapUsage")}
value={`${platformUsagePct.toFixed(1)}%`}
- subtitle={
- platformCap > 0
- ? t("platformLockedAndCap", {
- locked: formatMoneyMinor(platformLocked, currency),
- cap: formatMoneyMinor(platformCap, currency),
- })
- : t("platformCapNotConfigured", {
- locked: formatMoneyMinor(platformLocked, currency),
- })
- }
actionLabel={t("occupancyDetails")}
icon={ }
accent={
@@ -380,7 +431,7 @@ export function DashboardConsole(): ReactElement {
+ {label}
+
+ {value}
+
+
+ );
+}
+
+/** 盈亏行:正绿、负红、零灰(与 {@link signedMoneyClass} 一致) */
+export function DashboardSignedStatRow({
+ label,
+ amountMinor,
+ currencyCode,
+}: {
+ label: string;
+ amountMinor: number;
+ currencyCode: string | null;
+}): ReactElement {
+ return (
+
+ {label}
+
+ {formatDashboardSignedMoneyMinor(amountMinor, currencyCode)}
+
+
+ );
+}
+
+/** 规模/授信等栅格指标,金额可换行 */
+export function DashboardScopeMetric({
+ label,
+ value,
+}: {
+ label: string;
+ value: string;
+}): ReactElement {
+ return (
+
+ {label}
+
+ {value}
+
+
+ );
+}
+
/** 财务概览区紧凑 KPI,避免 StatCard 在窄栅格内撑破布局 */
export function DashboardKpiCard({
label,
@@ -187,43 +256,60 @@ export function DashboardKpiCard({
icon,
accent = "primary",
valueClassName,
+ signedAmountMinor,
+ currencyCode,
sparklineValues,
deltaLabel,
}: {
label: string;
- value: ReactNode;
+ value?: ReactNode;
hint?: ReactNode;
icon: ReactNode;
accent?: DashboardKpiAccent;
/** 覆盖主数值颜色(如盈亏红绿) */
valueClassName?: string;
+ /** 盈亏类 KPI:自动带 +/- 与红绿 */
+ signedAmountMinor?: number;
+ currencyCode?: string | null;
sparklineValues?: number[];
deltaLabel?: ReactNode;
}): ReactElement {
+ const resolvedValue =
+ typeof signedAmountMinor === "number" && currencyCode !== undefined
+ ? formatDashboardSignedMoneyMinor(signedAmountMinor, currencyCode)
+ : value;
+ const resolvedValueClassName =
+ typeof signedAmountMinor === "number"
+ ? signedMoneyClass(signedAmountMinor, true)
+ : valueClassName;
+ const valueTitle =
+ typeof resolvedValue === "string" || typeof resolvedValue === "number"
+ ? String(resolvedValue)
+ : undefined;
+
return (
-
+
+ {label}
{icon}
-
- {label}
-
- {value}
-
- {deltaLabel ? {deltaLabel} : null}
-
+
+ {resolvedValue}
+
+ {deltaLabel ? {deltaLabel} : null}
{sparklineValues && sparklineValues.length >= 2 ? (
) : null}
{hint ? (
- {hint}
+ {hint}
) : null}
);
@@ -477,9 +563,7 @@ export function DashboardPanelCard({
)}
{subtitle && !loading ? (
-
- {subtitle}
-
+ {subtitle}
) : null}
@@ -621,19 +705,48 @@ export function CapUsageBar({
const radialData = useMemo(() => [{ usage: pct, fill }], [pct, fill]);
if (compact) {
+ const lockedLabel = formatMoney(locked, currency);
+ const capLabel = cap > 0 ? formatMoney(cap, currency) : t("platformCapUnset");
+
return (
-
+
+
+
+
+ {t("platformLockedLabel")}
+
+
+ {lockedLabel}
+
+
+
+
+ {t("platformCapLabel")}
+
+
+ {capLabel}
+
+
+
+ className="h-2 overflow-hidden rounded-full bg-muted"
+ role="progressbar"
+ aria-valuenow={pct}
+ aria-valuemin={0}
+ aria-valuemax={100}
+ aria-label={t("riskCapUsage")}
+ >
+
+
);
}
@@ -741,6 +854,12 @@ export function FinanceStructureChart({
{t("payoutRateOfBet", { rate: payoutRate })}
+
+ {t("houseGross")}
+
+ {formatDashboardSignedMoneyMinor(gross, currency)}
+
+
} />
);
diff --git a/src/modules/dashboard/site-dashboard-console.tsx b/src/modules/dashboard/site-dashboard-console.tsx
index 57ec288..9dacfe2 100644
--- a/src/modules/dashboard/site-dashboard-console.tsx
+++ b/src/modules/dashboard/site-dashboard-console.tsx
@@ -10,40 +10,43 @@ 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 { PRD_DASHBOARD_ANALYTICS_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 { adminWeekdayKeyForDate, formatAdminBusinessDateIso, formatAdminCalendarToday } from "@/lib/admin-datetime";
import { cn } from "@/lib/utils";
import { useAdminProfile } from "@/stores/admin-session";
+import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
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 {
+ DashboardKpiCard,
+ DashboardScopeMetric,
+ DashboardSignedStatRow,
+ DashboardStatRow,
+} from "@/modules/dashboard/dashboard-visuals";
import {
formatDashboardMoneyMinor,
- formatDashboardSignedMoneyMinor,
} from "@/modules/dashboard/use-dashboard-analytics";
-import type { AdminDashboardSiteOverview } from "@/types/api/admin-dashboard";
+import type { AdminDashboardSiteOverview, AdminDashboardWarning } from "@/types/api/admin-dashboard";
import type { DrawCurrentSnapshot } from "@/types/api/public-draw";
import { LotteryApiBizError } from "@/types/api/errors";
-function SiteMetric({
- label,
- value,
-}: {
- label: string;
- value: string;
-}): ReactElement {
- return (
-
- {label}
- {value}
-
- );
+function buildTodayBetHint(
+ businessDate: string,
+ latestBetAt: string | null,
+ t: (key: string, opts?: Record) => string,
+ formatDt: (iso: string) => string,
+): string {
+ const dateHint = t("todayBusinessDateHint", { date: businessDate });
+ if (latestBetAt) {
+ return `${dateHint} · ${t("site.latestBetAt", { time: formatDt(latestBetAt) })}`;
+ }
+
+ return `${dateHint} · ${t("site.noBetToday")}`;
}
export function SiteDashboardConsole(): ReactElement {
@@ -53,6 +56,7 @@ export function SiteDashboardConsole(): ReactElement {
const profile = useAdminProfile();
const site = profile?.site ?? null;
const permissions = useMemo(() => profile?.permissions ?? [], [profile?.permissions]);
+ const businessDateToday = useMemo(() => formatAdminBusinessDateIso(), []);
const todayLabel = useMemo(() => {
const locale = normalizeAdminLanguage(i18n.resolvedLanguage ?? i18n.language);
@@ -66,6 +70,7 @@ export function SiteDashboardConsole(): ReactElement {
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState(null);
+ const [apiWarnings, setApiWarnings] = useState([]);
const [hall, setHall] = useState(null);
const [drawId, setDrawId] = useState(null);
const [overview, setOverview] = useState(null);
@@ -78,7 +83,7 @@ export function SiteDashboardConsole(): ReactElement {
[overview?.site_code, site?.code],
);
- const canAnalytics = adminHasAnyPermission(permissions, [...PRD_REPORTS_VIEW_ACCESS_ANY]);
+ const canAnalytics = adminHasAnyPermission(permissions, [...PRD_DASHBOARD_ANALYTICS_ACCESS_ANY]);
const load = useCallback(async (isRefresh = false) => {
if (isRefresh) {
@@ -92,6 +97,7 @@ export function SiteDashboardConsole(): ReactElement {
const d = await getAdminDashboard();
setHall(d.hall);
setOverview(d.site_overview);
+ setApiWarnings(d.warnings ?? []);
if (d.resolved_draw != null) {
setDrawId(d.resolved_draw.id);
} else {
@@ -144,31 +150,36 @@ export function SiteDashboardConsole(): ReactElement {
) : null}
+ {!loading && apiWarnings.length > 0 ? (
+
+ {t("notice")}
+ {apiWarnings.map((w) => w.message).join(" ")}
+
+ ) : null}
+
{loading ? (
-
+
{Array.from({ length: 4 }).map((_, i) => (
))}
) : overview ? (
-
+
}
- hint={
- overview.latest_bet_at
- ? t("site.latestBetAt", { time: formatDt(overview.latest_bet_at) })
- : t("site.noBetToday")
- }
+ hint={buildTodayBetHint(businessDateToday, overview.latest_bet_at, t, formatDt)}
/>
}
- hint={t("site.profitScopeHint")}
- valueClassName={signedMoneyClass(overview.today_profit_minor, true)}
+ hint={t("todayPayoutHint", {
+ amount: formatDashboardMoneyMinor(overview.today_payout_minor, displayCurrency),
+ })}
/>
{t("site.sevenDayTitle")}
-
-
- {t("site.todayBet")}
-
- {formatDashboardMoneyMinor(overview.seven_day_bet_minor, displayCurrency)}
-
-
-
- {t("site.sevenDayProfit")}
-
- {formatDashboardSignedMoneyMinor(overview.seven_day_profit_minor, displayCurrency)}
-
-
+
+
+
+
+ {t("site.profitScopeHint")}
@@ -218,24 +226,31 @@ export function SiteDashboardConsole(): ReactElement {
{t("site.scaleTitle")}
-
-
+
+
{overview.top_agent_today ? (
-
- {t("site.topAgentToday", {
- name: overview.top_agent_today.agent_name || overview.top_agent_today.agent_code,
- amount: formatDashboardMoneyMinor(
+
+ {t("site.topAgentTodayLabel")}
+
+ {overview.top_agent_today.agent_name || overview.top_agent_today.agent_code}
+
+
+ {formatDashboardMoneyMinor(
overview.top_agent_today.total_bet_minor,
displayCurrency,
- ),
- })}
+ )}
+
) : null}
- ) : null}
+ ) : (
+
+ {t("site.overviewEmpty")}
+
+ )}
{
- if (rows.length === 0) {
- throw new LotteryApiBizError("no_data", -1, null);
- }
-
- if (format === "csv") {
- const headers = Object.keys(rows[0]);
- const lines = [
- headers.map(toCsvValue).join(","),
- ...rows.map((row) => headers.map((header) => toCsvValue(row[header] ?? "")).join(",")),
- ];
- const blob = new Blob([`\uFEFF${lines.join("\n")}`], { type: "text/csv;charset=utf-8;" });
- downloadBlob(blob, `${filename}.csv`);
- return;
- }
-
- const XLSX = await import("xlsx");
- const worksheet = XLSX.utils.json_to_sheet(rows);
- const workbook = XLSX.utils.book_new();
- XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
- XLSX.writeFile(workbook, `${filename}.xlsx`);
-}
-
function buildDailyProfitRowsAndSummary(
items: AdminReportDailyProfitRow[],
total: number,
@@ -742,7 +675,7 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
}, [filteredReports, selectedKey]);
const pageScopedLabel = useCallback(
- (statKey: string) => `${t(`preview.stats.${statKey}`)} · ${t("preview.scope.currentPage")}`,
+ (statKey: string) => t(`preview.stats.${statKey}`),
[t],
);
@@ -1233,9 +1166,7 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
setPage(1);
}
- const usesServerExport = REPORT_UI_SERVER_FULL_EXPORT.has(selectedReport.key as ReportUiKey);
-
- async function exportViaServer(format: ExportFormat): Promise {
+ async function exportReport(format: ExportFormat): Promise {
if (!canExportReports) {
return;
}
@@ -1260,10 +1191,9 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
const ext = job.export_format === "xlsx" ? "xlsx" : "csv";
downloadBlob(blob, filename ?? `${exportFileBase}.${ext}`);
toast.success(
- t("exportServerSuccess", {
+ t("exportSuccess", {
report: t(`items.${selectedReport.key}.title`),
format: t(`formats.${format}`),
- jobNo: job.job_no,
}),
);
} catch (err) {
@@ -1273,36 +1203,6 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
}
}
- function exportPreview(format: ExportFormat): void {
- if (!canExportReports) {
- return;
- }
- if (!result || result.rows.length === 0) {
- toast.info(t("empty"));
- return;
- }
- setExporting(format);
- try {
- exportRows(result.rows, exportFileBase, t(`items.${selectedReport.key}.title`), format);
- toast.success(t("exportSuccess", { report: t(`items.${selectedReport.key}.title`), format: t(`formats.${format}`) }));
- } catch (err) {
- toast.error(err instanceof LotteryApiBizError ? err.message : t("exportFailed"));
- } finally {
- setExporting(null);
- }
- }
-
- function exportReport(format: ExportFormat): void {
- if (!canExportReports) {
- return;
- }
- if (usesServerExport) {
- void exportViaServer(format);
- return;
- }
- exportPreview(format);
- }
-
const renderSearchPicker = (kind: SearchKind) => {
const value =
kind === "draw" ? filters.drawNo : kind === "player" ? filters.player : filters.operator;
@@ -1726,21 +1626,13 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
})}
{t(`items.${selectedReport.key}.summary`)}
- {reportTimeAxisKey(selectedReport.key) ? (
- {t(`timeAxis.${reportTimeAxisKey(selectedReport.key)}`)}
- ) : null}
{selectedReport.fields.map(renderField)}
-
-
- {t("filterPanel")}
- {t("queryHint")}
-
-
+
@@ -1756,7 +1648,6 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
{loading ? t("querying") : t("query")}
-
@@ -1770,70 +1661,34 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
))}
- {reportDisclaimerKey(selectedReport.key) ? (
-
- {t(reportDisclaimerKey(selectedReport.key)!)}
-
- ) : null}
-
{t("preview.title")}
-
-
-
-
-
- {result && result.rows.length > 0 ? (
- <>
- {t("exportPreviewHint")}
-
-
-
-
- >
- ) : null}
-
-
-
-
- {t("preview.summaryScopeHint")}
+
+
+
+
+