feat(dashboard, i18n): enhance agent dashboard and localization support

Updated the agent dashboard to include new metrics for today's bets and payouts, improving visibility for users. Enhanced localization files with additional hints and labels for better user experience across English, Nepali, and Chinese. Introduced new functions for formatting business dates and improved the handling of analytics permissions in the dashboard components.
This commit is contained in:
2026-06-16 14:18:58 +08:00
parent a4454a54a4
commit d4cf4ff436
17 changed files with 534 additions and 415 deletions

View File

@@ -34,18 +34,19 @@ This version has breaking changes — APIs, conventions, and file structure may
- 占成/授信/回水/上限等数值字段用 `AdminNumericStepper`(± 步进 + 可手输),勿单独裸 `input[type=number]` - 占成/授信/回水/上限等数值字段用 `AdminNumericStepper`(± 步进 + 可手输),勿单独裸 `input[type=number]`
- 对外文档(接入 + 后台运营手册)禁用 RBAC slug`prd.settlement.agent.manage`);对客户称「贵司」;排版忌 AI 感,正文对比度与字号可读优先。 - 对外文档(接入 + 后台运营手册)禁用 RBAC slug`prd.settlement.agent.manage`);对客户称「贵司」;排版忌 AI 感,正文对比度与字号可读优先。
- 文档 i18n`useTranslation` 须显式 `ns``returnObjects` 列表用 `Array.isArray` 守卫;避免节名与表头 key 冲突(如 `billStatus`)。 - 文档 i18n`useTranslation` 须显式 `ns``returnObjects` 列表用 `Array.isArray` 守卫;避免节名与表头 key 冲突(如 `billStatus`)。
- 运营页少堆 `text-xs` 说明与多层免责条;口径/默认范围合并进各模块一行 `summary`,勿叠加 filterPanel/queryHint/disclaimer 等小字(对账、报表中心已按此精简)。
- i18n 按语言懒加载,不要一次 import 三语全套。
## Learned Workspace Facts ## Learned Workspace Facts
- 无接入站时依赖站点的页面展示 `<AdminNoIntegrationSiteState />`;仅 `profile.is_super_admin` 显示创建入口。 - 无接入站时依赖站点的页面展示 `<AdminNoIntegrationSiteState />`;仅 `profile.is_super_admin` 显示创建入口。
- 超管判定用登录态 `is_super_admin`,勿用站点角色或 `admin_user_site_roles` 绑定推断。 - 超管判定用登录态 `is_super_admin`,勿用站点角色或 `admin_user_site_roles` 绑定推断。
- 站点管理员(`profile.site != null`)代理 UI 绕过选中代理的 `can_create_*` 门控,按自身 manage 权限展示 Tab/操作。 - 站点管理员(`profile.site != null`)代理 UI 绕过选中代理的 `can_create_*` 门控,按自身 manage 权限展示 Tab/操作;在代理下创建玩家须传 `agent_node_id`,勿默认挂根代理
- 站点管理员在代理下创建玩家须传 `agent_node_id`(与超管同逻辑),勿默认挂根代理 - 客户对外文档:`/docs``/docs/integration``/docs/admin` 公开免登录;读者为接入方与站点运营/代理;顶栏「管理后台」链 `/admin`SSO 无登录换票、JWT+`player/me`、iframe `data.token`Docs 侧栏 `--docs-sticky-top`;静态校验用 `document.body.innerText`
- 客户对外文档:`/docs`(首页)、`/docs/integration`API 接入)、`/docs/admin`(后台运营手册),均公开免 `/admin` 登录;读者为接入方技术与站点运营/代理,非内部运维;旧 `/admin/docs/integration-guide` 重定向 `/docs/integration`;顶栏「管理后台」链 `/admin`(勿 `/admin/login`)。
- `SettlementBillRow``currency_code`;账单金额展示用玩家 `default_currency` - `SettlementBillRow``currency_code`;账单金额展示用玩家 `default_currency`
- 浏览器 `/api/v1/*` 由 Next 转发到 `LOTTERY_API_UPSTREAM`;与本地 Postgres 对账前须确认 upstream 与所查库一致。 - 浏览器 `/api/v1/*` 由 Next 转发到 `LOTTERY_API_UPSTREAM`;与本地 Postgres 对账前须确认 upstream 与所查库一致。
- 接入文档页静态内容校验用 `document.body.innerText``DocCode` 非裸 `<pre>/<code>`,勿只查 code 选择器。
- Docs 侧栏粘性定位用 CSS 变量 `--docs-sticky-top`(含顶栏/header 偏移)。
- Tanumo 联调/生产默认H5 `https://front.tanumo.com`、API `https://lotterylaravel.tanumo.com`、管理端/文档 `https://lotteryadmin.tanumo.com` - 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` 流水号;补单/冲正在钱包转账单页。

View File

@@ -77,6 +77,7 @@
"todayPayout": "Today's payout", "todayPayout": "Today's payout",
"todayProfit": "Today's profit", "todayProfit": "Today's profit",
"todayBusinessDateHint": "Business date {{date}}", "todayBusinessDateHint": "Business date {{date}}",
"todayPayoutHint": "Payout {{amount}}",
"drawNoHint": "Draw {{drawNo}}", "drawNoHint": "Draw {{drawNo}}",
"orderAndTicket": "{{orders}} orders · {{tickets}} items", "orderAndTicket": "{{orders}} orders · {{tickets}} items",
"marginRate": "Gross margin ~{{rate}}%", "marginRate": "Gross margin ~{{rate}}%",
@@ -94,6 +95,9 @@
"batchPendingDraws": "Draws involved", "batchPendingDraws": "Draws involved",
"batchPendingDrawsCount": "{{count}} draws pending", "batchPendingDrawsCount": "{{count}} draws pending",
"platformLockedAndCap": "Site locked {{locked}} / cap {{cap}}", "platformLockedAndCap": "Site locked {{locked}} / cap {{cap}}",
"platformLockedLabel": "Locked",
"platformCapLabel": "Cap",
"platformCapUnset": "Not set",
"platformCapNotConfigured": "Site locked {{locked}} · cap not configured", "platformCapNotConfigured": "Site locked {{locked}} · cap not configured",
"platformOrderAndTicket": "Site-wide {{orders}} orders · {{tickets}} lines", "platformOrderAndTicket": "Site-wide {{orders}} orders · {{tickets}} lines",
"platformBetTotal": "Lifetime bet", "platformBetTotal": "Lifetime bet",
@@ -162,6 +166,8 @@
"todayBet": "Today's bets", "todayBet": "Today's bets",
"todayProfit": "Today's P/L", "todayProfit": "Today's P/L",
"sevenDayTitle": "Last 7 days", "sevenDayTitle": "Last 7 days",
"sevenDayBet": "7-day bets",
"sevenDayPayout": "7-day payout",
"sevenDayProfit": "7-day P/L", "sevenDayProfit": "7-day P/L",
"profitScopeHint": "Site scope: bets minus payouts", "profitScopeHint": "Site scope: bets minus payouts",
"activePlayersToday": "Active players today", "activePlayersToday": "Active players today",
@@ -174,6 +180,8 @@
"agentCount": "Agent nodes", "agentCount": "Agent nodes",
"playerCount": "Players", "playerCount": "Players",
"topAgentToday": "Top agent today: {{name}} ({{amount}})", "topAgentToday": "Top agent today: {{name}} ({{amount}})",
"topAgentTodayLabel": "Top agent today",
"overviewEmpty": "No site operations data. Confirm the integration site is bound.",
"quickLinks": { "quickLinks": {
"tickets": "Tickets", "tickets": "Tickets",
"players": "Players", "players": "Players",
@@ -194,6 +202,7 @@
"creditAllocatedLabel": "Allocated credit", "creditAllocatedLabel": "Allocated credit",
"creditUsedLabel": "Used credit", "creditUsedLabel": "Used credit",
"shareRate": "Total share {{rate}}%", "shareRate": "Total share {{rate}}%",
"shareProfitScopeHint": "Share profit at this node (from bet share_snapshot)",
"settlementCycle": "Cycle {{cycle}}", "settlementCycle": "Cycle {{cycle}}",
"teamTitle": "Team size", "teamTitle": "Team size",
"directChildren": "Direct child agents", "directChildren": "Direct child agents",
@@ -208,8 +217,11 @@
"todayProfit": "Today's profit", "todayProfit": "Today's profit",
"todayShareProfit": "Today's share profit", "todayShareProfit": "Today's share profit",
"sevenDayTitle": "Last 7 days", "sevenDayTitle": "Last 7 days",
"sevenDayBet": "7-day bets",
"sevenDayPayout": "Payout {{amount}}", "sevenDayPayout": "Payout {{amount}}",
"sevenDayPayoutLabel": "7-day payout",
"sevenDayProfit": "Profit {{amount}}", "sevenDayProfit": "Profit {{amount}}",
"sevenDayShareProfitLabel": "7-day share profit",
"sevenDayShareProfit": "Share profit {{amount}}", "sevenDayShareProfit": "Share profit {{amount}}",
"pendingBills": "Open agent bills", "pendingBills": "Open agent bills",
"pendingUnpaid": "Unpaid total {{amount}}", "pendingUnpaid": "Unpaid total {{amount}}",
@@ -230,6 +242,7 @@
"no": "No", "no": "No",
"viewBills": "View bills", "viewBills": "View bills",
"lineMeta": "Depth {{depth}} · child agents {{childAgent}} · players {{player}}", "lineMeta": "Depth {{depth}} · child agents {{childAgent}} · players {{player}}",
"overviewEmpty": "No line operations data. Confirm the agent binding is valid.",
"viewLine": "Agent line", "viewLine": "Agent line",
"quickLinks": { "quickLinks": {
"tickets": "Tickets", "tickets": "Tickets",
@@ -242,6 +255,7 @@
"warnings": { "warnings": {
"drawPermission": "This account has no draw/dashboard view permission. Finance and risk data were not returned.", "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.", "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.", "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." "apiResourceMissing": "Dashboard analytics API is not registered. Run: php artisan lottery:admin-auth-sync (or apply the latest migration), then refresh."
} }

View File

@@ -13,7 +13,7 @@
"exportSuccess": "Exported {{report}} ({{format}})", "exportSuccess": "Exported {{report}} ({{format}})",
"exportServerSuccess": "Job {{jobNo}} created and downloaded {{report}} ({{format}})", "exportServerSuccess": "Job {{jobNo}} created and downloaded {{report}} ({{format}})",
"exportFailed": "Export failed", "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)", "exportServerHint": "Full export via server job (preview query not required)",
"exportClientHint": "Export current preview page (run query first)", "exportClientHint": "Export current preview page (run query first)",
"validation": { "validation": {
@@ -281,19 +281,15 @@
}, },
"daily_profit": { "daily_profit": {
"title": "Daily P&L summary", "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": { "player_win_loss": {
"title": "Player win/loss report", "title": "Player win/loss report",
"summary": "Track player win/loss over a selected period for finance and support review." "summary": "Win/loss by player and business date; defaults to the last 30 days. Ticket amounts — not credit-line settlement."
},
"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."
}, },
"player_transfer": { "player_transfer": {
"title": "Player transfer report", "title": "Player transfer report",
"summary": "Review player transfers in, transfers out, reversals, and exception handling.", "summary": "Wallet-mode main-site transfers by record time; defaults to the last 30 days."
"disclaimer": "Main-site wallet transfer orders only (wallet-mode players). Credit-line players have no such transfers — use Settlement Center for period ledger."
}, },
"hot_number_risk": { "hot_number_risk": {
"title": "Hot number risk report", "title": "Hot number risk report",
@@ -301,7 +297,7 @@
}, },
"play_dimension": { "play_dimension": {
"title": "Play dimension report", "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": { "sold_out_number": {
"title": "Sold-out number report", "title": "Sold-out number report",
@@ -309,12 +305,11 @@
}, },
"rebate_commission": { "rebate_commission": {
"title": "Commission / rebate report", "title": "Commission / rebate report",
"summary": "Summarize commission, rebate, and matched rules by play and period.", "summary": "Wallet-mode instant rebate by business date; defaults to the last 30 days. Not credit-line period settlement."
"disclaimer": "Wallet-mode instant rebate/commission — not agent credit-line period settlement. Use Agent → Settlement bills for credit-line reports."
}, },
"admin_audit": { "admin_audit": {
"title": "Admin operation audit report", "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."
} }
} }
} }

View File

@@ -75,6 +75,7 @@
"todayPayout": "आजको भुक्तानी", "todayPayout": "आजको भुक्तानी",
"todayProfit": "आजको नाफा/नोक्सान", "todayProfit": "आजको नाफा/नोक्सान",
"todayBusinessDateHint": "व्यापार मिति {{date}}", "todayBusinessDateHint": "व्यापार मिति {{date}}",
"todayPayoutHint": "भुक्तानी {{amount}}",
"drawNoHint": "ड्रअ {{drawNo}}", "drawNoHint": "ड्रअ {{drawNo}}",
"orderAndTicket": "{{orders}} अर्डर · {{tickets}} वस्तु", "orderAndTicket": "{{orders}} अर्डर · {{tickets}} वस्तु",
"marginRate": "सकल मार्जिन ~{{rate}}%", "marginRate": "सकल मार्जिन ~{{rate}}%",
@@ -92,6 +93,9 @@
"batchPendingDraws": "सम्बन्धित ड्रअ", "batchPendingDraws": "सम्बन्धित ड्रअ",
"batchPendingDrawsCount": "{{count}} ड्रअ पेन्डिङ", "batchPendingDrawsCount": "{{count}} ड्रअ पेन्डिङ",
"platformLockedAndCap": "साइट लक {{locked}} / क्याप {{cap}}", "platformLockedAndCap": "साइट लक {{locked}} / क्याप {{cap}}",
"platformLockedLabel": "लक",
"platformCapLabel": "क्याप",
"platformCapUnset": "सेट छैन",
"platformCapNotConfigured": "साइट लक {{locked}} · क्याप कन्फिगर गरिएको छैन", "platformCapNotConfigured": "साइट लक {{locked}} · क्याप कन्फिगर गरिएको छैन",
"platformOrderAndTicket": "साइटव्यापी {{orders}} अर्डर · {{tickets}} लाइन", "platformOrderAndTicket": "साइटव्यापी {{orders}} अर्डर · {{tickets}} लाइन",
"platformBetTotal": "जम्मा बेट", "platformBetTotal": "जम्मा बेट",
@@ -159,6 +163,8 @@
"todayBet": "आजको बाजी", "todayBet": "आजको बाजी",
"todayProfit": "आजको नाफा/नोक्सान", "todayProfit": "आजको नाफा/नोक्सान",
"sevenDayTitle": "पछिल्लो ७ दिन", "sevenDayTitle": "पछिल्लो ७ दिन",
"sevenDayBet": "७-दिने बाजी",
"sevenDayPayout": "७-दिने भुक्तानी",
"sevenDayProfit": "७-दिने नाफा/नोक्सान", "sevenDayProfit": "७-दिने नाफा/नोक्सान",
"profitScopeHint": "साइट दायरा: बाजी माइनस भुक्तानी", "profitScopeHint": "साइट दायरा: बाजी माइनस भुक्तानी",
"activePlayersToday": "आज सक्रिय खेलाडी", "activePlayersToday": "आज सक्रिय खेलाडी",
@@ -171,6 +177,8 @@
"agentCount": "एजेन्ट नोड", "agentCount": "एजेन्ट नोड",
"playerCount": "खेलाडी संख्या", "playerCount": "खेलाडी संख्या",
"topAgentToday": "आजको शीर्ष एजेन्ट: {{name}} ({{amount}})", "topAgentToday": "आजको शीर्ष एजेन्ट: {{name}} ({{amount}})",
"topAgentTodayLabel": "आजको शीर्ष एजेन्ट",
"overviewEmpty": "साइट सञ्चालन डाटा छैन। कृपया साइट बाइन्डिङ जाँच गर्नुहोस्।",
"quickLinks": { "quickLinks": {
"tickets": "टिकट", "tickets": "टिकट",
"players": "खेलाडी", "players": "खेलाडी",
@@ -179,9 +187,41 @@
"bills": "सेटलमेन्ट" "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": { "warnings": {
"drawPermission": "यो खातासँग ड्रअ/ड्यासबोर्ड हेर्ने अनुमति छैन। वित्तीय र जोखिम डाटा फिर्ता आएन।", "drawPermission": "यो खातासँग ड्रअ/ड्यासबोर्ड हेर्ने अनुमति छैन। वित्तीय र जोखिम डाटा फिर्ता आएन।",
"walletPermission": "यो खातासँग वालेट मिलान हेर्ने अनुमति छैन। असामान्य ट्रान्सफर संख्या फिर्ता आएन।", "walletPermission": "यो खातासँग वालेट मिलान हेर्ने अनुमति छैन। असामान्य ट्रान्सफर संख्या फिर्ता आएन।",
"analyticsUnavailable": "ट्रेन्ड/र्याङ्किङ विश्लेषण उपलब्ध छैन। माथिको KPI हेर्न सकिन्छ।",
"loadFailed": "लोड असफल भयो। API र लगइन अवस्था जाँच गर्नुहोस्।" "loadFailed": "लोड असफल भयो। API र लगइन अवस्था जाँच गर्नुहोस्।"
} }
} }

View File

@@ -13,7 +13,7 @@
"exportSuccess": "{{report}} ({{format}}) निर्यात भयो", "exportSuccess": "{{report}} ({{format}}) निर्यात भयो",
"exportServerSuccess": "कार्य {{jobNo}} सिर्जना भई {{report}} ({{format}}) डाउनलोड भयो", "exportServerSuccess": "कार्य {{jobNo}} सिर्जना भई {{report}} ({{format}}) डाउनलोड भयो",
"exportFailed": "निर्यात असफल भयो", "exportFailed": "निर्यात असफल भयो",
"exportHint": "सर्भर निर्यातले हालका फिल्टरअनुसार पूर्ण फाइल बनाउँछ। सम्पन्न कार्य तलबाट पुन: डाउनलोड गर्न सकिन्छ।", "exportHint": "हालका फिल्टरअनुसार पूर्ण डाटा निर्यात; पूर्वावलोकन क्वेरी अनिवार्य छैन।",
"exportServerHint": "पूर्ण निर्यात सर्भर कार्यबाट (पूर्वावलोकन क्वेरी अनिवार्य छैन)", "exportServerHint": "पूर्ण निर्यात सर्भर कार्यबाट (पूर्वावलोकन क्वेरी अनिवार्य छैन)",
"exportClientHint": "हालको पूर्वावलोकन पृष्ठ निर्यात (पहिले क्वेरी चलाउनुहोस्)", "exportClientHint": "हालको पूर्वावलोकन पृष्ठ निर्यात (पहिले क्वेरी चलाउनुहोस्)",
"validation": { "validation": {
@@ -278,19 +278,15 @@
}, },
"daily_profit": { "daily_profit": {
"title": "दैनिक P&L सारांश", "title": "दैनिक P&L सारांश",
"summary": "व्यावसायिक मितिअनुसार बेट रकम, पेआउट र हाउस P&L सारांश गर्नुहोस्। रिफन्ड र छुट्टै नेट रकम अहिले समावेश छैन।" "summary": "व्यावसायिक मितिअनुसार बेट P&L; मिति नचयेमा पछिल्लो ३० दिन। टिकट रकम — क्रेडिट-लाइन सेटलमेन्ट होइन।"
}, },
"player_win_loss": { "player_win_loss": {
"title": "खेलाडी जित/हार रिपोर्ट", "title": "खेलाडी जित/हार रिपोर्ट",
"summary": "चयन गरिएको अवधिमा खेलाडीको जित/हार वित्त र सपोर्टका लागि हेर्नुहोस्।" "summary": "खेलाडी र व्यावसायिक मितिअनुसार जित/हार; मिति नचयेमा पछिल्लो ३० दिन।"
},
"profit_reports": {
"disclaimer": "यी रिपोर्ट टिकट बेट/जित रकम (बेटिङ नतिजा) मा आधारित छन्, क्रेडिट-लाइन अवधि सेटलमेन्ट होइन। शेयर, रिबेट र सङ्कलनका लागि सेटलमेन्ट सेन्टर → अवधि रिपोर्ट प्रयोग गर्नुहोस्।"
}, },
"player_transfer": { "player_transfer": {
"title": "खेलाडी ट्रान्सफर रिपोर्ट", "title": "खेलाडी ट्रान्सफर रिपोर्ट",
"summary": "खेलाडी ट्रान्सफर इन, आउट, रिभर्सल र अपवाद रेकर्ड हेर्नुहोस्।", "summary": "वालेट-मोड मुख्य साइट ट्रान्सफर, रेकर्ड समय अनुसार; मिति नचयेमा पछिल्लो ३० दिन।"
"disclaimer": "मुख्य साइट वालेट ट्रान्सफर मात्र (वालेट-मोड खेलाडी)। क्रेडिट-लाइन खेलाडीमा यस्तो ट्रान्सफर हुँदैन — अवधि लेजरका लागि सेटलमेन्ट सेन्टर प्रयोग गर्नुहोस्।"
}, },
"hot_number_risk": { "hot_number_risk": {
"title": "हट नम्बर जोखिम रिपोर्ट", "title": "हट नम्बर जोखिम रिपोर्ट",
@@ -298,7 +294,7 @@
}, },
"play_dimension": { "play_dimension": {
"title": "खेल आयाम रिपोर्ट", "title": "खेल आयाम रिपोर्ट",
"summary": "खेल अनुसार बेट भोल्युम, पेआउट, रिबेट र P&L संरचना छुट्याउनुहोस्।" "summary": "खेल र व्यावसायिक मितिअनुसार P&L; मिति नचयेमा पछिल्लो ३० दिन।"
}, },
"sold_out_number": { "sold_out_number": {
"title": "सोल्ड-आउट नम्बर रिपोर्ट", "title": "सोल्ड-आउट नम्बर रिपोर्ट",
@@ -306,11 +302,11 @@
}, },
"rebate_commission": { "rebate_commission": {
"title": "कमिसन / रिबेट रिपोर्ट", "title": "कमिसन / रिबेट रिपोर्ट",
"summary": "खेल र अवधिअनुसार कमिसन, रिबेट र मिलेको नियम सारांश गर्नुहोस्।" "summary": "वालेट-मोड तत्काल रिबेट, व्यावसायिक मितिअनुसार; मिति नचयेमा पछिल्लो ३० दिन।"
}, },
"admin_audit": { "admin_audit": {
"title": "एडमिन अपरेशन अडिट रिपोर्ट", "title": "एडमिन अपरेशन अडिट रिपोर्ट",
"summary": "अपरेटर र अवधिअनुसार मुख्य एडमिन अपरेशन ट्रेस निर्यात गर्नुहोस्।" "summary": "अपरेटर र रेकर्ड समय अनुसार; मिति नचयेमा पछिल्लो ३० दिन।"
} }
} }
} }

View File

@@ -77,6 +77,7 @@
"todayPayout": "今日派彩", "todayPayout": "今日派彩",
"todayProfit": "今日盈亏", "todayProfit": "今日盈亏",
"todayBusinessDateHint": "业务日 {{date}}", "todayBusinessDateHint": "业务日 {{date}}",
"todayPayoutHint": "派彩 {{amount}}",
"drawNoHint": "期号 {{drawNo}}", "drawNoHint": "期号 {{drawNo}}",
"orderAndTicket": "{{orders}} 单 · {{tickets}} 笔", "orderAndTicket": "{{orders}} 单 · {{tickets}} 笔",
"marginRate": "毛利率约 {{rate}}%", "marginRate": "毛利率约 {{rate}}%",
@@ -94,6 +95,9 @@
"batchPendingDraws": "涉及期数", "batchPendingDraws": "涉及期数",
"batchPendingDrawsCount": "{{count}} 期待审", "batchPendingDrawsCount": "{{count}} 期待审",
"platformLockedAndCap": "全站已占用 {{locked}} / 封顶 {{cap}}", "platformLockedAndCap": "全站已占用 {{locked}} / 封顶 {{cap}}",
"platformLockedLabel": "已占用",
"platformCapLabel": "封顶",
"platformCapUnset": "未配置",
"platformCapNotConfigured": "全站已占用 {{locked}} · 尚未配置封顶", "platformCapNotConfigured": "全站已占用 {{locked}} · 尚未配置封顶",
"platformOrderAndTicket": "全站 {{orders}} 单 · {{tickets}} 笔", "platformOrderAndTicket": "全站 {{orders}} 单 · {{tickets}} 笔",
"platformBetTotal": "累计投注", "platformBetTotal": "累计投注",
@@ -162,6 +166,8 @@
"todayBet": "今日下注", "todayBet": "今日下注",
"todayProfit": "今日盈亏", "todayProfit": "今日盈亏",
"sevenDayTitle": "近 7 天走势", "sevenDayTitle": "近 7 天走势",
"sevenDayBet": "近 7 天下注",
"sevenDayPayout": "近 7 天派彩",
"sevenDayProfit": "近 7 天盈亏", "sevenDayProfit": "近 7 天盈亏",
"profitScopeHint": "站点口径:投注减派彩(不含占成拆分)", "profitScopeHint": "站点口径:投注减派彩(不含占成拆分)",
"activePlayersToday": "今日活跃玩家", "activePlayersToday": "今日活跃玩家",
@@ -174,6 +180,8 @@
"agentCount": "代理节点", "agentCount": "代理节点",
"playerCount": "玩家总数", "playerCount": "玩家总数",
"topAgentToday": "今日投注最高代理:{{name}}{{amount}}", "topAgentToday": "今日投注最高代理:{{name}}{{amount}}",
"topAgentTodayLabel": "今日投注最高代理",
"overviewEmpty": "暂无站点经营数据,请确认已绑定接入站点。",
"quickLinks": { "quickLinks": {
"tickets": "注单查询", "tickets": "注单查询",
"players": "玩家管理", "players": "玩家管理",
@@ -194,6 +202,7 @@
"creditAllocatedLabel": "已下发额度", "creditAllocatedLabel": "已下发额度",
"creditUsedLabel": "已占用额度", "creditUsedLabel": "已占用额度",
"shareRate": "总占成 {{rate}}%", "shareRate": "总占成 {{rate}}%",
"shareProfitScopeHint": "本级占成口径(按注单 share_snapshot",
"settlementCycle": "账期 {{cycle}}", "settlementCycle": "账期 {{cycle}}",
"teamTitle": "团队规模", "teamTitle": "团队规模",
"directChildren": "直属下级代理", "directChildren": "直属下级代理",
@@ -208,8 +217,11 @@
"todayProfit": "今日盈亏", "todayProfit": "今日盈亏",
"todayShareProfit": "今日本级占成", "todayShareProfit": "今日本级占成",
"sevenDayTitle": "近 7 天走势", "sevenDayTitle": "近 7 天走势",
"sevenDayBet": "近 7 天下注",
"sevenDayPayout": "派彩 {{amount}}", "sevenDayPayout": "派彩 {{amount}}",
"sevenDayPayoutLabel": "近 7 天派彩",
"sevenDayProfit": "盈亏 {{amount}}", "sevenDayProfit": "盈亏 {{amount}}",
"sevenDayShareProfitLabel": "近 7 日本级占成",
"sevenDayShareProfit": "本级占成 {{amount}}", "sevenDayShareProfit": "本级占成 {{amount}}",
"pendingBills": "待结代理账单", "pendingBills": "待结代理账单",
"pendingUnpaid": "未结合计 {{amount}}", "pendingUnpaid": "未结合计 {{amount}}",
@@ -230,6 +242,7 @@
"no": "否", "no": "否",
"viewBills": "查看账单", "viewBills": "查看账单",
"lineMeta": "层级 {{depth}} · 可开下级 {{childAgent}} · 可开玩家 {{player}}", "lineMeta": "层级 {{depth}} · 可开下级 {{childAgent}} · 可开玩家 {{player}}",
"overviewEmpty": "暂无线路经营数据,请确认代理绑定有效。",
"viewLine": "代理线路", "viewLine": "代理线路",
"quickLinks": { "quickLinks": {
"tickets": "注单查询", "tickets": "注单查询",
@@ -242,6 +255,7 @@
"warnings": { "warnings": {
"drawPermission": "当前账号无开奖/仪表盘查看权限,财务与风控数据未返回。", "drawPermission": "当前账号无开奖/仪表盘查看权限,财务与风控数据未返回。",
"walletPermission": "当前账号无钱包对账查看权限,异常转账计数未返回。", "walletPermission": "当前账号无钱包对账查看权限,异常转账计数未返回。",
"analyticsUnavailable": "暂无趋势与排行分析权限,上方 KPI 仍可查看。",
"loadFailed": "加载失败,请检查 API 与登录状态。", "loadFailed": "加载失败,请检查 API 与登录状态。",
"apiResourceMissing": "仪表盘分析接口未注册。请在服务端执行php artisan lottery:admin-auth-sync或运行最新数据库迁移后重试。" "apiResourceMissing": "仪表盘分析接口未注册。请在服务端执行php artisan lottery:admin-auth-sync或运行最新数据库迁移后重试。"
} }

View File

@@ -13,7 +13,7 @@
"exportSuccess": "已导出 {{report}}{{format}}", "exportSuccess": "已导出 {{report}}{{format}}",
"exportServerSuccess": "已生成任务 {{jobNo}} 并下载 {{report}}{{format}}", "exportServerSuccess": "已生成任务 {{jobNo}} 并下载 {{report}}{{format}}",
"exportFailed": "导出失败", "exportFailed": "导出失败",
"exportHint": "服务端导出按当前筛选条件生成全量文件;任务完成后可在下方列表再次下载。", "exportHint": "导出按当前筛选条件生成全量数据,无需先查询预览。",
"exportServerHint": "全量导出走服务端任务(无需先查询预览)", "exportServerHint": "全量导出走服务端任务(无需先查询预览)",
"exportClientHint": "导出当前预览页数据(需先查询)", "exportClientHint": "导出当前预览页数据(需先查询)",
"validation": { "validation": {
@@ -281,19 +281,15 @@
}, },
"daily_profit": { "daily_profit": {
"title": "每日盈亏汇总", "title": "每日盈亏汇总",
"summary": "按业务日汇总投注、派彩与平台盈亏,当前不包含退款与单独净额字段。" "summary": "按业务日汇总投注与盈亏;未选日期默认近 30 天。注单口径,非信用账期结算。"
}, },
"player_win_loss": { "player_win_loss": {
"title": "玩家输赢报表", "title": "玩家输赢报表",
"summary": "按玩家和时间段追踪输赢表现,适合客服与财务复核。" "summary": "按业务日与玩家汇总输赢;未选日期默认近 30 天。注单口径,非信用账期结算。"
},
"profit_reports": {
"disclaimer": "本组报表按注单下注/中奖金额统计投注结果,不等同于信用占成盘账期结算。占成、回水与收付请使用「结算中心 → 账期报表」。"
}, },
"player_transfer": { "player_transfer": {
"title": "玩家转入转出报表", "title": "玩家转入转出报表",
"summary": "集中查看玩家转入、转出、冲正和异常处理记录。", "summary": "钱包盘主站划转记录,按创建时间筛选;未选日期默认近 30 天。"
"disclaimer": "仅统计主站钱包划转单(钱包盘玩家)。信用盘玩家无此类转账,请使用结算中心查看账期账务。"
}, },
"hot_number_risk": { "hot_number_risk": {
"title": "热门号码风险报表", "title": "热门号码风险报表",
@@ -301,7 +297,7 @@
}, },
"play_dimension": { "play_dimension": {
"title": "玩法维度报表", "title": "玩法维度报表",
"summary": "按玩法拆分投注量、派彩、回水和盈亏结构。" "summary": "按玩法与业务日拆分盈亏;未选日期默认近 30 天。注单口径,非信用账期结算。"
}, },
"sold_out_number": { "sold_out_number": {
"title": "售罄号码报表", "title": "售罄号码报表",
@@ -309,12 +305,11 @@
}, },
"rebate_commission": { "rebate_commission": {
"title": "佣金/回水报表", "title": "佣金/回水报表",
"summary": "按玩法与时间段汇总佣金、回水与配置命中情况。", "summary": "钱包盘下注立减回水,按业务日汇总;未选日期默认近 30 天。非信用占成账期。"
"disclaimer": "本报表为钱包盘「下注立减回水/佣金」口径,不属于信用占成盘账期结算。占成盘请使用「代理 → 代理账单」中的账期报表。"
}, },
"admin_audit": { "admin_audit": {
"title": "后台操作审计报表", "title": "后台操作审计报表",
"summary": "按操作人和时间段导出关键后台操作留痕。" "summary": "按操作人与记录创建时间筛选;未选日期默认近 30 天。"
} }
} }
} }

View File

@@ -52,6 +52,15 @@ export function formatAdminCalendarToday(locale: AdminApiLocale, weekdayLabel: s
return `${datePart} ${weekdayLabel}`; 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 = const NAIVE_SCHEDULE_CLOCK_RE =
/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/; /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/;

View File

@@ -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_VIEW_ACCESS_ANY = [PRD_REPORT_VIEW] as const;
export const PRD_REPORTS_EXPORT_ACCESS_ANY = [PRD_REPORT_EXPORT] 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.* 资源口径一致) */ /** 系统设置(与后端 admin.settings.* 资源口径一致) */
export const PRD_SETTINGS_ACCESS_ANY = [ export const PRD_SETTINGS_ACCESS_ANY = [
PRD_WALLET_RECONCILE_MANAGE, PRD_WALLET_RECONCILE_MANAGE,

View File

@@ -16,6 +16,28 @@ export function signedMoneyClass(amount: number, emphasize = false): string {
return cn("text-muted-foreground", emphasize && "font-medium"); 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({ export function SignedMoney({
amount, amount,
children, children,

View File

@@ -34,19 +34,6 @@ export const REPORT_UI_TO_JOB_TYPE: Record<ReportUiKey, string> = {
admin_audit: "audit_operation_report", 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<ReportUiKey>([
"draw_profit",
"daily_profit",
"player_win_loss",
"player_transfer",
"hot_number_risk",
"play_dimension",
"sold_out_number",
"rebate_commission",
"admin_audit",
]);
export function buildReportJobParameters( export function buildReportJobParameters(
key: ReportUiKey, key: ReportUiKey,
filters: ReportFilterSnapshot, filters: ReportFilterSnapshot,

View File

@@ -10,40 +10,45 @@ 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_DASHBOARD_ANALYTICS_ACCESS_ANY } from "@/lib/admin-prd";
import { normalizeAdminLanguage } from "@/i18n"; import { normalizeAdminLanguage } from "@/i18n";
import { adminWeekdayKeyForDate, formatAdminCalendarToday } from "@/lib/admin-datetime"; import { adminWeekdayKeyForDate, formatAdminBusinessDateIso, 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 { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } 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 {
DashboardKpiCard,
DashboardScopeMetric,
DashboardSignedStatRow,
DashboardStatRow,
} from "@/modules/dashboard/dashboard-visuals";
import { import {
formatDashboardCreditMajor, formatDashboardCreditMajor,
formatDashboardMoneyMinor, formatDashboardMoneyMinor,
formatDashboardSignedMoneyMinor,
} from "@/modules/dashboard/use-dashboard-analytics"; } 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 type { DrawCurrentSnapshot } from "@/types/api/public-draw";
import { LotteryApiBizError } from "@/types/api/errors"; import { LotteryApiBizError } from "@/types/api/errors";
function AgentMetric({ function buildTodayBetHint(
label, businessDate: string,
value, latestBetAt: string | null,
}: { t: (key: string, opts?: Record<string, unknown>) => string,
label: string; formatDt: (iso: string) => string,
value: string; ): string {
}): ReactElement { const dateHint = t("todayBusinessDateHint", { date: businessDate });
return ( if (latestBetAt) {
<div className="rounded-lg border bg-muted/30 px-3 py-2.5"> return `${dateHint} · ${t("agent.latestBetAt", { time: formatDt(latestBetAt) })}`;
<p className="text-xs text-muted-foreground">{label}</p> }
<p className="mt-1 text-base font-semibold tabular-nums text-foreground">{value}</p>
</div> return `${dateHint} · ${t("agent.noBetToday")}`;
);
} }
export function AgentDashboardConsole(): ReactElement { export function AgentDashboardConsole(): ReactElement {
@@ -52,6 +57,9 @@ export function AgentDashboardConsole(): ReactElement {
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 businessDateToday = useMemo(() => formatAdminBusinessDateIso(), []);
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" });
@@ -65,10 +73,10 @@ export function AgentDashboardConsole(): ReactElement {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [apiWarnings, setApiWarnings] = useState<AdminDashboardWarning[]>([]);
const [hall, setHall] = useState<DrawCurrentSnapshot | null>(null); const [hall, setHall] = useState<DrawCurrentSnapshot | null>(null);
const [drawId, setDrawId] = useState<number | null>(null); const [drawId, setDrawId] = useState<number | null>(null);
const [overview, setOverview] = useState<AdminDashboardAgentOverview | null>(null); const [overview, setOverview] = useState<AdminDashboardAgentOverview | null>(null);
const [canFinance, setCanFinance] = useState(false);
const analyticsScope = useMemo( const analyticsScope = useMemo(
() => ({ () => ({
@@ -78,6 +86,8 @@ export function AgentDashboardConsole(): ReactElement {
[agent?.id, agent?.site_code], [agent?.id, agent?.site_code],
); );
const canAnalytics = adminHasAnyPermission(permissions, [...PRD_DASHBOARD_ANALYTICS_ACCESS_ANY]);
const load = useCallback(async (isRefresh = false) => { const load = useCallback(async (isRefresh = false) => {
if (isRefresh) { if (isRefresh) {
setRefreshing(true); setRefreshing(true);
@@ -90,7 +100,7 @@ export function AgentDashboardConsole(): ReactElement {
const d = await getAdminDashboard(); const d = await getAdminDashboard();
setHall(d.hall); setHall(d.hall);
setOverview(d.agent_overview); setOverview(d.agent_overview);
setCanFinance(d.capabilities.draw_finance_risk); setApiWarnings(d.warnings ?? []);
if (d.resolved_draw != null) { if (d.resolved_draw != null) {
setDrawId(d.resolved_draw.id); setDrawId(d.resolved_draw.id);
} else { } else {
@@ -110,8 +120,7 @@ export function AgentDashboardConsole(): ReactElement {
void load(false); void load(false);
}, []); }, []);
const currency = "NPR"; const displayCurrency = overview?.currency_code ?? "NPR";
const displayCurrency = overview?.currency_code ?? currency;
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">
@@ -144,31 +153,36 @@ export function AgentDashboardConsole(): ReactElement {
</Alert> </Alert>
) : null} ) : null}
{!loading && apiWarnings.length > 0 ? (
<Alert className="border-amber-200 bg-amber-50 dark:border-amber-900/60 dark:bg-amber-950/30">
<AlertTitle>{t("notice")}</AlertTitle>
<AlertDescription>{apiWarnings.map((w) => w.message).join(" ")}</AlertDescription>
</Alert>
) : null}
{loading ? ( {loading ? (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => ( {Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-24 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 gap-3 sm:grid-cols-2 xl:grid-cols-4"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<DashboardKpiCard <DashboardKpiCard
label={t("agent.todayBet")} label={t("agent.todayBet")}
value={formatDashboardMoneyMinor(overview.today_bet_minor, displayCurrency)} value={formatDashboardMoneyMinor(overview.today_bet_minor, displayCurrency)}
icon={<TrendingUp className="size-4" />} icon={<TrendingUp className="size-4" />}
hint={ hint={buildTodayBetHint(businessDateToday, overview.latest_bet_at, t, formatDt)}
overview.latest_bet_at
? t("agent.latestBetAt", { time: formatDt(overview.latest_bet_at) })
: t("agent.noBetToday")
}
/> />
<DashboardKpiCard <DashboardKpiCard
label={t("agent.todayShareProfit")} label={t("agent.todayShareProfit")}
value={formatDashboardSignedMoneyMinor(overview.today_profit_minor, displayCurrency)} signedAmountMinor={overview.today_profit_minor}
currencyCode={displayCurrency}
icon={<BarChart3 className="size-4" />} icon={<BarChart3 className="size-4" />}
hint={t("agent.shareRate", { rate: overview.total_share_rate })} hint={`${t("agent.shareRate", { rate: overview.total_share_rate })} · ${t("todayPayoutHint", {
valueClassName={signedMoneyClass(overview.today_profit_minor, true)} amount: formatDashboardMoneyMinor(overview.today_payout_minor, displayCurrency),
})}`}
/> />
<DashboardKpiCard <DashboardKpiCard
label={t("agent.activePlayersToday")} label={t("agent.activePlayersToday")}
@@ -193,7 +207,7 @@ export function AgentDashboardConsole(): ReactElement {
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<div> <div>
<p className="text-2xl font-semibold tabular-nums"> <p className="break-all text-2xl font-semibold tabular-nums leading-tight">
{formatDashboardCreditMajor(overview.credit_limit, displayCurrency)} {formatDashboardCreditMajor(overview.credit_limit, displayCurrency)}
</p> </p>
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
@@ -202,19 +216,15 @@ export function AgentDashboardConsole(): ReactElement {
})} })}
</p> </p>
</div> </div>
<div className="grid gap-3 sm:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-2">
<AgentMetric <DashboardScopeMetric
label={t("agent.creditAllocatedLabel")} label={t("agent.creditAllocatedLabel")}
value={formatDashboardCreditMajor(overview.allocated_credit, displayCurrency)} value={formatDashboardCreditMajor(overview.allocated_credit, displayCurrency)}
/> />
<AgentMetric <DashboardScopeMetric
label={t("agent.creditUsedLabel")} label={t("agent.creditUsedLabel")}
value={formatDashboardCreditMajor(overview.used_credit, displayCurrency)} value={formatDashboardCreditMajor(overview.used_credit, displayCurrency)}
/> />
<AgentMetric
label={t("agent.pendingBills")}
value={String(overview.pending_bill_count)}
/>
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t("agent.lineMeta", { {t("agent.lineMeta", {
@@ -231,24 +241,21 @@ export function AgentDashboardConsole(): ReactElement {
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">{t("agent.sevenDayTitle")}</CardTitle> <CardTitle className="text-sm font-semibold">{t("agent.sevenDayTitle")}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-2 text-sm"> <CardContent className="space-y-2">
<div className="flex items-center justify-between gap-3"> <DashboardStatRow
<span className="text-muted-foreground">{t("agent.todayBet")}</span> label={t("agent.sevenDayBet")}
<span className="font-semibold tabular-nums"> value={formatDashboardMoneyMinor(overview.seven_day_bet_minor, displayCurrency)}
{formatDashboardMoneyMinor(overview.seven_day_bet_minor, displayCurrency)} />
</span> <DashboardStatRow
</div> label={t("agent.sevenDayPayoutLabel")}
<div className="flex items-center justify-between gap-3"> value={formatDashboardMoneyMinor(overview.seven_day_payout_minor, displayCurrency)}
<span className="text-muted-foreground">{t("agent.todayShareProfit")}</span> />
<span <DashboardSignedStatRow
className={cn( label={t("agent.sevenDayShareProfitLabel")}
"font-semibold tabular-nums", amountMinor={overview.seven_day_profit_minor}
signedMoneyClass(overview.seven_day_profit_minor, true), currencyCode={displayCurrency}
)} />
> <p className="pt-1 text-xs text-muted-foreground">{t("agent.shareProfitScopeHint")}</p>
{formatDashboardSignedMoneyMinor(overview.seven_day_profit_minor, displayCurrency)}
</span>
</div>
</CardContent> </CardContent>
</Card> </Card>
@@ -257,19 +264,19 @@ export function AgentDashboardConsole(): ReactElement {
<CardTitle className="text-sm font-semibold">{t("agent.teamTitle")}</CardTitle> <CardTitle className="text-sm font-semibold">{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">
<AgentMetric <DashboardScopeMetric
label={t("agent.directChildren")} label={t("agent.directChildren")}
value={String(overview.direct_child_count)} value={String(overview.direct_child_count)}
/> />
<AgentMetric <DashboardScopeMetric
label={t("agent.directPlayers")} label={t("agent.directPlayers")}
value={String(overview.direct_player_count)} value={String(overview.direct_player_count)}
/> />
<AgentMetric <DashboardScopeMetric
label={t("agent.subtreeAgents")} label={t("agent.subtreeAgents")}
value={String(overview.subtree_agent_count)} value={String(overview.subtree_agent_count)}
/> />
<AgentMetric <DashboardScopeMetric
label={t("agent.teamPlayers")} label={t("agent.teamPlayers")}
value={String(overview.team_player_count)} value={String(overview.team_player_count)}
/> />
@@ -277,7 +284,11 @@ export function AgentDashboardConsole(): ReactElement {
</Card> </Card>
</div> </div>
</section> </section>
) : null} ) : (
<AdminNoResourceState className="py-12 text-sm text-muted-foreground">
{t("agent.overviewEmpty")}
</AdminNoResourceState>
)}
<DashboardCurrentDrawCard <DashboardCurrentDrawCard
key={`${hall?.draw_no ?? "empty"}:${loading ? "loading" : "ready"}`} key={`${hall?.draw_no ?? "empty"}:${loading ? "loading" : "ready"}`}
@@ -286,18 +297,15 @@ export function AgentDashboardConsole(): ReactElement {
loading={loading} loading={loading}
/> />
{canFinance ? ( {canAnalytics ? (
<DashboardAnalyticsPanel <DashboardAnalyticsPanel
enabled={canFinance} enabled={canAnalytics}
playOptions={playOptions} playOptions={playOptions}
scope={analyticsScope} scope={analyticsScope}
/> />
) : ( ) : !loading ? (
<Alert className="border-muted"> <p className="text-xs text-muted-foreground">{t("warnings.analyticsUnavailable")}</p>
<AlertTitle>{t("notice")}</AlertTitle> ) : null}
<AlertDescription>{t("warnings.drawPermission")}</AlertDescription>
</Alert>
)}
</div> </div>
); );
} }

View File

@@ -21,7 +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 { signedMoneyClass, signedTrendClassName } 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";
@@ -56,16 +56,8 @@ function computeDeltaPercent(series: number[]): string | null {
return `${sign} ${Math.abs(pct).toFixed(1)}%`; return `${sign} ${Math.abs(pct).toFixed(1)}%`;
} }
function deltaClassName(series: number[]): string { function deltaClassName(series: number[], mode: "higherBetter" | "lowerBetter" | "signed"): string {
if (series.length < 2) { return signedTrendClassName(series, mode);
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";
} }
export function DashboardAnalyticsMain({ analytics }: { analytics: DashboardAnalyticsState }): ReactNode { export function DashboardAnalyticsMain({ analytics }: { analytics: DashboardAnalyticsState }): ReactNode {
@@ -190,13 +182,13 @@ export function DashboardAnalyticsMain({ analytics }: { analytics: DashboardAnal
) : null} ) : null}
{loading ? ( {loading ? (
<div className="grid min-w-0 gap-3 sm:grid-cols-2 xl:grid-cols-3"> <div className="grid min-w-0 gap-3 sm:grid-cols-2 2xl:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => ( {Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-28 w-full rounded-xl" /> <Skeleton key={i} className="h-28 w-full rounded-xl" />
))} ))}
</div> </div>
) : summary ? ( ) : summary ? (
<div className="grid min-w-0 gap-3 sm:grid-cols-2 xl:grid-cols-3"> <div className="grid min-w-0 gap-3 sm:grid-cols-2 2xl:grid-cols-3">
<DashboardKpiCard <DashboardKpiCard
label={t("analytics.summaryBet")} label={t("analytics.summaryBet")}
value={formatMoney(summary.total_bet_minor, currency)} value={formatMoney(summary.total_bet_minor, currency)}
@@ -208,7 +200,7 @@ export function DashboardAnalyticsMain({ analytics }: { analytics: DashboardAnal
sparklineValues={sparklines.bet} sparklineValues={sparklines.bet}
deltaLabel={ deltaLabel={
computeDeltaPercent(sparklines.bet) ? ( computeDeltaPercent(sparklines.bet) ? (
<span className={deltaClassName(sparklines.bet)}> <span className={deltaClassName(sparklines.bet, "higherBetter")}>
{computeDeltaPercent(sparklines.bet)} {computeDeltaPercent(sparklines.bet)}
</span> </span>
) : undefined ) : undefined
@@ -229,7 +221,7 @@ export function DashboardAnalyticsMain({ analytics }: { analytics: DashboardAnal
sparklineValues={sparklines.payout} sparklineValues={sparklines.payout}
deltaLabel={ deltaLabel={
computeDeltaPercent(sparklines.payout) ? ( computeDeltaPercent(sparklines.payout) ? (
<span className={deltaClassName(sparklines.payout)}> <span className={deltaClassName(sparklines.payout, "lowerBetter")}>
{computeDeltaPercent(sparklines.payout)} {computeDeltaPercent(sparklines.payout)}
</span> </span>
) : undefined ) : undefined
@@ -241,8 +233,8 @@ export function DashboardAnalyticsMain({ analytics }: { analytics: DashboardAnal
? t("analytics.summaryShareProfit") ? t("analytics.summaryShareProfit")
: t("analytics.summaryProfit") : t("analytics.summaryProfit")
} }
value={formatSignedMoney(summary.approx_house_gross_minor, currency)} signedAmountMinor={summary.approx_house_gross_minor}
valueClassName={signedMoneyClass(summary.approx_house_gross_minor, true)} currencyCode={currency}
hint={ hint={
profitScope === "share_profit" profitScope === "share_profit"
? t("analytics.shareProfitHint") ? t("analytics.shareProfitHint")
@@ -256,7 +248,7 @@ export function DashboardAnalyticsMain({ analytics }: { analytics: DashboardAnal
sparklineValues={sparklines.profit} sparklineValues={sparklines.profit}
deltaLabel={ deltaLabel={
computeDeltaPercent(sparklines.profit) ? ( computeDeltaPercent(sparklines.profit) ? (
<span className={deltaClassName(sparklines.profit)}> <span className={deltaClassName(sparklines.profit, "signed")}>
{computeDeltaPercent(sparklines.profit)} {computeDeltaPercent(sparklines.profit)}
</span> </span>
) : undefined ) : undefined

View File

@@ -4,7 +4,7 @@ import dynamic from "next/dynamic";
import Link from "next/link"; import Link from "next/link";
import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react"; import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react";
import { useTranslation } from "react-i18next"; 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 { getAdminDashboardByScope } from "@/api/admin-dashboard";
import { useAdminPlayTypeCatalog } from "@/hooks/use-admin-play-type-catalog"; import { useAdminPlayTypeCatalog } from "@/hooks/use-admin-play-type-catalog";
@@ -18,6 +18,9 @@ import {
} from "@/modules/dashboard/dashboard-analytics-panel"; } from "@/modules/dashboard/dashboard-analytics-panel";
import { DashboardCurrentDrawCard } from "@/modules/dashboard/dashboard-current-draw-card"; import { DashboardCurrentDrawCard } from "@/modules/dashboard/dashboard-current-draw-card";
import { useDashboardAnalytics } from "@/modules/dashboard/use-dashboard-analytics"; 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 { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state"; import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { Button, buttonVariants } from "@/components/ui/button"; import { Button, buttonVariants } from "@/components/ui/button";
@@ -39,12 +42,18 @@ import type {
AdminDashboardLifetimeFinance, AdminDashboardLifetimeFinance,
AdminDashboardPlatformRisk, AdminDashboardPlatformRisk,
AdminDashboardResultBatchQueue, AdminDashboardResultBatchQueue,
AdminDashboardTodayFinance,
AdminDashboardWarning,
} from "@/types/api/admin-dashboard"; } from "@/types/api/admin-dashboard";
import type { AdminDrawFinanceSummaryData } from "@/types/api/admin-draw-finance"; import type { AdminDrawFinanceSummaryData } from "@/types/api/admin-draw-finance";
import type { AdminRiskPoolRow } from "@/types/api/admin-risk"; import type { AdminRiskPoolRow } from "@/types/api/admin-risk";
import type { DrawCurrentSnapshot } from "@/types/api/public-draw"; import type { DrawCurrentSnapshot } from "@/types/api/public-draw";
// recharts 图表组件懒加载,避免 ~200KB 进入主 bundle // recharts 图表组件懒加载,避免 ~200KB 进入主 bundle
const DashboardKpiCard = dynamic(
() => import("@/modules/dashboard/dashboard-visuals").then((m) => ({ default: m.DashboardKpiCard })),
{ ssr: false },
);
const DashboardPanelCard = dynamic( const DashboardPanelCard = dynamic(
() => import("@/modules/dashboard/dashboard-visuals").then((m) => ({ default: m.DashboardPanelCard })), () => import("@/modules/dashboard/dashboard-visuals").then((m) => ({ default: m.DashboardPanelCard })),
{ ssr: false }, { ssr: false },
@@ -172,6 +181,8 @@ export function DashboardConsole(): ReactElement {
const [lifetimeFinance, setLifetimeFinance] = useState<AdminDashboardLifetimeFinance | null>( const [lifetimeFinance, setLifetimeFinance] = useState<AdminDashboardLifetimeFinance | null>(
null, null,
); );
const [todayFinance, setTodayFinance] = useState<AdminDashboardTodayFinance | null>(null);
const [apiWarnings, setApiWarnings] = useState<AdminDashboardWarning[]>([]);
const [platformRisk, setPlatformRisk] = useState<AdminDashboardPlatformRisk | null>(null); const [platformRisk, setPlatformRisk] = useState<AdminDashboardPlatformRisk | null>(null);
const [riskLocked, setRiskLocked] = useState(0); const [riskLocked, setRiskLocked] = useState(0);
const [riskCap, setRiskCap] = useState(0); const [riskCap, setRiskCap] = useState(0);
@@ -193,6 +204,8 @@ export function DashboardConsole(): ReactElement {
setDrawPanel(null); setDrawPanel(null);
setResultBatchQueue(null); setResultBatchQueue(null);
setLifetimeFinance(null); setLifetimeFinance(null);
setTodayFinance(null);
setApiWarnings([]);
setPlatformRisk(null); setPlatformRisk(null);
setDrawId(null); setDrawId(null);
setRiskLocked(0); setRiskLocked(0);
@@ -214,6 +227,8 @@ export function DashboardConsole(): ReactElement {
} }
setResultBatchQueue(d.result_batch_queue); setResultBatchQueue(d.result_batch_queue);
setLifetimeFinance(d.lifetime_finance); setLifetimeFinance(d.lifetime_finance);
setTodayFinance(d.today_finance);
setApiWarnings(d.warnings ?? []);
setPlatformRisk(d.platform_risk); setPlatformRisk(d.platform_risk);
if (d.draw != null) { if (d.draw != null) {
setDrawPanel(d.draw); setDrawPanel(d.draw);
@@ -239,7 +254,10 @@ export function DashboardConsole(): ReactElement {
}, []); }, []);
const currency = 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 canFinance = capabilities?.draw_finance_risk ?? false;
const platformLocked = coerceAdminMinor(platformRisk?.locked_amount); const platformLocked = coerceAdminMinor(platformRisk?.locked_amount);
const platformCap = coerceAdminMinor(platformRisk?.cap_amount); const platformCap = coerceAdminMinor(platformRisk?.cap_amount);
@@ -296,6 +314,13 @@ export function DashboardConsole(): ReactElement {
</Alert> </Alert>
) : null} ) : null}
{!loading && apiWarnings.length > 0 ? (
<Alert className="border-amber-200 bg-amber-50 dark:border-amber-900/60 dark:bg-amber-950/30">
<AlertTitle>{t("notice")}</AlertTitle>
<AlertDescription>{apiWarnings.map((w) => w.message).join(" ")}</AlertDescription>
</Alert>
) : null}
<section className="flex min-w-0 flex-col gap-4"> <section className="flex min-w-0 flex-col gap-4">
<DashboardCurrentDrawCard <DashboardCurrentDrawCard
key={`${hall?.draw_no ?? "empty"}:${hall?.seconds_to_close ?? 0}:${loading ? "loading" : "ready"}`} key={`${hall?.draw_no ?? "empty"}:${hall?.seconds_to_close ?? 0}:${loading ? "loading" : "ready"}`}
@@ -303,6 +328,42 @@ export function DashboardConsole(): ReactElement {
drawId={drawId} drawId={drawId}
loading={loading} loading={loading}
/> />
{canFinance && !loading && todayFinance ? (
<div className="grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
<DashboardKpiCard
label={t("todayBetTotal")}
value={formatDashboardMoneyMinor(todayFinance.total_bet_minor, currency)}
icon={<TrendingUp className="size-4" />}
hint={t("todayBusinessDateHint", { date: todayFinance.business_date })}
/>
<DashboardKpiCard
label={t("todayProfit")}
signedAmountMinor={todayFinance.approx_house_gross_minor}
currencyCode={currency}
icon={<BarChart3 className="size-4" />}
hint={t("todayPayoutHint", {
amount: formatDashboardMoneyMinor(todayFinance.total_payout_minor, currency),
})}
/>
<DashboardKpiCard
label={t("lifetimeProfit")}
signedAmountMinor={lifetimeFinance?.approx_house_gross_minor}
currencyCode={currency}
icon={<Wallet className="size-4" />}
hint={
lifetimeFinance
? t("lifetimeActivityHint", {
draws: lifetimeFinance.draw_count,
days: lifetimeFinance.business_day_count,
})
: undefined
}
value={lifetimeFinance ? undefined : "—"}
/>
</div>
) : null}
<div className="grid min-w-0 grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4"> <div className="grid min-w-0 grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
<DashboardPanelCard <DashboardPanelCard
href={pendingReviewHref(drawId, resultBatchQueue)} href={pendingReviewHref(drawId, resultBatchQueue)}
@@ -337,7 +398,7 @@ export function DashboardConsole(): ReactElement {
> >
<AbnormalTransferPanelFooter <AbnormalTransferPanelFooter
total={abnormalTransferTotal} total={abnormalTransferTotal}
walletPermission={capabilities?.wallet_transfer_view ?? true} walletPermission={capabilities?.wallet_transfer_view ?? false}
/> />
</DashboardPanelCard> </DashboardPanelCard>
@@ -345,16 +406,6 @@ export function DashboardConsole(): ReactElement {
href="/admin/risk" href="/admin/risk"
title={t("riskCapUsage")} title={t("riskCapUsage")}
value={`${platformUsagePct.toFixed(1)}%`} 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")} actionLabel={t("occupancyDetails")}
icon={<Shield className="size-5" aria-hidden />} icon={<Shield className="size-5" aria-hidden />}
accent={ accent={
@@ -380,7 +431,7 @@ export function DashboardConsole(): ReactElement {
<DashboardPanelCard <DashboardPanelCard
href="/admin/reports" href="/admin/reports"
title={t("payoutComposition")} title={t("lifetimePayout")}
value={ value={
lifetimeFinance lifetimeFinance
? formatMoneyMinor(lifetimeFinance.total_payout_minor, currency) ? formatMoneyMinor(lifetimeFinance.total_payout_minor, currency)

View File

@@ -36,6 +36,7 @@ import {
getAdminCurrencyDecimalPlaces, getAdminCurrencyDecimalPlaces,
} from "@/lib/money"; } from "@/lib/money";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { SignedMoney, signedMoneyClass } from "@/lib/admin-signed-money";
import { import {
buildBatchProgressConfig, buildBatchProgressConfig,
buildFinanceStructureConfig, buildFinanceStructureConfig,
@@ -46,6 +47,7 @@ import {
DASHBOARD_CHART_COLORS, DASHBOARD_CHART_COLORS,
} from "@/modules/dashboard/dashboard-chart-config"; } from "@/modules/dashboard/dashboard-chart-config";
import { DashboardChartEmpty } from "@/modules/dashboard/dashboard-chart-empty"; import { DashboardChartEmpty } from "@/modules/dashboard/dashboard-chart-empty";
import { formatDashboardSignedMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
import type { AdminDrawFinanceSummaryData } from "@/types/api/admin-draw-finance"; import type { AdminDrawFinanceSummaryData } from "@/types/api/admin-draw-finance";
import type { AdminDashboardLifetimeFinance } from "@/types/api/admin-dashboard"; import type { AdminDashboardLifetimeFinance } from "@/types/api/admin-dashboard";
import type { AdminRiskPoolRow } from "@/types/api/admin-risk"; import type { AdminRiskPoolRow } from "@/types/api/admin-risk";
@@ -179,6 +181,73 @@ function kpiAccentClass(accent: DashboardKpiAccent): string {
} }
} }
/** 站点/代理卡片内 label | amount 行,金额过长时可换行 */
export function DashboardStatRow({
label,
value,
valueClassName,
}: {
label: string;
value: string;
valueClassName?: string;
}): ReactElement {
return (
<div className="flex items-start justify-between gap-3 text-sm">
<span className="shrink-0 text-muted-foreground">{label}</span>
<span
className={cn(
"min-w-0 break-all text-right font-semibold tabular-nums leading-tight",
valueClassName ?? "text-foreground",
)}
>
{value}
</span>
</div>
);
}
/** 盈亏行:正绿、负红、零灰(与 {@link signedMoneyClass} 一致) */
export function DashboardSignedStatRow({
label,
amountMinor,
currencyCode,
}: {
label: string;
amountMinor: number;
currencyCode: string | null;
}): ReactElement {
return (
<div className="flex items-start justify-between gap-3 text-sm">
<span className="shrink-0 text-muted-foreground">{label}</span>
<SignedMoney
amount={amountMinor}
emphasize
className="min-w-0 break-all text-right leading-tight"
>
{formatDashboardSignedMoneyMinor(amountMinor, currencyCode)}
</SignedMoney>
</div>
);
}
/** 规模/授信等栅格指标,金额可换行 */
export function DashboardScopeMetric({
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 break-all text-base font-semibold tabular-nums leading-tight text-foreground">
{value}
</p>
</div>
);
}
/** 财务概览区紧凑 KPI避免 StatCard 在窄栅格内撑破布局 */ /** 财务概览区紧凑 KPI避免 StatCard 在窄栅格内撑破布局 */
export function DashboardKpiCard({ export function DashboardKpiCard({
label, label,
@@ -187,43 +256,60 @@ export function DashboardKpiCard({
icon, icon,
accent = "primary", accent = "primary",
valueClassName, valueClassName,
signedAmountMinor,
currencyCode,
sparklineValues, sparklineValues,
deltaLabel, deltaLabel,
}: { }: {
label: string; label: string;
value: ReactNode; value?: ReactNode;
hint?: ReactNode; hint?: ReactNode;
icon: ReactNode; icon: ReactNode;
accent?: DashboardKpiAccent; accent?: DashboardKpiAccent;
/** 覆盖主数值颜色(如盈亏红绿) */ /** 覆盖主数值颜色(如盈亏红绿) */
valueClassName?: string; valueClassName?: string;
/** 盈亏类 KPI自动带 +/- 与红绿 */
signedAmountMinor?: number;
currencyCode?: string | null;
sparklineValues?: number[]; sparklineValues?: number[];
deltaLabel?: ReactNode; deltaLabel?: ReactNode;
}): ReactElement { }): 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 ( return (
<div className="flex h-full min-w-0 flex-col rounded-xl border border-border/60 bg-card p-4"> <div className="flex h-full min-w-0 flex-col rounded-xl border border-border/60 bg-card p-4">
<div className="flex min-w-0 items-start gap-3"> <div className="flex items-start justify-between gap-2">
<p className="min-w-0 flex-1 text-xs font-medium leading-snug text-muted-foreground">{label}</p>
<div <div
className={cn( className={cn(
"flex size-10 shrink-0 items-center justify-center rounded-lg", "flex size-9 shrink-0 items-center justify-center rounded-lg [&_svg]:size-4",
kpiAccentClass(accent), kpiAccentClass(accent),
)} )}
> >
{icon} {icon}
</div> </div>
<div className="min-w-0 flex-1"> </div>
<p className="text-xs font-medium text-muted-foreground">{label}</p>
<p <p
title={valueTitle}
className={cn( className={cn(
"mt-1 truncate text-xl font-bold tabular-nums tracking-tight", "mt-2 break-words text-base font-bold tabular-nums leading-tight tracking-tight sm:text-lg",
valueClassName ?? "text-foreground", resolvedValueClassName ?? "text-foreground",
)} )}
> >
{value} {resolvedValue}
</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}
</div>
</div>
{sparklineValues && sparklineValues.length >= 2 ? ( {sparklineValues && sparklineValues.length >= 2 ? (
<div className="mt-3 flex justify-end"> <div className="mt-3 flex justify-end">
<MiniSparkline <MiniSparkline
@@ -239,7 +325,7 @@ export function DashboardKpiCard({
</div> </div>
) : null} ) : null}
{hint ? ( {hint ? (
<p className="mt-2 line-clamp-2 text-[11px] leading-snug text-muted-foreground">{hint}</p> <p className="mt-2 text-[11px] leading-snug text-muted-foreground">{hint}</p>
) : null} ) : null}
</div> </div>
); );
@@ -477,9 +563,7 @@ export function DashboardPanelCard({
</p> </p>
)} )}
{subtitle && !loading ? ( {subtitle && !loading ? (
<p className="mt-2 line-clamp-2 text-xs leading-relaxed text-muted-foreground"> <div className="mt-2 text-xs leading-snug text-muted-foreground">{subtitle}</div>
{subtitle}
</p>
) : null} ) : null}
</div> </div>
@@ -621,7 +705,35 @@ export function CapUsageBar({
const radialData = useMemo(() => [{ usage: pct, fill }], [pct, fill]); const radialData = useMemo(() => [{ usage: pct, fill }], [pct, fill]);
if (compact) { if (compact) {
const lockedLabel = formatMoney(locked, currency);
const capLabel = cap > 0 ? formatMoney(cap, currency) : t("platformCapUnset");
return ( return (
<div className="space-y-2.5">
<div className="grid grid-cols-2 gap-2">
<div className="rounded-lg bg-primary/5 px-2.5 py-2 ring-1 ring-primary/15">
<p className="text-[10px] font-medium uppercase tracking-wide text-primary/80">
{t("platformLockedLabel")}
</p>
<p
className="mt-1 break-all text-sm font-semibold tabular-nums leading-tight text-foreground"
title={lockedLabel}
>
{lockedLabel}
</p>
</div>
<div className="rounded-lg bg-muted/50 px-2.5 py-2 ring-1 ring-border/60">
<p className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
{t("platformCapLabel")}
</p>
<p
className="mt-1 break-all text-sm font-semibold tabular-nums leading-tight text-foreground"
title={capLabel}
>
{capLabel}
</p>
</div>
</div>
<div <div
className="h-2 overflow-hidden rounded-full bg-muted" className="h-2 overflow-hidden rounded-full bg-muted"
role="progressbar" role="progressbar"
@@ -635,6 +747,7 @@ export function CapUsageBar({
style={{ width: `${pct}%`, backgroundColor: fill }} style={{ width: `${pct}%`, backgroundColor: fill }}
/> />
</div> </div>
</div>
); );
} }
@@ -741,6 +854,12 @@ export function FinanceStructureChart({
<p className="text-center text-xs text-muted-foreground"> <p className="text-center text-xs text-muted-foreground">
{t("payoutRateOfBet", { rate: payoutRate })} {t("payoutRateOfBet", { rate: payoutRate })}
</p> </p>
<p className="text-center text-sm tabular-nums">
<span className="text-muted-foreground">{t("houseGross")} </span>
<SignedMoney amount={gross} emphasize>
{formatDashboardSignedMoneyMinor(gross, currency)}
</SignedMoney>
</p>
<ChartLegend content={<ChartLegendContent />} /> <ChartLegend content={<ChartLegendContent />} />
</div> </div>
); );

View File

@@ -10,40 +10,43 @@ 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 { adminHasAnyPermission } from "@/lib/admin-permissions"; 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 { normalizeAdminLanguage } from "@/i18n";
import { adminWeekdayKeyForDate, formatAdminCalendarToday } from "@/lib/admin-datetime"; import { adminWeekdayKeyForDate, formatAdminBusinessDateIso, 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 { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } 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 {
DashboardKpiCard,
DashboardScopeMetric,
DashboardSignedStatRow,
DashboardStatRow,
} from "@/modules/dashboard/dashboard-visuals";
import { import {
formatDashboardMoneyMinor, formatDashboardMoneyMinor,
formatDashboardSignedMoneyMinor,
} from "@/modules/dashboard/use-dashboard-analytics"; } 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 type { DrawCurrentSnapshot } from "@/types/api/public-draw";
import { LotteryApiBizError } from "@/types/api/errors"; import { LotteryApiBizError } from "@/types/api/errors";
function SiteMetric({ function buildTodayBetHint(
label, businessDate: string,
value, latestBetAt: string | null,
}: { t: (key: string, opts?: Record<string, unknown>) => string,
label: string; formatDt: (iso: string) => string,
value: string; ): string {
}): ReactElement { const dateHint = t("todayBusinessDateHint", { date: businessDate });
return ( if (latestBetAt) {
<div className="rounded-lg border bg-muted/30 px-3 py-2.5"> return `${dateHint} · ${t("site.latestBetAt", { time: formatDt(latestBetAt) })}`;
<p className="text-xs text-muted-foreground">{label}</p> }
<p className="mt-1 text-base font-semibold tabular-nums text-foreground">{value}</p>
</div> return `${dateHint} · ${t("site.noBetToday")}`;
);
} }
export function SiteDashboardConsole(): ReactElement { export function SiteDashboardConsole(): ReactElement {
@@ -53,6 +56,7 @@ export function SiteDashboardConsole(): ReactElement {
const profile = useAdminProfile(); const profile = useAdminProfile();
const site = profile?.site ?? null; const site = profile?.site ?? null;
const permissions = useMemo(() => profile?.permissions ?? [], [profile?.permissions]); const permissions = useMemo(() => profile?.permissions ?? [], [profile?.permissions]);
const businessDateToday = useMemo(() => formatAdminBusinessDateIso(), []);
const todayLabel = useMemo(() => { const todayLabel = useMemo(() => {
const locale = normalizeAdminLanguage(i18n.resolvedLanguage ?? i18n.language); const locale = normalizeAdminLanguage(i18n.resolvedLanguage ?? i18n.language);
@@ -66,6 +70,7 @@ export function SiteDashboardConsole(): ReactElement {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [apiWarnings, setApiWarnings] = useState<AdminDashboardWarning[]>([]);
const [hall, setHall] = useState<DrawCurrentSnapshot | null>(null); const [hall, setHall] = useState<DrawCurrentSnapshot | null>(null);
const [drawId, setDrawId] = useState<number | null>(null); const [drawId, setDrawId] = useState<number | null>(null);
const [overview, setOverview] = useState<AdminDashboardSiteOverview | null>(null); const [overview, setOverview] = useState<AdminDashboardSiteOverview | null>(null);
@@ -78,7 +83,7 @@ export function SiteDashboardConsole(): ReactElement {
[overview?.site_code, site?.code], [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) => { const load = useCallback(async (isRefresh = false) => {
if (isRefresh) { if (isRefresh) {
@@ -92,6 +97,7 @@ export function SiteDashboardConsole(): ReactElement {
const d = await getAdminDashboard(); const d = await getAdminDashboard();
setHall(d.hall); setHall(d.hall);
setOverview(d.site_overview); setOverview(d.site_overview);
setApiWarnings(d.warnings ?? []);
if (d.resolved_draw != null) { if (d.resolved_draw != null) {
setDrawId(d.resolved_draw.id); setDrawId(d.resolved_draw.id);
} else { } else {
@@ -144,31 +150,36 @@ export function SiteDashboardConsole(): ReactElement {
</Alert> </Alert>
) : null} ) : null}
{!loading && apiWarnings.length > 0 ? (
<Alert className="border-amber-200 bg-amber-50 dark:border-amber-900/60 dark:bg-amber-950/30">
<AlertTitle>{t("notice")}</AlertTitle>
<AlertDescription>{apiWarnings.map((w) => w.message).join(" ")}</AlertDescription>
</Alert>
) : null}
{loading ? ( {loading ? (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => ( {Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-24 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 gap-3 sm:grid-cols-2 xl:grid-cols-4"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<DashboardKpiCard <DashboardKpiCard
label={t("site.todayBet")} label={t("site.todayBet")}
value={formatDashboardMoneyMinor(overview.today_bet_minor, displayCurrency)} value={formatDashboardMoneyMinor(overview.today_bet_minor, displayCurrency)}
icon={<TrendingUp className="size-4" />} icon={<TrendingUp className="size-4" />}
hint={ hint={buildTodayBetHint(businessDateToday, overview.latest_bet_at, t, formatDt)}
overview.latest_bet_at
? t("site.latestBetAt", { time: formatDt(overview.latest_bet_at) })
: t("site.noBetToday")
}
/> />
<DashboardKpiCard <DashboardKpiCard
label={t("site.todayProfit")} label={t("site.todayProfit")}
value={formatDashboardSignedMoneyMinor(overview.today_profit_minor, displayCurrency)} signedAmountMinor={overview.today_profit_minor}
currencyCode={displayCurrency}
icon={<BarChart3 className="size-4" />} icon={<BarChart3 className="size-4" />}
hint={t("site.profitScopeHint")} hint={t("todayPayoutHint", {
valueClassName={signedMoneyClass(overview.today_profit_minor, true)} amount: formatDashboardMoneyMinor(overview.today_payout_minor, displayCurrency),
})}
/> />
<DashboardKpiCard <DashboardKpiCard
label={t("site.activePlayersToday")} label={t("site.activePlayersToday")}
@@ -192,24 +203,21 @@ export function SiteDashboardConsole(): ReactElement {
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">{t("site.sevenDayTitle")}</CardTitle> <CardTitle className="text-sm font-semibold">{t("site.sevenDayTitle")}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-2 text-sm"> <CardContent className="space-y-2">
<div className="flex items-center justify-between gap-3"> <DashboardStatRow
<span className="text-muted-foreground">{t("site.todayBet")}</span> label={t("site.sevenDayBet")}
<span className="font-semibold tabular-nums"> value={formatDashboardMoneyMinor(overview.seven_day_bet_minor, displayCurrency)}
{formatDashboardMoneyMinor(overview.seven_day_bet_minor, displayCurrency)} />
</span> <DashboardStatRow
</div> label={t("site.sevenDayPayout")}
<div className="flex items-center justify-between gap-3"> value={formatDashboardMoneyMinor(overview.seven_day_payout_minor, displayCurrency)}
<span className="text-muted-foreground">{t("site.sevenDayProfit")}</span> />
<span <DashboardSignedStatRow
className={cn( label={t("site.sevenDayProfit")}
"font-semibold tabular-nums", amountMinor={overview.seven_day_profit_minor}
signedMoneyClass(overview.seven_day_profit_minor, true), currencyCode={displayCurrency}
)} />
> <p className="pt-1 text-xs text-muted-foreground">{t("site.profitScopeHint")}</p>
{formatDashboardSignedMoneyMinor(overview.seven_day_profit_minor, displayCurrency)}
</span>
</div>
</CardContent> </CardContent>
</Card> </Card>
@@ -218,24 +226,31 @@ export function SiteDashboardConsole(): ReactElement {
<CardTitle className="text-sm font-semibold">{t("site.scaleTitle")}</CardTitle> <CardTitle className="text-sm font-semibold">{t("site.scaleTitle")}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="grid grid-cols-2 gap-3 text-sm"> <CardContent className="grid grid-cols-2 gap-3 text-sm">
<SiteMetric label={t("site.agentCount")} value={String(overview.agent_count)} /> <DashboardScopeMetric label={t("site.agentCount")} value={String(overview.agent_count)} />
<SiteMetric label={t("site.playerCount")} value={String(overview.player_count)} /> <DashboardScopeMetric label={t("site.playerCount")} value={String(overview.player_count)} />
{overview.top_agent_today ? ( {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"> <div className="col-span-2 rounded-lg border bg-muted/20 px-3 py-2.5">
{t("site.topAgentToday", { <p className="text-xs text-muted-foreground">{t("site.topAgentTodayLabel")}</p>
name: overview.top_agent_today.agent_name || overview.top_agent_today.agent_code, <p className="mt-1 font-medium text-foreground">
amount: formatDashboardMoneyMinor( {overview.top_agent_today.agent_name || overview.top_agent_today.agent_code}
</p>
<p className="mt-0.5 break-all text-sm font-semibold tabular-nums">
{formatDashboardMoneyMinor(
overview.top_agent_today.total_bet_minor, overview.top_agent_today.total_bet_minor,
displayCurrency, displayCurrency,
), )}
})} </p>
</div> </div>
) : null} ) : null}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</section> </section>
) : null} ) : (
<AdminNoResourceState className="py-12 text-sm text-muted-foreground">
{t("site.overviewEmpty")}
</AdminNoResourceState>
)}
<DashboardCurrentDrawCard <DashboardCurrentDrawCard
key={`${hall?.draw_no ?? "empty"}:${loading ? "loading" : "ready"}`} key={`${hall?.draw_no ?? "empty"}:${loading ? "loading" : "ready"}`}

View File

@@ -35,7 +35,6 @@ import {
} from "@/api/admin-reports"; } from "@/api/admin-reports";
import { import {
buildReportJobParameters, buildReportJobParameters,
REPORT_UI_SERVER_FULL_EXPORT,
REPORT_UI_TO_JOB_TYPE, REPORT_UI_TO_JOB_TYPE,
type ReportUiKey, type ReportUiKey,
} from "@/lib/report-export-map"; } from "@/lib/report-export-map";
@@ -239,37 +238,6 @@ function resolveDisplayCurrency(apiCode?: string | null): string {
return fallback?.trim() || "NPR"; return fallback?.trim() || "NPR";
} }
function reportTimeAxisKey(key: ReportKey): "businessDate" | "recordCreatedAt" | null {
switch (key) {
case "daily_profit":
case "player_win_loss":
case "play_dimension":
case "rebate_commission":
return "businessDate";
case "player_transfer":
case "admin_audit":
return "recordCreatedAt";
default:
return null;
}
}
function reportDisclaimerKey(key: ReportKey): string | null {
switch (key) {
case "draw_profit":
case "daily_profit":
case "player_win_loss":
case "play_dimension":
return "items.profit_reports.disclaimer";
case "player_transfer":
return "items.player_transfer.disclaimer";
case "rebate_commission":
return "items.rebate_commission.disclaimer";
default:
return null;
}
}
const emptySearch: SearchState = { const emptySearch: SearchState = {
open: null, open: null,
query: "", query: "",
@@ -328,41 +296,6 @@ function formatExportInstant(iso: string | null | undefined): ExportCell {
return formatAdminInstant(iso, { locale: getAdminRequestLocale() }); return formatAdminInstant(iso, { locale: getAdminRequestLocale() });
} }
function toCsvValue(value: ExportCell): string {
if (value == null) {
return "";
}
const stringValue = String(value);
if (/[",\n]/.test(stringValue)) {
return `"${stringValue.replace(/"/g, '""')}"`;
}
return stringValue;
}
async function exportRows(rows: ExportRow[], filename: string, sheetName: string, format: ExportFormat): Promise<void> {
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( function buildDailyProfitRowsAndSummary(
items: AdminReportDailyProfitRow[], items: AdminReportDailyProfitRow[],
total: number, total: number,
@@ -742,7 +675,7 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
}, [filteredReports, selectedKey]); }, [filteredReports, selectedKey]);
const pageScopedLabel = useCallback( const pageScopedLabel = useCallback(
(statKey: string) => `${t(`preview.stats.${statKey}`)} · ${t("preview.scope.currentPage")}`, (statKey: string) => t(`preview.stats.${statKey}`),
[t], [t],
); );
@@ -1233,9 +1166,7 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
setPage(1); setPage(1);
} }
const usesServerExport = REPORT_UI_SERVER_FULL_EXPORT.has(selectedReport.key as ReportUiKey); async function exportReport(format: ExportFormat): Promise<void> {
async function exportViaServer(format: ExportFormat): Promise<void> {
if (!canExportReports) { if (!canExportReports) {
return; return;
} }
@@ -1260,10 +1191,9 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
const ext = job.export_format === "xlsx" ? "xlsx" : "csv"; const ext = job.export_format === "xlsx" ? "xlsx" : "csv";
downloadBlob(blob, filename ?? `${exportFileBase}.${ext}`); downloadBlob(blob, filename ?? `${exportFileBase}.${ext}`);
toast.success( toast.success(
t("exportServerSuccess", { t("exportSuccess", {
report: t(`items.${selectedReport.key}.title`), report: t(`items.${selectedReport.key}.title`),
format: t(`formats.${format}`), format: t(`formats.${format}`),
jobNo: job.job_no,
}), }),
); );
} catch (err) { } 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 renderSearchPicker = (kind: SearchKind) => {
const value = const value =
kind === "draw" ? filters.drawNo : kind === "player" ? filters.player : filters.operator; kind === "draw" ? filters.drawNo : kind === "player" ? filters.player : filters.operator;
@@ -1726,21 +1626,13 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
})} })}
</div> </div>
<div className="text-sm text-muted-foreground">{t(`items.${selectedReport.key}.summary`)}</div> <div className="text-sm text-muted-foreground">{t(`items.${selectedReport.key}.summary`)}</div>
{reportTimeAxisKey(selectedReport.key) ? (
<p className="text-xs text-muted-foreground">{t(`timeAxis.${reportTimeAxisKey(selectedReport.key)}`)}</p>
) : null}
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-3 pt-0"> <CardContent className="space-y-3 pt-0">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4"> <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{selectedReport.fields.map(renderField)} {selectedReport.fields.map(renderField)}
</div> </div>
<div className="flex flex-col gap-2 border-t border-border/60 pt-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex justify-end gap-2 border-t border-border/60 pt-3">
<div className="space-y-1 text-xs text-muted-foreground">
<div>{t("filterPanel")}</div>
<div>{t("queryHint")}</div>
</div>
<div className="flex shrink-0 gap-2">
<Button type="button" variant="outline" size="sm" onClick={resetFilters}> <Button type="button" variant="outline" size="sm" onClick={resetFilters}>
{t("reset")} {t("reset")}
</Button> </Button>
@@ -1757,7 +1649,6 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
{loading ? t("querying") : t("query")} {loading ? t("querying") : t("query")}
</Button> </Button>
</div> </div>
</div>
</CardContent> </CardContent>
</Card> </Card>
@@ -1770,70 +1661,34 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
))} ))}
</div> </div>
{reportDisclaimerKey(selectedReport.key) ? (
<div className="rounded-md border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-950 dark:border-amber-700 dark:bg-amber-950/30 dark:text-amber-100">
{t(reportDisclaimerKey(selectedReport.key)!)}
</div>
) : null}
<Card className="admin-list-card"> <Card className="admin-list-card">
<CardHeader className="admin-list-header flex flex-col gap-2 pb-3 sm:flex-row sm:items-center sm:justify-between"> <CardHeader className="admin-list-header flex flex-col gap-2 pb-3 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<CardTitle className="admin-list-title">{t("preview.title")}</CardTitle> <CardTitle className="admin-list-title">{t("preview.title")}</CardTitle>
</div> </div>
<div className="flex flex-col items-end gap-1.5">
<div className="flex flex-wrap justify-end gap-2"> <div className="flex flex-wrap justify-end gap-2">
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="sm" size="sm"
disabled={!canExportReports || exporting !== null} disabled={!canExportReports || exporting !== null}
onClick={() => exportReport("csv")} onClick={() => void exportReport("csv")}
> >
<FileDown data-icon="inline-start" /> <FileDown data-icon="inline-start" />
{t("formats.csvServer")} {t("formats.csv")}
</Button> </Button>
<Button <Button
type="button" type="button"
size="sm" size="sm"
disabled={!canExportReports || exporting !== null} disabled={!canExportReports || exporting !== null}
onClick={() => exportReport("excel")} onClick={() => void exportReport("excel")}
> >
<FileSpreadsheet data-icon="inline-start" /> <FileSpreadsheet data-icon="inline-start" />
{t("formats.excelServer")} {t("formats.excel")}
</Button> </Button>
</div> </div>
{result && result.rows.length > 0 ? (
<>
<p className="text-xs text-muted-foreground">{t("exportPreviewHint")}</p>
<div className="flex flex-wrap justify-end gap-2">
<Button
type="button"
variant="ghost"
size="sm"
disabled={!canExportReports || exporting !== null}
onClick={() => exportPreview("csv")}
>
{t("formats.csvPreview")}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
disabled={!canExportReports || exporting !== null}
onClick={() => exportPreview("excel")}
>
{t("formats.excelPreview")}
</Button>
</div>
</>
) : null}
</div>
</CardHeader> </CardHeader>
<CardContent className="space-y-3 pt-3"> <CardContent className="space-y-3 pt-3">
<div className="rounded-md border border-amber-200 bg-amber-50/70 px-3 py-2 text-xs text-amber-950">
{t("preview.summaryScopeHint")}
</div>
<Table id="reports-preview-table"> <Table id="reports-preview-table">
<TableHeader> <TableHeader>
<TableRow> <TableRow>