diff --git a/src/api/admin-audit.ts b/src/api/admin-audit.ts index 52a245d..663db29 100644 --- a/src/api/admin-audit.ts +++ b/src/api/admin-audit.ts @@ -12,6 +12,9 @@ export async function getAdminAuditLogs(params?: { module_code?: string; action_code?: string; operator_type?: string; + operator_id?: number; + start_date?: string; + end_date?: string; }): Promise { return adminRequest.get(`${A}/audit-logs`, { params, diff --git a/src/api/admin-reports.ts b/src/api/admin-reports.ts new file mode 100644 index 0000000..1508782 --- /dev/null +++ b/src/api/admin-reports.ts @@ -0,0 +1,46 @@ +import { adminRequest } from "@/lib/admin-http"; + +import { API_V1_PREFIX } from "./paths"; + +import type { + AdminReportDailyProfitRow, + AdminReportListData, + AdminReportPlayDimensionRow, + AdminReportPlayerWinLossRow, + AdminReportQueryParams, + AdminReportRebateCommissionRow, +} from "@/types/api/admin-reports"; + +const A = `${API_V1_PREFIX}/admin`; + +export async function getAdminReportDailyProfit( + params: AdminReportQueryParams, +): Promise> { + return adminRequest.get>(`${A}/reports/daily-profit`, { + params, + }); +} + +export async function getAdminReportPlayerWinLoss( + params: AdminReportQueryParams, +): Promise> { + return adminRequest.get>(`${A}/reports/player-win-loss`, { + params, + }); +} + +export async function getAdminReportPlayDimension( + params: AdminReportQueryParams, +): Promise> { + return adminRequest.get>(`${A}/reports/play-dimension`, { + params, + }); +} + +export async function getAdminReportRebateCommission( + params: AdminReportQueryParams, +): Promise> { + return adminRequest.get>(`${A}/reports/rebate-commission`, { + params, + }); +} diff --git a/src/api/index.ts b/src/api/index.ts index 6026b65..096e01c 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -14,6 +14,12 @@ export { postAdminReconcileJob, } from "@/api/admin-reconcile"; export { getAdminAuditLogs } from "@/api/admin-audit"; +export { + getAdminReportDailyProfit, + getAdminReportPlayDimension, + getAdminReportPlayerWinLoss, + getAdminReportRebateCommission, +} from "@/api/admin-reports"; export { getAdminDraw, getAdminDrawFinanceSummary, diff --git a/src/app/admin/(shell)/reports/page.tsx b/src/app/admin/(shell)/reports/page.tsx new file mode 100644 index 0000000..8b7331e --- /dev/null +++ b/src/app/admin/(shell)/reports/page.tsx @@ -0,0 +1,14 @@ +import { ModuleScaffold } from "@/components/admin/module-scaffold"; +import { buildPageMetadata } from "@/lib/page-metadata"; +import { ReportsConsole } from "@/modules/reports/reports-console"; +import type { Metadata } from "next"; + +export const metadata: Metadata = buildPageMetadata("reports", "title"); + +export default function AdminReportsPage() { + return ( + + + + ); +} diff --git a/src/components/admin/admin-breadcrumb.tsx b/src/components/admin/admin-breadcrumb.tsx index c57e8cb..bc60072 100644 --- a/src/components/admin/admin-breadcrumb.tsx +++ b/src/components/admin/admin-breadcrumb.tsx @@ -36,6 +36,7 @@ const NAV_TRANSLATION_KEYS: Record = { risk: "risk", settlement: "settlement", reconcile: "reconcile", + reports: "reports", tickets: "tickets", audit: "audit", settings: "settings", @@ -68,7 +69,7 @@ type BreadcrumbCrumb = { }; export function AdminBreadcrumb() { - const { t } = useTranslation(["common", "dashboard", "audit", "config", "draws"]); + const { t } = useTranslation(["common", "dashboard", "audit", "config", "draws", "reports"]); const pathname = usePathname(); const profile = useAdminProfile(); const navItems = profile?.navigation ?? []; diff --git a/src/components/admin/admin-sidebar.tsx b/src/components/admin/admin-sidebar.tsx index 35ecfd9..7bf1c53 100644 --- a/src/components/admin/admin-sidebar.tsx +++ b/src/components/admin/admin-sidebar.tsx @@ -36,7 +36,7 @@ function isActive(pathname: string, item: { href: string; activeMatchPrefix?: st } export function AdminAppSidebar() { - const { t } = useTranslation(["common", "dashboard", "players", "draws", "config", "wallet", "risk", "settlement", "jackpot", "reconcile", "tickets", "audit"]); + const { t } = useTranslation(["common", "dashboard", "players", "draws", "config", "wallet", "risk", "settlement", "jackpot", "reconcile", "tickets", "audit", "reports"]); const pathname = usePathname(); const profile = useAdminProfile(); const visibleNav = useMemo( diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 45de9b3..2a70859 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -17,6 +17,7 @@ import enSettlement from "@/i18n/locales/en/settlement.json"; import enPlayers from "@/i18n/locales/en/players.json"; import enTickets from "@/i18n/locales/en/tickets.json"; import enReconcile from "@/i18n/locales/en/reconcile.json"; +import enReports from "@/i18n/locales/en/reports.json"; import enWallet from "@/i18n/locales/en/wallet.json"; import neAudit from "@/i18n/locales/ne/audit.json"; import neAdminUsers from "@/i18n/locales/ne/adminUsers.json"; @@ -31,6 +32,7 @@ import neSettlement from "@/i18n/locales/ne/settlement.json"; import nePlayers from "@/i18n/locales/ne/players.json"; import neTickets from "@/i18n/locales/ne/tickets.json"; import neReconcile from "@/i18n/locales/ne/reconcile.json"; +import neReports from "@/i18n/locales/ne/reports.json"; import neWallet from "@/i18n/locales/ne/wallet.json"; import zhAudit from "@/i18n/locales/zh/audit.json"; import zhAdminUsers from "@/i18n/locales/zh/adminUsers.json"; @@ -45,13 +47,14 @@ import zhSettlement from "@/i18n/locales/zh/settlement.json"; import zhPlayers from "@/i18n/locales/zh/players.json"; import zhTickets from "@/i18n/locales/zh/tickets.json"; import zhReconcile from "@/i18n/locales/zh/reconcile.json"; +import zhReports from "@/i18n/locales/zh/reports.json"; import zhWallet from "@/i18n/locales/zh/wallet.json"; export const ADMIN_SUPPORTED_LANGUAGES = ["en", "ne", "zh"] as const; export type AdminLanguage = (typeof ADMIN_SUPPORTED_LANGUAGES)[number]; export const ADMIN_DEFAULT_LANGUAGE: AdminLanguage = "zh"; -const namespaces = ["common", "auth", "dashboard", "audit", "draws", "settlement", "risk", "jackpot", "players", "tickets", "reconcile", "wallet", "adminUsers", "config"] as const; +const namespaces = ["common", "auth", "dashboard", "audit", "draws", "settlement", "risk", "jackpot", "players", "tickets", "reconcile", "reports", "wallet", "adminUsers", "config"] as const; const resources = { en: { @@ -65,6 +68,7 @@ const resources = { players: enPlayers, tickets: enTickets, reconcile: enReconcile, + reports: enReports, risk: enRisk, audit: enAudit, settlement: enSettlement, @@ -81,6 +85,7 @@ const resources = { players: nePlayers, tickets: neTickets, reconcile: neReconcile, + reports: neReports, risk: neRisk, audit: neAudit, settlement: neSettlement, @@ -97,6 +102,7 @@ const resources = { players: zhPlayers, tickets: zhTickets, reconcile: zhReconcile, + reports: zhReports, risk: zhRisk, audit: zhAudit, settlement: zhSettlement, diff --git a/src/i18n/locales/en/audit.json b/src/i18n/locales/en/audit.json index eee6271..105f166 100644 --- a/src/i18n/locales/en/audit.json +++ b/src/i18n/locales/en/audit.json @@ -3,6 +3,7 @@ "moduleCode": "Module code", "actionCode": "Action code", "operatorType": "Operator type", + "operatorIdPlaceholder": "Enter operator ID", "exactMatch": "Exact match", "operatorTypePlaceholder": "For example admin / system", "operator": "Operator", diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 0621573..c5c25b8 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -94,6 +94,7 @@ "players": "Players", "currencies": "Currencies", "wallet": "Wallet", + "reports": "Reports", "draws": "Draws", "rules_plays": "Play rules", "rules_odds": "Odds & rebate", diff --git a/src/i18n/locales/en/reports.json b/src/i18n/locales/en/reports.json new file mode 100644 index 0000000..f869ec1 --- /dev/null +++ b/src/i18n/locales/en/reports.json @@ -0,0 +1,172 @@ +{ + "title": "Reports", + "subtitle": "Centralized operational, finance, risk, and audit reports with unified export filters.", + "exportPanel": "Export setup", + "chooseReport": "Choose a report to export", + "libraryTitle": "Report types", + "exportConfig": "Export filters", + "recentTasks": "Recent export tasks", + "taskEmpty": "No export tasks yet", + "dimension": "Dimension", + "exportPending": "{{report}} {{format}} export API is not connected yet", + "exportSuccess": "Exported {{report}} ({{format}})", + "exportFailed": "Export failed", + "exportHint": "Once export APIs are connected, the current filters will generate the selected file format.", + "validation": { + "drawNoRequired": "Please enter a draw number", + "drawNoNumberRequired": "Please enter both draw number and number" + }, + "formats": { + "csv": "CSV", + "excel": "Excel" + }, + "empty": "No matching reports", + "connected": "Connected", + "pending": "Pending", + "backendPending": "Backend API not connected yet", + "filterPanel": "Filters", + "queryHint": "Set filters and run a query to preview and export.", + "query": "Query", + "querying": "Querying…", + "reset": "Reset", + "loadFailed": "Failed to load", + "yes": "Yes", + "no": "No", + "filterAll": "All plays", + "searchPicker": { + "open": "Open picker", + "select": "Pick", + "keyword": "Search keyword" + }, + "preview": { + "title": "Preview", + "subtitle": "Results appear below. Export as CSV or Excel.", + "empty": "No data. Adjust filters and try again.", + "exportableRows": "rows exportable", + "columns": { + "primary": "Primary", + "secondary": "Secondary", + "metricA": "Metric A", + "metricB": "Metric B", + "metricC": "Metric C", + "status": "Status", + "extra": "Extra", + "time": "Time" + }, + "stats": { + "records": "Records", + "currentPage": "This page", + "drawNo": "Draw no.", + "currency": "Currency", + "exportRows": "Export rows", + "bet": "Bets", + "payout": "Payout", + "houseGross": "House P&L", + "orders": "Orders", + "locked": "Locked", + "remaining": "Remaining", + "usage": "Usage", + "logs": "Logs", + "modules": "Modules", + "operators": "Operators", + "players": "Players", + "transferIn": "Transfer in", + "transferOut": "Transfer out", + "rebate": "Total rebate" + } + }, + "stats": { + "total": "Total reports", + "current": "Current list", + "formats": "Export formats" + }, + "table": { + "report": "Report", + "category": "Category", + "dimension": "Dimension", + "description": "Description", + "formats": "Formats", + "actions": "Actions", + "status": "Status", + "createdAt": "Created at" + }, + "categories": { + "all": "All", + "profit": "Profit", + "wallet": "Funds", + "risk": "Risk", + "audit": "Audit" + }, + "filters": { + "draw": "Draw", + "date": "Date", + "player_period": "Player + period", + "draw_number": "Draw + number", + "play": "Play", + "play_period": "Play + period", + "operator_period": "Operator + period" + }, + "scopes": { + "drawNo": "Draw", + "date": "Date", + "playerPeriod": "Player + period", + "drawNumber": "Draw + number", + "play": "Play", + "playPeriod": "Play + period", + "operatorPeriod": "Operator + period" + }, + "fields": { + "drawNo": "Draw no.", + "number": "Number", + "period": "Period", + "player": "Player", + "play": "Play", + "operator": "Operator" + }, + "placeholders": { + "drawNo": "e.g. 20260522-001", + "number": "Enter number", + "keyword": "Search report name / description", + "player": "Player ID / name / phone", + "play": "Play name or code", + "operator": "Admin account or name" + }, + "items": { + "draw_profit": { + "title": "Draw bets / payout / P&L", + "summary": "Review bet amount, payout, platform P&L, and settlement state by draw." + }, + "daily_profit": { + "title": "Daily P&L summary", + "summary": "Summarize bets, payouts, refunds, P&L, and net amount by date." + }, + "player_win_loss": { + "title": "Player win/loss report", + "summary": "Track player win/loss over a selected period for finance and support review." + }, + "player_transfer": { + "title": "Player transfer report", + "summary": "Review player transfers in, transfers out, reversals, and exception handling." + }, + "hot_number_risk": { + "title": "Hot number risk report", + "summary": "Inspect betting heat, risk occupancy, and cap proximity by draw and number." + }, + "play_dimension": { + "title": "Play dimension report", + "summary": "Break down betting volume, payout, rebate, and P&L structure by play." + }, + "sold_out_number": { + "title": "Sold-out number report", + "summary": "Review sold-out numbers, sold-out time, and risk lock state by draw." + }, + "rebate_commission": { + "title": "Commission / rebate report", + "summary": "Summarize commission, rebate, and matched rules by play and period." + }, + "admin_audit": { + "title": "Admin operation audit report", + "summary": "Export key admin operation traces by operator and period." + } + } +} diff --git a/src/i18n/locales/ne/audit.json b/src/i18n/locales/ne/audit.json index 3910cc9..7c413b5 100644 --- a/src/i18n/locales/ne/audit.json +++ b/src/i18n/locales/ne/audit.json @@ -3,6 +3,7 @@ "moduleCode": "मोड्युल कोड", "actionCode": "कार्य कोड", "operatorType": "अपरेटर प्रकार", + "operatorIdPlaceholder": "अपरेटर ID प्रविष्ट गर्नुहोस्", "exactMatch": "ठ्याक्कै मिलान", "operatorTypePlaceholder": "जस्तै admin / system", "operator": "अपरेटर", diff --git a/src/i18n/locales/ne/common.json b/src/i18n/locales/ne/common.json index 1ed9c9f..5409527 100644 --- a/src/i18n/locales/ne/common.json +++ b/src/i18n/locales/ne/common.json @@ -94,6 +94,7 @@ "players": "खेलाडी सूची", "currencies": "मुद्रा व्यवस्थापन", "wallet": "वालेट", + "reports": "रिपोर्ट", "draws": "ड्रअहरू", "rules_plays": "खेल नियम", "rules_odds": "बाधा र रिबेट", diff --git a/src/i18n/locales/ne/reports.json b/src/i18n/locales/ne/reports.json new file mode 100644 index 0000000..e14ccc8 --- /dev/null +++ b/src/i18n/locales/ne/reports.json @@ -0,0 +1,172 @@ +{ + "title": "रिपोर्ट", + "subtitle": "सञ्चालन, वित्त, जोखिम र अडिट रिपोर्टहरू एउटै ठाउँबाट फिल्टर गरी निर्यात गर्नुहोस्।", + "exportPanel": "निर्यात सेटअप", + "chooseReport": "निर्यात गर्ने रिपोर्ट छान्नुहोस्", + "libraryTitle": "रिपोर्ट प्रकार", + "exportConfig": "निर्यात फिल्टर", + "recentTasks": "हालका निर्यात कार्यहरू", + "taskEmpty": "निर्यात कार्य छैन", + "dimension": "आयाम", + "exportPending": "{{report}} {{format}} निर्यात API अझै जोडिएको छैन", + "exportSuccess": "{{report}} ({{format}}) निर्यात भयो", + "exportFailed": "निर्यात असफल भयो", + "exportHint": "निर्यात API जोडिएपछि हालका फिल्टरअनुसार छानिएको फाइल ढाँचा बनाइनेछ।", + "validation": { + "drawNoRequired": "कृपया ड्र नं. प्रविष्ट गर्नुहोस्", + "drawNoNumberRequired": "कृपया ड्र नं. र नम्बर दुवै प्रविष्ट गर्नुहोस्" + }, + "formats": { + "csv": "CSV", + "excel": "Excel" + }, + "empty": "मिल्ने रिपोर्ट छैन", + "connected": "जोडिएको", + "pending": "बाँकी", + "backendPending": "ब्याकएन्ड API जोडिन बाँकी", + "filterPanel": "फिल्टर", + "queryHint": "फिल्टर सेट गरी क्वेरी चलाउनुहोस्।", + "query": "क्वेरी", + "querying": "क्वेरी हुँदै…", + "reset": "रिसेट", + "loadFailed": "लोड असफल", + "yes": "हो", + "no": "होइन", + "filterAll": "सबै प्ले", + "searchPicker": { + "open": "छनोट खोल्नुहोस्", + "select": "छान्नुहोस्", + "keyword": "खोज शब्द" + }, + "preview": { + "title": "पूर्वावलोकन", + "subtitle": "तल तालिकामा नतिजा देखिन्छ।", + "empty": "डाटा छैन।", + "exportableRows": "पङ्क्ति निर्यात योग्य", + "columns": { + "primary": "मुख्य", + "secondary": "दोस्रो", + "metricA": "मेट्रिक A", + "metricB": "मेट्रिक B", + "metricC": "मेट्रिक C", + "status": "स्थिति", + "extra": "अतिरिक्त", + "time": "समय" + }, + "stats": { + "records": "रेकर्ड", + "currentPage": "यो पृष्ठ", + "drawNo": "ड्र नं.", + "currency": "मुद्रा", + "exportRows": "निर्यात पङ्क्ति", + "bet": "बेट", + "payout": "भुक्तानी", + "houseGross": "हाउस P&L", + "orders": "अर्डर", + "locked": "लक", + "remaining": "बाँकी", + "usage": "उपयोग", + "logs": "लग", + "modules": "मोड्युल", + "operators": "अपरेटर", + "players": "खेलाडी", + "transferIn": "भित्र", + "transferOut": "बाहिर", + "rebate": "रिबेट" + } + }, + "stats": { + "total": "कुल रिपोर्ट", + "current": "हालको सूची", + "formats": "निर्यात ढाँचा" + }, + "table": { + "report": "रिपोर्ट", + "category": "वर्ग", + "dimension": "आयाम", + "description": "विवरण", + "formats": "ढाँचा", + "actions": "कार्य", + "status": "स्थिति", + "createdAt": "सिर्जना समय" + }, + "categories": { + "all": "सबै", + "profit": "नाफा/घाटा", + "wallet": "कोष", + "risk": "जोखिम", + "audit": "अडिट" + }, + "filters": { + "draw": "ड्र", + "date": "मिति", + "player_period": "खेलाडी + अवधि", + "draw_number": "ड्र + नम्बर", + "play": "खेल", + "play_period": "खेल + अवधि", + "operator_period": "अपरेटर + अवधि" + }, + "scopes": { + "drawNo": "ड्र", + "date": "मिति", + "playerPeriod": "खेलाडी + अवधि", + "drawNumber": "ड्र + नम्बर", + "play": "खेल", + "playPeriod": "खेल + अवधि", + "operatorPeriod": "अपरेटर + अवधि" + }, + "fields": { + "drawNo": "ड्र नं.", + "number": "नम्बर", + "period": "अवधि", + "player": "खेलाडी", + "play": "खेल", + "operator": "अपरेटर" + }, + "placeholders": { + "drawNo": "जस्तै 20260522-001", + "number": "नम्बर लेख्नुहोस्", + "keyword": "रिपोर्ट नाम / विवरण खोज्नुहोस्", + "player": "खेलाडी ID / नाम / फोन", + "play": "खेल नाम वा कोड", + "operator": "एडमिन खाता वा नाम" + }, + "items": { + "draw_profit": { + "title": "ड्र बेट / पेआउट / P&L", + "summary": "ड्र अनुसार बेट रकम, पेआउट, प्लेटफर्म P&L र सेटलमेन्ट स्थिति हेर्नुहोस्।" + }, + "daily_profit": { + "title": "दैनिक P&L सारांश", + "summary": "मिति अनुसार बेट, पेआउट, रिफन्ड, P&L र नेट रकम सारांश गर्नुहोस्।" + }, + "player_win_loss": { + "title": "खेलाडी जित/हार रिपोर्ट", + "summary": "चयन गरिएको अवधिमा खेलाडीको जित/हार वित्त र सपोर्टका लागि हेर्नुहोस्।" + }, + "player_transfer": { + "title": "खेलाडी ट्रान्सफर रिपोर्ट", + "summary": "खेलाडी ट्रान्सफर इन, आउट, रिभर्सल र अपवाद रेकर्ड हेर्नुहोस्।" + }, + "hot_number_risk": { + "title": "हट नम्बर जोखिम रिपोर्ट", + "summary": "ड्र र नम्बर अनुसार बेटिङ हिट, जोखिम प्रयोग र क्याप नजिकिएको अवस्था हेर्नुहोस्।" + }, + "play_dimension": { + "title": "खेल आयाम रिपोर्ट", + "summary": "खेल अनुसार बेट भोल्युम, पेआउट, रिबेट र P&L संरचना छुट्याउनुहोस्।" + }, + "sold_out_number": { + "title": "सोल्ड-आउट नम्बर रिपोर्ट", + "summary": "ड्र अनुसार सोल्ड-आउट नम्बर, समय र जोखिम लक अवस्था हेर्नुहोस्।" + }, + "rebate_commission": { + "title": "कमिसन / रिबेट रिपोर्ट", + "summary": "खेल र अवधिअनुसार कमिसन, रिबेट र मिलेको नियम सारांश गर्नुहोस्।" + }, + "admin_audit": { + "title": "एडमिन अपरेशन अडिट रिपोर्ट", + "summary": "अपरेटर र अवधिअनुसार मुख्य एडमिन अपरेशन ट्रेस निर्यात गर्नुहोस्।" + } + } +} diff --git a/src/i18n/locales/zh/audit.json b/src/i18n/locales/zh/audit.json index 95293e8..f2f34b8 100644 --- a/src/i18n/locales/zh/audit.json +++ b/src/i18n/locales/zh/audit.json @@ -3,6 +3,7 @@ "moduleCode": "模块", "actionCode": "动作", "operatorType": "操作者类型", + "operatorIdPlaceholder": "请输入操作人 ID", "exactMatch": "请输入完整名称", "operatorTypePlaceholder": "如管理员、系统", "operator": "操作者", diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index b3ba913..ce160e1 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -94,6 +94,7 @@ "players": "玩家列表", "currencies": "币种管理", "wallet": "钱包流水", + "reports": "报表中心", "draws": "期号列表", "rules_plays": "投注规则", "rules_odds": "赔率与回水", diff --git a/src/i18n/locales/zh/reports.json b/src/i18n/locales/zh/reports.json new file mode 100644 index 0000000..07daff4 --- /dev/null +++ b/src/i18n/locales/zh/reports.json @@ -0,0 +1,172 @@ +{ + "title": "报表中心", + "subtitle": "集中查看运营、资金、风控与审计报表,统一按维度筛选后导出。", + "exportPanel": "导出设置", + "chooseReport": "选择要导出的报表", + "libraryTitle": "报表类型", + "exportConfig": "导出条件", + "recentTasks": "最近导出任务", + "taskEmpty": "暂无导出任务", + "dimension": "维度", + "exportPending": "{{report}} 的 {{format}} 导出接口待接入", + "exportSuccess": "已导出 {{report}}({{format}})", + "exportFailed": "导出失败", + "exportHint": "接入导出接口后,会按当前条件生成对应格式的文件。", + "validation": { + "drawNoRequired": "请输入期号", + "drawNoNumberRequired": "请输入期号和号码" + }, + "formats": { + "csv": "CSV", + "excel": "Excel" + }, + "empty": "没有匹配的报表", + "connected": "已接入", + "pending": "待接入", + "backendPending": "后端接口待接入", + "filterPanel": "筛选条件", + "queryHint": "设置筛选条件后点击查询,可预览并导出。", + "query": "查询", + "querying": "查询中…", + "reset": "重置", + "loadFailed": "加载失败", + "yes": "是", + "no": "否", + "filterAll": "全部玩法", + "searchPicker": { + "open": "打开选择器", + "select": "选择", + "keyword": "输入关键词搜索" + }, + "preview": { + "title": "数据预览", + "subtitle": "查询结果将显示在下方表格,可导出 CSV 或 Excel。", + "empty": "暂无数据,请调整筛选条件后重试。", + "exportableRows": "行可导出", + "columns": { + "primary": "主字段", + "secondary": "次字段", + "metricA": "指标 A", + "metricB": "指标 B", + "metricC": "指标 C", + "status": "状态", + "extra": "附加", + "time": "时间" + }, + "stats": { + "records": "记录数", + "currentPage": "当前页", + "drawNo": "期号", + "currency": "币种", + "exportRows": "导出行数", + "bet": "下注", + "payout": "派彩", + "houseGross": "平台盈亏", + "orders": "订单数", + "locked": "已占用", + "remaining": "剩余额度", + "usage": "使用率", + "logs": "日志数", + "modules": "模块数", + "operators": "操作人数", + "players": "玩家数", + "transferIn": "转入笔数", + "transferOut": "转出笔数", + "rebate": "回水合计" + } + }, + "stats": { + "total": "全部报表", + "current": "当前列表", + "formats": "导出格式" + }, + "table": { + "report": "报表名称", + "category": "分类", + "dimension": "统计维度", + "description": "说明", + "formats": "格式", + "actions": "操作", + "status": "状态", + "createdAt": "创建时间" + }, + "categories": { + "all": "全部", + "profit": "盈亏", + "wallet": "资金", + "risk": "风控", + "audit": "审计" + }, + "filters": { + "draw": "期号", + "date": "日期", + "player_period": "玩家 + 时间段", + "draw_number": "期号 + 号码", + "play": "玩法", + "play_period": "玩法 + 时间段", + "operator_period": "操作人 + 时间段" + }, + "scopes": { + "drawNo": "期号", + "date": "日期", + "playerPeriod": "玩家 + 时间段", + "drawNumber": "期号 + 号码", + "play": "玩法", + "playPeriod": "玩法 + 时间段", + "operatorPeriod": "操作人 + 时间段" + }, + "fields": { + "drawNo": "期号", + "number": "号码", + "period": "时间段", + "player": "玩家", + "play": "玩法", + "operator": "操作人" + }, + "placeholders": { + "drawNo": "例如 20260522-001", + "number": "输入号码", + "keyword": "搜索报表名称 / 说明", + "player": "玩家 ID / 昵称 / 手机", + "play": "玩法名称或编码", + "operator": "管理员账号或昵称" + }, + "items": { + "draw_profit": { + "title": "每期下注/派彩/盈亏", + "summary": "按期号核对下注额、派彩金额、平台盈亏与结算状态。" + }, + "daily_profit": { + "title": "每日盈亏汇总", + "summary": "按自然日汇总投注、派奖、退款、盈亏和净额。" + }, + "player_win_loss": { + "title": "玩家输赢报表", + "summary": "按玩家和时间段追踪输赢表现,适合客服与财务复核。" + }, + "player_transfer": { + "title": "玩家转入转出报表", + "summary": "集中查看玩家转入、转出、冲正和异常处理记录。" + }, + "hot_number_risk": { + "title": "热门号码风险报表", + "summary": "按期号与号码查看投注热度、风险占用和封顶接近度。" + }, + "play_dimension": { + "title": "玩法维度报表", + "summary": "按玩法拆分投注量、派彩、回水和盈亏结构。" + }, + "sold_out_number": { + "title": "售罄号码报表", + "summary": "查看单期已售罄号码、售罄时间和风险封锁情况。" + }, + "rebate_commission": { + "title": "佣金/回水报表", + "summary": "按玩法与时间段汇总佣金、回水与配置命中情况。" + }, + "admin_audit": { + "title": "后台操作审计报表", + "summary": "按操作人和时间段导出关键后台操作留痕。" + } + } +} diff --git a/src/lib/admin-page-title.ts b/src/lib/admin-page-title.ts index 5de113d..500caf6 100644 --- a/src/lib/admin-page-title.ts +++ b/src/lib/admin-page-title.ts @@ -11,6 +11,7 @@ const EXACT_ROUTES: Record = { "/admin/tickets": { ns: "tickets", key: "title" }, "/admin/settlement-batches": { ns: "settlement", key: "batchList" }, "/admin/reconcile": { ns: "reconcile", key: "title" }, + "/admin/reports": { ns: "reports", key: "title" }, "/admin/audit-logs": { ns: "audit", key: "title" }, "/admin/admin-users": { ns: "adminUsers", key: "title" }, "/admin/admin-roles": { ns: "adminRoles", key: "title" }, diff --git a/src/lib/page-metadata.ts b/src/lib/page-metadata.ts index a4ac413..5b8ba82 100644 --- a/src/lib/page-metadata.ts +++ b/src/lib/page-metadata.ts @@ -1,6 +1,5 @@ import type { Metadata } from "next"; -import enAdminRoles from "@/i18n/locales/en/adminRoles.json"; import enAdminUsers from "@/i18n/locales/en/adminUsers.json"; import enAudit from "@/i18n/locales/en/audit.json"; import enAuth from "@/i18n/locales/en/auth.json"; @@ -10,6 +9,7 @@ import enDraws from "@/i18n/locales/en/draws.json"; import enJackpot from "@/i18n/locales/en/jackpot.json"; import enPlayers from "@/i18n/locales/en/players.json"; import enReconcile from "@/i18n/locales/en/reconcile.json"; +import enReports from "@/i18n/locales/en/reports.json"; import enRisk from "@/i18n/locales/en/risk.json"; import enSettlement from "@/i18n/locales/en/settlement.json"; import enCommon from "@/i18n/locales/en/common.json"; @@ -23,9 +23,10 @@ const EN_FLAT: Record> = { tickets: enTickets, settlement: enSettlement, reconcile: enReconcile, + reports: enReports, audit: enAudit, adminUsers: enAdminUsers, - adminRoles: enAdminRoles, + adminRoles: enAdminUsers, wallet: enWallet, risk: enRisk, jackpot: enJackpot, diff --git a/src/modules/_config/admin-nav-icons.tsx b/src/modules/_config/admin-nav-icons.tsx index 23003b3..c23d39e 100644 --- a/src/modules/_config/admin-nav-icons.tsx +++ b/src/modules/_config/admin-nav-icons.tsx @@ -2,6 +2,7 @@ import type { LucideIcon } from "lucide-react"; import { CalendarClock, CircleDollarSign, + FileSpreadsheet, Landmark, LayoutDashboard, LogIn, @@ -30,6 +31,7 @@ export const adminNavIconBySegment: Record risk_cap: ShieldAlert, tickets: Ticket, wallet: Wallet, + reports: FileSpreadsheet, risk: ShieldAlert, settlement: Landmark, reconcile: Scale, diff --git a/src/modules/_config/admin-nav.ts b/src/modules/_config/admin-nav.ts index 157f074..03f2ee4 100644 --- a/src/modules/_config/admin-nav.ts +++ b/src/modules/_config/admin-nav.ts @@ -10,6 +10,7 @@ export type AdminNavSegment = | "risk_cap" | "tickets" | "wallet" + | "reports" | "risk" | "settings" | "settlement" diff --git a/src/modules/audit/audit-logs-console.tsx b/src/modules/audit/audit-logs-console.tsx index 88d0d83..8f5c467 100644 --- a/src/modules/audit/audit-logs-console.tsx +++ b/src/modules/audit/audit-logs-console.tsx @@ -5,6 +5,7 @@ import { useExportLabels } from "@/hooks/use-export-labels"; import { useTranslation } from "react-i18next"; import { getAdminAuditLogs } from "@/api/admin-audit"; +import { AdminDateRangeField } from "@/components/admin/admin-date-range-field"; import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer"; import { AdminTableExportButton } from "@/components/admin/admin-table-export-button"; import { Button } from "@/components/ui/button"; @@ -32,23 +33,39 @@ export function AuditLogsConsole(): React.ReactElement { const [err, setErr] = useState(null); const [page, setPage] = useState(1); const [perPage, setPerPage] = useState(10); + const [operatorId, setOperatorId] = useState(""); const [moduleCode, setModuleCode] = useState(""); const [actionCode, setActionCode] = useState(""); const [operatorType, setOperatorType] = useState(""); + const [startDate, setStartDate] = useState(""); + const [endDate, setEndDate] = useState(""); + const [appliedOperatorId, setAppliedOperatorId] = useState(""); const [appliedModule, setAppliedModule] = useState(""); const [appliedAction, setAppliedAction] = useState(""); const [appliedOpType, setAppliedOpType] = useState(""); + const [appliedStartDate, setAppliedStartDate] = useState(""); + const [appliedEndDate, setAppliedEndDate] = useState(""); const load = useCallback(async () => { setLoading(true); setErr(null); try { + const operatorIdValue = appliedOperatorId.trim(); + const parsedOperatorId = Number(operatorIdValue); + const operatorIdParam = + operatorIdValue !== "" && Number.isInteger(parsedOperatorId) && parsedOperatorId > 0 + ? parsedOperatorId + : undefined; + const d = await getAdminAuditLogs({ page, per_page: perPage, + operator_id: operatorIdParam, module_code: appliedModule.trim() || undefined, action_code: appliedAction.trim() || undefined, operator_type: appliedOpType.trim() || undefined, + start_date: appliedStartDate || undefined, + end_date: appliedEndDate || undefined, }); setData(d); } catch (e) { @@ -57,7 +74,7 @@ export function AuditLogsConsole(): React.ReactElement { } finally { setLoading(false); } - }, [page, perPage, appliedModule, appliedAction, appliedOpType, t]); + }, [page, perPage, appliedOperatorId, appliedModule, appliedAction, appliedOpType, appliedStartDate, appliedEndDate, t]); useEffect(() => { queueMicrotask(() => { @@ -71,7 +88,20 @@ export function AuditLogsConsole(): React.ReactElement { {t("title")} -
+
+
+ + setOperatorId(e.target.value)} + placeholder={t("operatorIdPlaceholder")} + className="w-full" + inputMode="numeric" + /> +
+
+ { + setStartDate(range.from); + setEndDate(range.to); + }} + /> +
+
+ {search.open === kind ? ( +
+ setSearch((prev) => ({ ...prev, query: e.target.value }))} + /> +
+ {search.loading ? ( +

{t("states.loading", { ns: "common" })}

+ ) : null} + {!search.loading && kind === "draw" ? ( + search.draws.map((item) => ( + + )) + ) : null} + {!search.loading && kind === "player" ? ( + search.players.map((item) => ( + + )) + ) : null} + {!search.loading && kind === "operator" ? ( + search.operators.map((item) => ( + + )) + ) : null} +
+
+ ) : null} +
+ ); + }; + + const renderField = (field: FieldKey) => { + if (field === "period") { + return ( + { + setFilters((prev) => ({ ...prev, dateFrom: from, dateTo: to })); + }} + /> + ); + } + if (field === "drawNo") { + return
{renderSearchPicker("draw")}
; + } + if (field === "player") { + return
{renderSearchPicker("player")}
; + } + if (field === "operator") { + return
{renderSearchPicker("operator")}
; + } + if (field === "play") { + return ( +
+ + +
+ ); + } + + return ( +
+ + updateFilter("number", e.target.value)} + placeholder={t(`placeholders.${field}`)} + /> +
+ ); + }; + + const renderTable = () => { + if (!selectedReport.connected) { + return ( + + + {t("backendPending")} + + + ); + } + if (loading) { + return ( + + + {t("states.loading", { ns: "common" })} + + + ); + } + if (error) { + return ( + + + {error} + + + ); + } + if (!result || result.rows.length === 0) { + return ( + + + {t("preview.empty")} + + + ); + } + + if (result.key === "draw_profit") { + const summary = result.raw; + return ( + <> + + {summary.draw_no} + {summary.draw_status} + {summary.order_count} + {summary.ticket_item_count} + {formatPlainMoney(summary.total_bet_minor, summary.currency_code)} + {formatPlainMoney(summary.total_payout_minor, summary.currency_code)} + {formatPlainMoney(summary.approx_house_gross_minor, summary.currency_code)} + {summary.settlement_batches.length} + + {summary.settlement_batches.map((batch) => ( + + #{batch.id} + {batch.status} + {batch.total_ticket_count} + {batch.total_win_count} + - + {formatPlainMoney(batch.total_payout_amount, summary.currency_code)} + {formatPlainMoney(batch.total_jackpot_payout_amount, summary.currency_code)} + {formatTs(batch.finished_at)} + + ))} + + ); + } + + if (result.key === "player_transfer") { + return result.raw.map((item) => ( + + {item.transfer_no} + {optionText(item.username, item.nickname) || item.player_id} + {item.direction} + {item.status} + {item.currency_code} {item.amount} + {item.external_ref_no || "-"} + {item.fail_reason || "-"} + {formatTs(item.created_at)} + + )); + } + + if (result.key === "hot_number_risk") { + return ( + <> + + {result.raw.pool.normalized_number} + {result.raw.draw_no} + {formatPlainMoney(result.raw.pool.total_cap_amount, result.raw.currency_code)} + {formatPlainMoney(result.raw.pool.locked_amount, result.raw.currency_code)} + {formatPlainMoney(result.raw.pool.remaining_amount, result.raw.currency_code)} + {result.raw.pool.is_sold_out ? t("yes") : t("no")} + {result.raw.pool.usage_ratio == null ? "-" : `${result.raw.pool.usage_ratio}%`} + v{result.raw.pool.version} + + {result.raw.logs.items.map((item) => ( + + #{item.id} + {item.action_type} + {formatPlainMoney(item.amount, result.raw.currency_code)} + {item.play_code || "-"} + {item.ticket_no || "-"} + {item.player_id || "-"} + {item.source_reason || "-"} + {formatTs(item.created_at)} + + ))} + + ); + } + + if (result.key === "sold_out_number") { + return result.raw.map((item) => ( + + {item.normalized_number} + {filters.drawNo} + {formatPlainMoney(item.total_cap_amount, null)} + {formatPlainMoney(item.locked_amount, null)} + {formatPlainMoney(item.remaining_amount, null)} + {item.is_sold_out ? t("yes") : t("no")} + {item.usage_ratio == null ? "-" : `${item.usage_ratio}%`} + v{item.version} + + )); + } + + if (result.key === "daily_profit") { + return result.raw.map((item) => ( + + {item.business_date} + - + {formatPlainMoney(item.total_bet_minor, "NPR")} + {formatPlainMoney(item.total_payout_minor, "NPR")} + {formatPlainMoney(item.approx_house_gross_minor, "NPR")} + - + - + - + + )); + } + + if (result.key === "player_win_loss") { + return result.raw.map((item) => ( + + {item.username} + ID {item.player_id} + {formatPlainMoney(item.total_bet_minor, "NPR")} + {formatPlainMoney(item.total_payout_minor, "NPR")} + {formatPlainMoney(item.net_win_loss_minor, "NPR")} + - + - + - + + )); + } + + if (result.key === "play_dimension") { + return result.raw.map((item) => ( + + {item.play_code} + {item.dimension}D + {formatPlainMoney(item.total_bet_minor, "NPR")} + {formatPlainMoney(item.total_payout_minor, "NPR")} + {formatPlainMoney(item.approx_house_gross_minor, "NPR")} + - + - + - + + )); + } + + if (result.key === "rebate_commission") { + return result.raw.map((item) => ( + + {item.play_code} + {item.order_count} + {formatPlainMoney(item.total_rebate_minor, "NPR")} + {item.ticket_item_count} + - + - + - + - + + )); + } + + if (result.key === "admin_audit") { + return result.raw.map((item) => ( + + #{item.id} + {item.operator_type} + {item.operator_id} + {item.module_code} + {item.action_code} + {item.target_type || "-"} + {item.ip || "-"} + {formatTs(item.created_at)} + + )); + } + + return null; + }; + + return ( +
+
+
+

{t("title")}

+

{t("subtitle")}

+
+ + {resultRowCount(result)} {t("preview.exportableRows")} + +
+ +
+ + + {t("chooseReport")} + + + {REPORTS.map((report) => { + const Icon = report.icon; + const active = report.key === selectedReport.key; + return ( + + ); + })} + + + +
+ + +
+ {t("filterPanel")} +

{t(`items.${selectedReport.key}.summary`)}

+
+
+ {t(`categories.${selectedReport.category}`)} + {t(`scopes.${selectedReport.scope}`)} +
+
+ +
+ {selectedReport.fields.map(renderField)} +
+
+

+ {selectedReport.connected ? t("queryHint") : t("backendPending")} +

+
+ + +
+
+
+
+ +
+ {(result?.summary ?? [ + { label: t("preview.stats.records"), value: "-" }, + { label: t("preview.stats.currentPage"), value: "-" }, + { label: t("preview.stats.drawNo"), value: filters.drawNo || "-" }, + { label: t("preview.stats.exportRows"), value: String(resultRowCount(result)) }, + ]).map((item) => ( +
+
{item.label}
+
{item.value}
+
+ ))} +
+ + + +
+ {t("preview.title")} +

{t("preview.subtitle")}

+
+
+ + +
+
+ + + + + {t("preview.columns.primary")} + {t("preview.columns.secondary")} + {t("preview.columns.metricA")} + {t("preview.columns.metricB")} + {t("preview.columns.metricC")} + {t("preview.columns.status")} + {t("preview.columns.extra")} + {t("preview.columns.time")} + + + {renderTable()} +
+ + {result?.meta ? ( + { + setPerPage(next); + setPage(1); + }} + onPageChange={setPage} + /> + ) : null} +
+
+
+
+
+ ); +} diff --git a/src/types/api/admin-reports.ts b/src/types/api/admin-reports.ts new file mode 100644 index 0000000..3c654e7 --- /dev/null +++ b/src/types/api/admin-reports.ts @@ -0,0 +1,48 @@ +export type AdminReportDailyProfitRow = { + business_date: string; + total_bet_minor: number; + total_payout_minor: number; + approx_house_gross_minor: number; +}; + +export type AdminReportPlayerWinLossRow = { + player_id: number; + username: string; + total_bet_minor: number; + total_payout_minor: number; + net_win_loss_minor: number; +}; + +export type AdminReportPlayDimensionRow = { + play_code: string; + dimension: number; + total_bet_minor: number; + total_payout_minor: number; + approx_house_gross_minor: number; +}; + +export type AdminReportRebateCommissionRow = { + play_code: string; + total_rebate_minor: number; + order_count: number; + ticket_item_count: number; +}; + +export type AdminReportListData = { + items: T[]; + meta: { + current_page: number; + per_page: number; + total: number; + last_page: number; + }; +}; + +export type AdminReportQueryParams = { + page?: number; + per_page?: number; + date_from?: string; + date_to?: string; + player_id?: number; + play_code?: string; +};