feat(admin): 上线报表中心页面并接入九类报表查询导出
新增报表控制台、汇总 API 客户端与中英尼文案,九类报表均可筛选预览并导出 CSV/Excel。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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<AdminAuditLogListData> {
|
||||
return adminRequest.get<AdminAuditLogListData>(`${A}/audit-logs`, {
|
||||
params,
|
||||
|
||||
46
src/api/admin-reports.ts
Normal file
46
src/api/admin-reports.ts
Normal file
@@ -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<AdminReportListData<AdminReportDailyProfitRow>> {
|
||||
return adminRequest.get<AdminReportListData<AdminReportDailyProfitRow>>(`${A}/reports/daily-profit`, {
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAdminReportPlayerWinLoss(
|
||||
params: AdminReportQueryParams,
|
||||
): Promise<AdminReportListData<AdminReportPlayerWinLossRow>> {
|
||||
return adminRequest.get<AdminReportListData<AdminReportPlayerWinLossRow>>(`${A}/reports/player-win-loss`, {
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAdminReportPlayDimension(
|
||||
params: AdminReportQueryParams,
|
||||
): Promise<AdminReportListData<AdminReportPlayDimensionRow>> {
|
||||
return adminRequest.get<AdminReportListData<AdminReportPlayDimensionRow>>(`${A}/reports/play-dimension`, {
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAdminReportRebateCommission(
|
||||
params: AdminReportQueryParams,
|
||||
): Promise<AdminReportListData<AdminReportRebateCommissionRow>> {
|
||||
return adminRequest.get<AdminReportListData<AdminReportRebateCommissionRow>>(`${A}/reports/rebate-commission`, {
|
||||
params,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
14
src/app/admin/(shell)/reports/page.tsx
Normal file
14
src/app/admin/(shell)/reports/page.tsx
Normal file
@@ -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 (
|
||||
<ModuleScaffold>
|
||||
<ReportsConsole />
|
||||
</ModuleScaffold>
|
||||
);
|
||||
}
|
||||
@@ -36,6 +36,7 @@ const NAV_TRANSLATION_KEYS: Record<string, string> = {
|
||||
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 ?? [];
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -94,6 +94,7 @@
|
||||
"players": "Players",
|
||||
"currencies": "Currencies",
|
||||
"wallet": "Wallet",
|
||||
"reports": "Reports",
|
||||
"draws": "Draws",
|
||||
"rules_plays": "Play rules",
|
||||
"rules_odds": "Odds & rebate",
|
||||
|
||||
172
src/i18n/locales/en/reports.json
Normal file
172
src/i18n/locales/en/reports.json
Normal file
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
"moduleCode": "मोड्युल कोड",
|
||||
"actionCode": "कार्य कोड",
|
||||
"operatorType": "अपरेटर प्रकार",
|
||||
"operatorIdPlaceholder": "अपरेटर ID प्रविष्ट गर्नुहोस्",
|
||||
"exactMatch": "ठ्याक्कै मिलान",
|
||||
"operatorTypePlaceholder": "जस्तै admin / system",
|
||||
"operator": "अपरेटर",
|
||||
|
||||
@@ -94,6 +94,7 @@
|
||||
"players": "खेलाडी सूची",
|
||||
"currencies": "मुद्रा व्यवस्थापन",
|
||||
"wallet": "वालेट",
|
||||
"reports": "रिपोर्ट",
|
||||
"draws": "ड्रअहरू",
|
||||
"rules_plays": "खेल नियम",
|
||||
"rules_odds": "बाधा र रिबेट",
|
||||
|
||||
172
src/i18n/locales/ne/reports.json
Normal file
172
src/i18n/locales/ne/reports.json
Normal file
@@ -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": "अपरेटर र अवधिअनुसार मुख्य एडमिन अपरेशन ट्रेस निर्यात गर्नुहोस्।"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
"moduleCode": "模块",
|
||||
"actionCode": "动作",
|
||||
"operatorType": "操作者类型",
|
||||
"operatorIdPlaceholder": "请输入操作人 ID",
|
||||
"exactMatch": "请输入完整名称",
|
||||
"operatorTypePlaceholder": "如管理员、系统",
|
||||
"operator": "操作者",
|
||||
|
||||
@@ -94,6 +94,7 @@
|
||||
"players": "玩家列表",
|
||||
"currencies": "币种管理",
|
||||
"wallet": "钱包流水",
|
||||
"reports": "报表中心",
|
||||
"draws": "期号列表",
|
||||
"rules_plays": "投注规则",
|
||||
"rules_odds": "赔率与回水",
|
||||
|
||||
172
src/i18n/locales/zh/reports.json
Normal file
172
src/i18n/locales/zh/reports.json
Normal file
@@ -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": "按操作人和时间段导出关键后台操作留痕。"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ const EXACT_ROUTES: Record<string, PageTitleSpec> = {
|
||||
"/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" },
|
||||
|
||||
@@ -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<string, Record<string, unknown>> = {
|
||||
tickets: enTickets,
|
||||
settlement: enSettlement,
|
||||
reconcile: enReconcile,
|
||||
reports: enReports,
|
||||
audit: enAudit,
|
||||
adminUsers: enAdminUsers,
|
||||
adminRoles: enAdminRoles,
|
||||
adminRoles: enAdminUsers,
|
||||
wallet: enWallet,
|
||||
risk: enRisk,
|
||||
jackpot: enJackpot,
|
||||
|
||||
@@ -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<AdminNavItem["segment"], LucideIcon>
|
||||
risk_cap: ShieldAlert,
|
||||
tickets: Ticket,
|
||||
wallet: Wallet,
|
||||
reports: FileSpreadsheet,
|
||||
risk: ShieldAlert,
|
||||
settlement: Landmark,
|
||||
reconcile: Scale,
|
||||
|
||||
@@ -10,6 +10,7 @@ export type AdminNavSegment =
|
||||
| "risk_cap"
|
||||
| "tickets"
|
||||
| "wallet"
|
||||
| "reports"
|
||||
| "risk"
|
||||
| "settings"
|
||||
| "settlement"
|
||||
|
||||
@@ -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<string | null>(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 {
|
||||
<Card className="admin-list-card w-full max-w-none">
|
||||
<CardHeader className="admin-list-header flex flex-col gap-5">
|
||||
<CardTitle className="admin-list-title">{t("title")}</CardTitle>
|
||||
<div className="grid gap-3 lg:grid-cols-3">
|
||||
<div className="grid gap-3 lg:grid-cols-2 xl:grid-cols-5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Label htmlFor="aud-operator-id" className="shrink-0 whitespace-nowrap">
|
||||
{t("operator")}
|
||||
</Label>
|
||||
<Input
|
||||
id="aud-operator-id"
|
||||
value={operatorId}
|
||||
onChange={(e) => setOperatorId(e.target.value)}
|
||||
placeholder={t("operatorIdPlaceholder")}
|
||||
className="w-full"
|
||||
inputMode="numeric"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Label htmlFor="aud-mod" className="shrink-0 whitespace-nowrap">
|
||||
{t("moduleCode")}
|
||||
@@ -108,19 +138,34 @@ export function AuditLogsConsole(): React.ReactElement {
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 xl:col-span-2">
|
||||
<AdminDateRangeField
|
||||
id="aud-date-range"
|
||||
label={t("time")}
|
||||
from={startDate}
|
||||
to={endDate}
|
||||
onRangeChange={(range) => {
|
||||
setStartDate(range.from);
|
||||
setEndDate(range.to);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<AdminTableExportButton
|
||||
tableId="audit-logs-table"
|
||||
filename={exportLabels.filename}
|
||||
sheetName={exportLabels.sheetName}
|
||||
sheetName={exportLabels.sheetName}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAppliedOperatorId(operatorId);
|
||||
setAppliedModule(moduleCode);
|
||||
setAppliedAction(actionCode);
|
||||
setAppliedOpType(operatorType);
|
||||
setAppliedStartDate(startDate);
|
||||
setAppliedEndDate(endDate);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
@@ -130,12 +175,18 @@ export function AuditLogsConsole(): React.ReactElement {
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setOperatorId("");
|
||||
setModuleCode("");
|
||||
setActionCode("");
|
||||
setOperatorType("");
|
||||
setStartDate("");
|
||||
setEndDate("");
|
||||
setAppliedOperatorId("");
|
||||
setAppliedModule("");
|
||||
setAppliedAction("");
|
||||
setAppliedOpType("");
|
||||
setAppliedStartDate("");
|
||||
setAppliedEndDate("");
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
|
||||
5
src/modules/reports/meta.ts
Normal file
5
src/modules/reports/meta.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const reportsModuleMeta = {
|
||||
segment: "reports",
|
||||
title: "报表中心",
|
||||
description: "",
|
||||
} as const;
|
||||
1314
src/modules/reports/reports-console.tsx
Normal file
1314
src/modules/reports/reports-console.tsx
Normal file
@@ -0,0 +1,1314 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import * as XLSX from "xlsx";
|
||||
import {
|
||||
CalendarDays,
|
||||
CircleDollarSign,
|
||||
Database,
|
||||
FileDown,
|
||||
FileSpreadsheet,
|
||||
ListFilter,
|
||||
Search,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
Ticket,
|
||||
Users,
|
||||
WalletCards,
|
||||
} from "lucide-react";
|
||||
|
||||
import { getAdminAuditLogs } from "@/api/admin-audit";
|
||||
import { getAdminPlayTypes } from "@/api/admin-config";
|
||||
import { getAdminDraws, getAdminDrawFinanceSummary } from "@/api/admin-draws";
|
||||
import { getAdminPlayers } from "@/api/admin-player";
|
||||
import {
|
||||
getAdminReportDailyProfit,
|
||||
getAdminReportPlayDimension,
|
||||
getAdminReportPlayerWinLoss,
|
||||
getAdminReportRebateCommission,
|
||||
} from "@/api/admin-reports";
|
||||
import { getAdminRiskPoolDetail, getAdminRiskPools } from "@/api/admin-risk";
|
||||
import { getAdminUsers } from "@/api/admin-users";
|
||||
import { getAdminTransferOrders } from "@/api/admin-wallet";
|
||||
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatAdminMinorUnits } from "@/lib/money";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { AdminAuditLogRow } from "@/types/api/admin-audit";
|
||||
import type { AdminDrawListItem } from "@/types/api/admin-draws";
|
||||
import type { AdminDrawFinanceSummaryData } from "@/types/api/admin-draw-finance";
|
||||
import type { AdminPlayerRow } from "@/types/api/admin-player";
|
||||
import type { AdminRiskPoolRow, AdminRiskPoolShowData } from "@/types/api/admin-risk";
|
||||
import type { AdminUserPermissionRow } from "@/types/api/admin-user";
|
||||
import type { AdminTransferOrderItem } from "@/types/api/admin-wallet";
|
||||
import type {
|
||||
AdminReportDailyProfitRow,
|
||||
AdminReportPlayDimensionRow,
|
||||
AdminReportPlayerWinLossRow,
|
||||
AdminReportRebateCommissionRow,
|
||||
} from "@/types/api/admin-reports";
|
||||
|
||||
type ReportCategory = "profit" | "wallet" | "risk" | "audit";
|
||||
type FilterKind = "draw" | "date" | "player_period" | "draw_number" | "play" | "play_period" | "operator_period";
|
||||
type FieldKey = "drawNo" | "number" | "player" | "play" | "operator" | "period";
|
||||
type ExportFormat = "csv" | "excel";
|
||||
type ExportCell = string | number | null;
|
||||
type ExportRow = Record<string, ExportCell>;
|
||||
type SearchKind = "draw" | "player" | "operator";
|
||||
|
||||
type ReportKey =
|
||||
| "draw_profit"
|
||||
| "daily_profit"
|
||||
| "player_win_loss"
|
||||
| "player_transfer"
|
||||
| "hot_number_risk"
|
||||
| "play_dimension"
|
||||
| "sold_out_number"
|
||||
| "rebate_commission"
|
||||
| "admin_audit";
|
||||
|
||||
type ReportDefinition = {
|
||||
key: ReportKey;
|
||||
category: ReportCategory;
|
||||
icon: typeof FileSpreadsheet;
|
||||
filterKind: FilterKind;
|
||||
scope: string;
|
||||
fields: FieldKey[];
|
||||
connected: boolean;
|
||||
};
|
||||
|
||||
type ReportFilters = {
|
||||
drawNo: string;
|
||||
drawId: number | null;
|
||||
number: string;
|
||||
player: string;
|
||||
playerId: number | null;
|
||||
play: string;
|
||||
operator: string;
|
||||
operatorId: number | null;
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
};
|
||||
|
||||
type ReportMeta = {
|
||||
total: number;
|
||||
page: number;
|
||||
perPage: number;
|
||||
lastPage: number;
|
||||
};
|
||||
|
||||
type ReportResult =
|
||||
| { key: "draw_profit"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta | null; raw: AdminDrawFinanceSummaryData }
|
||||
| { key: "daily_profit"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta; raw: AdminReportDailyProfitRow[] }
|
||||
| { key: "player_win_loss"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta; raw: AdminReportPlayerWinLossRow[] }
|
||||
| { key: "player_transfer"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta; raw: AdminTransferOrderItem[] }
|
||||
| { key: "hot_number_risk"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta | null; raw: AdminRiskPoolShowData }
|
||||
| { key: "play_dimension"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta; raw: AdminReportPlayDimensionRow[] }
|
||||
| { key: "sold_out_number"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta; raw: AdminRiskPoolRow[] }
|
||||
| { key: "rebate_commission"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta; raw: AdminReportRebateCommissionRow[] }
|
||||
| { key: "admin_audit"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta; raw: AdminAuditLogRow[] };
|
||||
|
||||
type StatCard = {
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: "default" | "good" | "warn" | "bad";
|
||||
};
|
||||
|
||||
type SearchState = {
|
||||
open: SearchKind | null;
|
||||
query: string;
|
||||
loading: boolean;
|
||||
draws: AdminDrawListItem[];
|
||||
players: AdminPlayerRow[];
|
||||
operators: AdminUserPermissionRow[];
|
||||
};
|
||||
|
||||
type PlayOption = {
|
||||
code: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const REPORTS: ReportDefinition[] = [
|
||||
{ key: "draw_profit", category: "profit", icon: Ticket, filterKind: "draw", scope: "drawNo", fields: ["drawNo"], connected: true },
|
||||
{ key: "daily_profit", category: "profit", icon: CalendarDays, filterKind: "date", scope: "date", fields: ["period"], connected: true },
|
||||
{ key: "player_win_loss", category: "profit", icon: Users, filterKind: "player_period", scope: "playerPeriod", fields: ["player", "period"], connected: true },
|
||||
{ key: "player_transfer", category: "wallet", icon: WalletCards, filterKind: "player_period", scope: "playerPeriod", fields: ["player", "period"], connected: true },
|
||||
{ key: "hot_number_risk", category: "risk", icon: ShieldAlert, filterKind: "draw_number", scope: "drawNumber", fields: ["drawNo", "number"], connected: true },
|
||||
{ key: "play_dimension", category: "profit", icon: ListFilter, filterKind: "play_period", scope: "playPeriod", fields: ["play", "period"], connected: true },
|
||||
{ key: "sold_out_number", category: "risk", icon: ShieldCheck, filterKind: "draw", scope: "drawNo", fields: ["drawNo"], connected: true },
|
||||
{ key: "rebate_commission", category: "wallet", icon: CircleDollarSign, filterKind: "play_period", scope: "playPeriod", fields: ["play", "period"], connected: true },
|
||||
{ key: "admin_audit", category: "audit", icon: FileSpreadsheet, filterKind: "operator_period", scope: "operatorPeriod", fields: ["operator", "period"], connected: true },
|
||||
];
|
||||
|
||||
const emptyFilters: ReportFilters = {
|
||||
drawNo: "",
|
||||
drawId: null,
|
||||
number: "",
|
||||
player: "",
|
||||
playerId: null,
|
||||
play: "",
|
||||
operator: "",
|
||||
operatorId: null,
|
||||
dateFrom: "",
|
||||
dateTo: "",
|
||||
};
|
||||
|
||||
const emptySearch: SearchState = {
|
||||
open: null,
|
||||
query: "",
|
||||
loading: false,
|
||||
draws: [],
|
||||
players: [],
|
||||
operators: [],
|
||||
};
|
||||
|
||||
function categoryTone(category: ReportCategory): string {
|
||||
switch (category) {
|
||||
case "wallet":
|
||||
return "border-emerald-200 bg-emerald-50 text-emerald-700";
|
||||
case "risk":
|
||||
return "border-red-200 bg-red-50 text-red-700";
|
||||
case "audit":
|
||||
return "border-slate-200 bg-slate-50 text-slate-700";
|
||||
default:
|
||||
return "border-blue-200 bg-blue-50 text-blue-700";
|
||||
}
|
||||
}
|
||||
|
||||
function statTone(tone: StatCard["tone"]): string {
|
||||
switch (tone) {
|
||||
case "good":
|
||||
return "border-emerald-200 bg-emerald-50/70 text-emerald-900";
|
||||
case "warn":
|
||||
return "border-amber-200 bg-amber-50/70 text-amber-950";
|
||||
case "bad":
|
||||
return "border-red-200 bg-red-50/70 text-red-950";
|
||||
default:
|
||||
return "border-border/70 bg-card text-foreground";
|
||||
}
|
||||
}
|
||||
|
||||
function formatKind(kind: FilterKind, t: (key: string) => string): string {
|
||||
return t(`filters.${kind}`);
|
||||
}
|
||||
|
||||
function downloadBlob(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function normalizeFilenamePart(value: string): string {
|
||||
return value.trim().replace(/[\\/:*?"<>|\s]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
function toCsvValue(value: ExportCell): string {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const stringValue = String(value);
|
||||
if (/[",\n]/.test(stringValue)) {
|
||||
return `"${stringValue.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return stringValue;
|
||||
}
|
||||
|
||||
function exportRows(rows: ExportRow[], filename: string, sheetName: string, format: ExportFormat): 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 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 metaFromList(meta: { current_page: number; per_page: number; total: number; last_page: number }): ReportMeta {
|
||||
return {
|
||||
total: meta.total,
|
||||
page: meta.current_page,
|
||||
perPage: meta.per_page,
|
||||
lastPage: meta.last_page,
|
||||
};
|
||||
}
|
||||
|
||||
function formatPlainMoney(value: number, currencyCode: string | null | undefined): string {
|
||||
return formatAdminMinorUnits(value, currencyCode || "NPR");
|
||||
}
|
||||
|
||||
function optionText(...parts: Array<string | number | null | undefined>): string {
|
||||
return parts.filter((part) => part !== null && part !== undefined && String(part).trim() !== "").join(" / ");
|
||||
}
|
||||
|
||||
function reportListParams(filters: ReportFilters, page: number, perPage: number) {
|
||||
return {
|
||||
page,
|
||||
per_page: perPage,
|
||||
date_from: filters.dateFrom || undefined,
|
||||
date_to: filters.dateTo || undefined,
|
||||
player_id: filters.playerId ?? undefined,
|
||||
play_code: filters.play.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: string): number | null {
|
||||
const trimmed = value.trim();
|
||||
if (!/^\d+$/.test(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number(trimmed);
|
||||
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
async function resolveDraw(filters: ReportFilters): Promise<{ id: number; draw_no: string }> {
|
||||
if (filters.drawId && filters.drawNo.trim()) {
|
||||
return { id: filters.drawId, draw_no: filters.drawNo.trim() };
|
||||
}
|
||||
|
||||
const drawNo = filters.drawNo.trim();
|
||||
if (!drawNo) {
|
||||
throw new LotteryApiBizError("请输入期号", -1, null);
|
||||
}
|
||||
|
||||
const data = await getAdminDraws({ draw_no: drawNo, page: 1, per_page: 1 });
|
||||
const matched = data.items.find((item) => item.draw_no === drawNo) ?? data.items[0];
|
||||
if (!matched) {
|
||||
throw new LotteryApiBizError("未找到期号", -1, { drawNo });
|
||||
}
|
||||
return { id: matched.id, draw_no: matched.draw_no };
|
||||
}
|
||||
|
||||
function drawRowsFromSummary(summary: AdminDrawFinanceSummaryData): ExportRow[] {
|
||||
return [
|
||||
{
|
||||
row_type: "summary",
|
||||
draw_id: summary.draw_id,
|
||||
draw_no: summary.draw_no,
|
||||
draw_status: summary.draw_status,
|
||||
currency_code: summary.currency_code,
|
||||
order_count: summary.order_count,
|
||||
ticket_item_count: summary.ticket_item_count,
|
||||
total_bet_minor: summary.total_bet_minor,
|
||||
total_win_payout_minor: summary.total_win_payout_minor,
|
||||
total_jackpot_win_minor: summary.total_jackpot_win_minor,
|
||||
total_payout_minor: summary.total_payout_minor,
|
||||
approx_house_gross_minor: summary.approx_house_gross_minor,
|
||||
},
|
||||
...summary.settlement_batches.map((batch) => ({
|
||||
row_type: "settlement_batch",
|
||||
draw_id: summary.draw_id,
|
||||
draw_no: summary.draw_no,
|
||||
settlement_batch_id: batch.id,
|
||||
settlement_status: batch.status,
|
||||
total_ticket_count: batch.total_ticket_count,
|
||||
total_win_count: batch.total_win_count,
|
||||
total_payout_amount: batch.total_payout_amount,
|
||||
total_jackpot_payout_amount: batch.total_jackpot_payout_amount,
|
||||
finished_at: batch.finished_at,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
function resultRowCount(result: ReportResult | null): number {
|
||||
return result?.rows.length ?? 0;
|
||||
}
|
||||
|
||||
export function ReportsConsole() {
|
||||
const { t } = useTranslation(["reports", "common"]);
|
||||
useAdminCurrencyCatalog();
|
||||
const formatTs = useAdminDateTimeFormatter();
|
||||
const [selectedKey, setSelectedKey] = useState<ReportKey>(REPORTS[0].key);
|
||||
const [filters, setFilters] = useState<ReportFilters>(emptyFilters);
|
||||
const [result, setResult] = useState<ReportResult | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(20);
|
||||
const [exporting, setExporting] = useState<ExportFormat | null>(null);
|
||||
const [search, setSearch] = useState<SearchState>(emptySearch);
|
||||
const [playOptions, setPlayOptions] = useState<PlayOption[]>([]);
|
||||
|
||||
const selectedReport = REPORTS.find((report) => report.key === selectedKey) ?? REPORTS[0];
|
||||
|
||||
const exportFileBase = useMemo(() => {
|
||||
const segments = [selectedReport.key];
|
||||
if (filters.drawNo.trim()) segments.push(filters.drawNo.trim());
|
||||
if (filters.number.trim()) segments.push(filters.number.trim());
|
||||
if (filters.player.trim()) segments.push(filters.player.trim());
|
||||
if (filters.play.trim()) segments.push(filters.play.trim());
|
||||
if (filters.operator.trim()) segments.push(filters.operator.trim());
|
||||
if (filters.dateFrom) segments.push(filters.dateFrom);
|
||||
if (filters.dateTo) segments.push(filters.dateTo);
|
||||
return normalizeFilenamePart(segments.join("-")) || selectedReport.key;
|
||||
}, [selectedReport.key, filters]);
|
||||
|
||||
const loadPlayOptions = useCallback(async () => {
|
||||
try {
|
||||
const payload = await getAdminPlayTypes();
|
||||
setPlayOptions(
|
||||
payload.items.map((item) => ({
|
||||
code: item.play_code,
|
||||
label: optionText(item.display_name_zh, item.play_code),
|
||||
})),
|
||||
);
|
||||
} catch {
|
||||
setPlayOptions([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadPlayOptions();
|
||||
}, [loadPlayOptions]);
|
||||
|
||||
const loadSearchOptions = useCallback(async (kind: SearchKind, query: string) => {
|
||||
setSearch((prev) => ({ ...prev, loading: true }));
|
||||
try {
|
||||
if (kind === "draw") {
|
||||
const payload = await getAdminDraws({ draw_no: query.trim() || undefined, page: 1, per_page: 8 });
|
||||
setSearch((prev) => ({ ...prev, draws: payload.items, loading: false }));
|
||||
return;
|
||||
}
|
||||
if (kind === "player") {
|
||||
const payload = await getAdminPlayers({ keyword: query.trim() || undefined, page: 1, per_page: 8 });
|
||||
setSearch((prev) => ({ ...prev, players: payload.items, loading: false }));
|
||||
return;
|
||||
}
|
||||
const payload = await getAdminUsers({ keyword: query.trim() || undefined, page: 1, per_page: 8 });
|
||||
setSearch((prev) => ({ ...prev, operators: payload.items, loading: false }));
|
||||
} catch {
|
||||
setSearch((prev) => ({ ...prev, loading: false }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (search.open === null) {
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(() => {
|
||||
void loadSearchOptions(search.open as SearchKind, search.query);
|
||||
}, 250);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [search.open, search.query, loadSearchOptions]);
|
||||
|
||||
const queryReport = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
switch (selectedReport.key) {
|
||||
case "draw_profit": {
|
||||
const draw = await resolveDraw(filters);
|
||||
const summary = await getAdminDrawFinanceSummary(draw.id);
|
||||
setResult({
|
||||
key: "draw_profit",
|
||||
raw: summary,
|
||||
rows: drawRowsFromSummary(summary),
|
||||
meta: null,
|
||||
summary: [
|
||||
{ label: t("preview.stats.bet"), value: formatPlainMoney(summary.total_bet_minor, summary.currency_code) },
|
||||
{ label: t("preview.stats.payout"), value: formatPlainMoney(summary.total_payout_minor, summary.currency_code) },
|
||||
{
|
||||
label: t("preview.stats.houseGross"),
|
||||
value: formatPlainMoney(summary.approx_house_gross_minor, summary.currency_code),
|
||||
tone: summary.approx_house_gross_minor >= 0 ? "good" : "bad",
|
||||
},
|
||||
{ label: t("preview.stats.orders"), value: String(summary.order_count) },
|
||||
],
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "daily_profit": {
|
||||
const payload = await getAdminReportDailyProfit(reportListParams(filters, page, perPage));
|
||||
const rows = payload.items.map((item) => ({
|
||||
business_date: item.business_date,
|
||||
total_bet_minor: item.total_bet_minor,
|
||||
total_payout_minor: item.total_payout_minor,
|
||||
approx_house_gross_minor: item.approx_house_gross_minor,
|
||||
}));
|
||||
const totalBet = payload.items.reduce((sum, item) => sum + item.total_bet_minor, 0);
|
||||
const totalPayout = payload.items.reduce((sum, item) => sum + item.total_payout_minor, 0);
|
||||
const totalGross = payload.items.reduce((sum, item) => sum + item.approx_house_gross_minor, 0);
|
||||
setResult({
|
||||
key: "daily_profit",
|
||||
raw: payload.items,
|
||||
rows,
|
||||
meta: metaFromList(payload.meta),
|
||||
summary: [
|
||||
{ label: t("preview.stats.records"), value: String(payload.meta.total) },
|
||||
{ label: t("preview.stats.bet"), value: formatPlainMoney(totalBet, "NPR") },
|
||||
{ label: t("preview.stats.payout"), value: formatPlainMoney(totalPayout, "NPR") },
|
||||
{
|
||||
label: t("preview.stats.houseGross"),
|
||||
value: formatPlainMoney(totalGross, "NPR"),
|
||||
tone: totalGross >= 0 ? "good" : "bad",
|
||||
},
|
||||
],
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "player_win_loss": {
|
||||
const payload = await getAdminReportPlayerWinLoss(reportListParams(filters, page, perPage));
|
||||
const rows = payload.items.map((item) => ({
|
||||
player_id: item.player_id,
|
||||
username: item.username,
|
||||
total_bet_minor: item.total_bet_minor,
|
||||
total_payout_minor: item.total_payout_minor,
|
||||
net_win_loss_minor: item.net_win_loss_minor,
|
||||
}));
|
||||
setResult({
|
||||
key: "player_win_loss",
|
||||
raw: payload.items,
|
||||
rows,
|
||||
meta: metaFromList(payload.meta),
|
||||
summary: [
|
||||
{ label: t("preview.stats.records"), value: String(payload.meta.total) },
|
||||
{ label: t("preview.stats.currentPage"), value: String(payload.items.length) },
|
||||
{
|
||||
label: t("preview.stats.houseGross"),
|
||||
value: formatPlainMoney(
|
||||
payload.items.reduce((sum, item) => sum - item.net_win_loss_minor, 0),
|
||||
"NPR",
|
||||
),
|
||||
},
|
||||
{ label: t("preview.stats.players"), value: String(new Set(payload.items.map((item) => item.player_id)).size) },
|
||||
],
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "player_transfer": {
|
||||
const playerId = filters.playerId ?? parsePositiveInteger(filters.player);
|
||||
const payload = await getAdminTransferOrders({
|
||||
page,
|
||||
per_page: perPage,
|
||||
player_id: playerId ?? undefined,
|
||||
player_account: playerId ? undefined : filters.player.trim() || undefined,
|
||||
created_from: filters.dateFrom || undefined,
|
||||
created_to: filters.dateTo || undefined,
|
||||
});
|
||||
const rows = payload.items.map((item) => ({
|
||||
id: item.id,
|
||||
transfer_no: item.transfer_no,
|
||||
player_id: item.player_id,
|
||||
username: item.username,
|
||||
nickname: item.nickname,
|
||||
direction: item.direction,
|
||||
currency_code: item.currency_code,
|
||||
amount: item.amount,
|
||||
status: item.status,
|
||||
external_ref_no: item.external_ref_no,
|
||||
fail_reason: item.fail_reason,
|
||||
created_at: item.created_at,
|
||||
finished_at: item.finished_at,
|
||||
}));
|
||||
setResult({
|
||||
key: "player_transfer",
|
||||
raw: payload.items,
|
||||
rows,
|
||||
meta: { total: payload.total, page: payload.page, perPage: payload.per_page, lastPage: Math.max(1, Math.ceil(payload.total / payload.per_page)) },
|
||||
summary: [
|
||||
{ label: t("preview.stats.records"), value: String(payload.total) },
|
||||
{ label: t("preview.stats.currentPage"), value: String(payload.items.length) },
|
||||
{ label: t("preview.stats.transferIn"), value: String(payload.items.filter((item) => item.direction === "in").length), tone: "good" },
|
||||
{ label: t("preview.stats.transferOut"), value: String(payload.items.filter((item) => item.direction === "out").length), tone: "warn" },
|
||||
],
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "hot_number_risk": {
|
||||
if (!filters.number.trim()) {
|
||||
throw new LotteryApiBizError(t("validation.drawNoNumberRequired"), -1, null);
|
||||
}
|
||||
const draw = await resolveDraw(filters);
|
||||
const detail = await getAdminRiskPoolDetail(draw.id, filters.number.trim(), { page, per_page: perPage });
|
||||
const rows: ExportRow[] = [
|
||||
{
|
||||
row_type: "risk_pool",
|
||||
draw_id: detail.draw_id,
|
||||
draw_no: detail.draw_no,
|
||||
number: detail.pool.normalized_number,
|
||||
total_cap_amount: detail.pool.total_cap_amount,
|
||||
locked_amount: detail.pool.locked_amount,
|
||||
remaining_amount: detail.pool.remaining_amount,
|
||||
sold_out_status: detail.pool.sold_out_status,
|
||||
is_sold_out: detail.pool.is_sold_out ? 1 : 0,
|
||||
usage_ratio: detail.pool.usage_ratio,
|
||||
version: detail.pool.version,
|
||||
},
|
||||
...detail.logs.items.map((item) => ({
|
||||
row_type: "lock_log",
|
||||
draw_id: detail.draw_id,
|
||||
draw_no: detail.draw_no,
|
||||
number: detail.pool.normalized_number,
|
||||
log_id: item.id,
|
||||
action_type: item.action_type,
|
||||
amount: item.amount,
|
||||
source_reason: item.source_reason,
|
||||
ticket_item_id: item.ticket_item_id,
|
||||
ticket_no: item.ticket_no,
|
||||
play_code: item.play_code,
|
||||
player_id: item.player_id,
|
||||
created_at: item.created_at,
|
||||
})),
|
||||
];
|
||||
setResult({
|
||||
key: "hot_number_risk",
|
||||
raw: detail,
|
||||
rows,
|
||||
meta: metaFromList(detail.logs.meta),
|
||||
summary: [
|
||||
{ label: t("preview.stats.locked"), value: formatPlainMoney(detail.pool.locked_amount, detail.currency_code) },
|
||||
{ label: t("preview.stats.remaining"), value: formatPlainMoney(detail.pool.remaining_amount, detail.currency_code), tone: detail.pool.is_sold_out ? "bad" : "good" },
|
||||
{ label: t("preview.stats.usage"), value: detail.pool.usage_ratio == null ? "-" : `${detail.pool.usage_ratio}%`, tone: detail.pool.is_sold_out ? "bad" : "warn" },
|
||||
{ label: t("preview.stats.logs"), value: String(detail.logs.meta.total) },
|
||||
],
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "sold_out_number": {
|
||||
const draw = await resolveDraw(filters);
|
||||
const payload = await getAdminRiskPools(draw.id, { page, per_page: perPage, sold_out_only: true, sort: "number_asc" });
|
||||
const rows = payload.items.map((item) => ({
|
||||
draw_id: payload.draw_id,
|
||||
draw_no: payload.draw_no,
|
||||
currency_code: payload.currency_code,
|
||||
normalized_number: item.normalized_number,
|
||||
total_cap_amount: item.total_cap_amount,
|
||||
locked_amount: item.locked_amount,
|
||||
remaining_amount: item.remaining_amount,
|
||||
sold_out_status: item.sold_out_status,
|
||||
is_sold_out: item.is_sold_out ? 1 : 0,
|
||||
usage_ratio: item.usage_ratio,
|
||||
version: item.version,
|
||||
}));
|
||||
setResult({
|
||||
key: "sold_out_number",
|
||||
raw: payload.items,
|
||||
rows,
|
||||
meta: metaFromList(payload.meta),
|
||||
summary: [
|
||||
{ label: t("preview.stats.records"), value: String(payload.meta.total), tone: payload.meta.total > 0 ? "bad" : "good" },
|
||||
{ label: t("preview.stats.currentPage"), value: String(payload.items.length) },
|
||||
{ label: t("preview.stats.drawNo"), value: payload.draw_no },
|
||||
{ label: t("preview.stats.currency"), value: payload.currency_code || "-" },
|
||||
],
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "play_dimension": {
|
||||
const payload = await getAdminReportPlayDimension(reportListParams(filters, page, perPage));
|
||||
const rows = payload.items.map((item) => ({
|
||||
play_code: item.play_code,
|
||||
dimension: item.dimension,
|
||||
total_bet_minor: item.total_bet_minor,
|
||||
total_payout_minor: item.total_payout_minor,
|
||||
approx_house_gross_minor: item.approx_house_gross_minor,
|
||||
}));
|
||||
setResult({
|
||||
key: "play_dimension",
|
||||
raw: payload.items,
|
||||
rows,
|
||||
meta: metaFromList(payload.meta),
|
||||
summary: [
|
||||
{ label: t("preview.stats.records"), value: String(payload.meta.total) },
|
||||
{ label: t("preview.stats.currentPage"), value: String(payload.items.length) },
|
||||
{ label: t("preview.stats.bet"), value: formatPlainMoney(payload.items.reduce((s, i) => s + i.total_bet_minor, 0), "NPR") },
|
||||
{ label: t("preview.stats.payout"), value: formatPlainMoney(payload.items.reduce((s, i) => s + i.total_payout_minor, 0), "NPR") },
|
||||
],
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "rebate_commission": {
|
||||
const payload = await getAdminReportRebateCommission(reportListParams(filters, page, perPage));
|
||||
const rows = payload.items.map((item) => ({
|
||||
play_code: item.play_code,
|
||||
total_rebate_minor: item.total_rebate_minor,
|
||||
order_count: item.order_count,
|
||||
ticket_item_count: item.ticket_item_count,
|
||||
}));
|
||||
setResult({
|
||||
key: "rebate_commission",
|
||||
raw: payload.items,
|
||||
rows,
|
||||
meta: metaFromList(payload.meta),
|
||||
summary: [
|
||||
{ label: t("preview.stats.records"), value: String(payload.meta.total) },
|
||||
{ label: t("preview.stats.currentPage"), value: String(payload.items.length) },
|
||||
{ label: t("preview.stats.rebate"), value: formatPlainMoney(payload.items.reduce((s, i) => s + i.total_rebate_minor, 0), "NPR") },
|
||||
{ label: t("preview.stats.orders"), value: String(payload.items.reduce((s, i) => s + i.order_count, 0)) },
|
||||
],
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "admin_audit": {
|
||||
const operatorId = filters.operatorId ?? parsePositiveInteger(filters.operator);
|
||||
const payload = await getAdminAuditLogs({
|
||||
page,
|
||||
per_page: perPage,
|
||||
operator_id: operatorId ?? undefined,
|
||||
operator_type: operatorId ? undefined : filters.operator.trim() || undefined,
|
||||
start_date: filters.dateFrom || undefined,
|
||||
end_date: filters.dateTo || undefined,
|
||||
});
|
||||
const rows = payload.items.map((item) => ({
|
||||
id: item.id,
|
||||
operator_type: item.operator_type,
|
||||
operator_id: item.operator_id,
|
||||
module_code: item.module_code,
|
||||
action_code: item.action_code,
|
||||
target_type: item.target_type,
|
||||
target_id: item.target_id,
|
||||
ip: item.ip,
|
||||
user_agent: item.user_agent,
|
||||
created_at: item.created_at,
|
||||
}));
|
||||
setResult({
|
||||
key: "admin_audit",
|
||||
raw: payload.items,
|
||||
rows,
|
||||
meta: metaFromList(payload.meta),
|
||||
summary: [
|
||||
{ label: t("preview.stats.records"), value: String(payload.meta.total) },
|
||||
{ label: t("preview.stats.currentPage"), value: String(payload.items.length) },
|
||||
{ label: t("preview.stats.modules"), value: String(new Set(payload.items.map((item) => item.module_code)).size) },
|
||||
{ label: t("preview.stats.operators"), value: String(new Set(payload.items.map((item) => item.operator_id)).size) },
|
||||
],
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
setResult(null);
|
||||
setError(t("loadFailed"));
|
||||
}
|
||||
} catch (err) {
|
||||
setResult(null);
|
||||
setError(err instanceof LotteryApiBizError ? err.message : t("loadFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filters, page, perPage, selectedReport, t]);
|
||||
|
||||
useEffect(() => {
|
||||
setResult(null);
|
||||
setError(null);
|
||||
setPage(1);
|
||||
}, [selectedKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (result && result.key === selectedReport.key && selectedReport.connected) {
|
||||
void queryReport();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [page, perPage]);
|
||||
|
||||
function updateFilter<K extends keyof ReportFilters>(key: K, value: ReportFilters[K]): void {
|
||||
setFilters((prev) => ({ ...prev, [key]: value }));
|
||||
}
|
||||
|
||||
function resetFilters(): void {
|
||||
setFilters(emptyFilters);
|
||||
setResult(null);
|
||||
setError(null);
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
function exportReport(format: ExportFormat): void {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
const renderSearchPicker = (kind: SearchKind) => {
|
||||
const value =
|
||||
kind === "draw" ? filters.drawNo : kind === "player" ? filters.player : filters.operator;
|
||||
const labelKey = kind === "draw" ? "drawNo" : kind === "player" ? "player" : "operator";
|
||||
|
||||
return (
|
||||
<div className="relative grid gap-1.5">
|
||||
<Label htmlFor={`report-${kind}`}>{t(`fields.${labelKey}`)}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id={`report-${kind}`}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value;
|
||||
if (kind === "draw") {
|
||||
setFilters((prev) => ({ ...prev, drawNo: next, drawId: null }));
|
||||
} else if (kind === "player") {
|
||||
setFilters((prev) => ({ ...prev, player: next, playerId: null }));
|
||||
} else {
|
||||
setFilters((prev) => ({ ...prev, operator: next, operatorId: null }));
|
||||
}
|
||||
}}
|
||||
placeholder={t(`placeholders.${labelKey}`)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="shrink-0"
|
||||
onClick={() => {
|
||||
setSearch((prev) => ({
|
||||
...prev,
|
||||
open: prev.open === kind ? null : kind,
|
||||
query: value,
|
||||
}));
|
||||
}}
|
||||
aria-label={t("searchPicker.open")}
|
||||
>
|
||||
<Search data-icon="inline-start" />
|
||||
{t("searchPicker.select")}
|
||||
</Button>
|
||||
</div>
|
||||
{search.open === kind ? (
|
||||
<div className="absolute left-0 right-0 top-[4.55rem] z-20 rounded-md border border-border bg-popover p-2 shadow-lg">
|
||||
<Input
|
||||
value={search.query}
|
||||
placeholder={t("searchPicker.keyword")}
|
||||
onChange={(e) => setSearch((prev) => ({ ...prev, query: e.target.value }))}
|
||||
/>
|
||||
<div className="mt-2 max-h-64 overflow-auto">
|
||||
{search.loading ? (
|
||||
<p className="px-2 py-2 text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
||||
) : null}
|
||||
{!search.loading && kind === "draw" ? (
|
||||
search.draws.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between rounded-md px-2 py-2 text-left text-sm hover:bg-muted"
|
||||
onClick={() => {
|
||||
setFilters((prev) => ({ ...prev, drawNo: item.draw_no, drawId: item.id }));
|
||||
setSearch(emptySearch);
|
||||
}}
|
||||
>
|
||||
<span className="font-medium">{item.draw_no}</span>
|
||||
<span className="text-xs text-muted-foreground">{item.status}</span>
|
||||
</button>
|
||||
))
|
||||
) : null}
|
||||
{!search.loading && kind === "player" ? (
|
||||
search.players.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between gap-3 rounded-md px-2 py-2 text-left text-sm hover:bg-muted"
|
||||
onClick={() => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
player: optionText(item.username, item.site_player_id, `ID ${item.id}`),
|
||||
playerId: item.id,
|
||||
}));
|
||||
setSearch(emptySearch);
|
||||
}}
|
||||
>
|
||||
<span className="min-w-0 truncate font-medium">{optionText(item.username, item.nickname, item.site_player_id)}</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">ID {item.id}</span>
|
||||
</button>
|
||||
))
|
||||
) : null}
|
||||
{!search.loading && kind === "operator" ? (
|
||||
search.operators.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between gap-3 rounded-md px-2 py-2 text-left text-sm hover:bg-muted"
|
||||
onClick={() => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
operator: optionText(item.username, item.nickname, `ID ${item.id}`),
|
||||
operatorId: item.id,
|
||||
}));
|
||||
setSearch(emptySearch);
|
||||
}}
|
||||
>
|
||||
<span className="min-w-0 truncate font-medium">{optionText(item.username, item.nickname)}</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">ID {item.id}</span>
|
||||
</button>
|
||||
))
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderField = (field: FieldKey) => {
|
||||
if (field === "period") {
|
||||
return (
|
||||
<AdminDateRangeField
|
||||
key={field}
|
||||
id="report-date-range"
|
||||
label={t("fields.period")}
|
||||
from={filters.dateFrom}
|
||||
to={filters.dateTo}
|
||||
onRangeChange={({ from, to }) => {
|
||||
setFilters((prev) => ({ ...prev, dateFrom: from, dateTo: to }));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (field === "drawNo") {
|
||||
return <div key={field}>{renderSearchPicker("draw")}</div>;
|
||||
}
|
||||
if (field === "player") {
|
||||
return <div key={field}>{renderSearchPicker("player")}</div>;
|
||||
}
|
||||
if (field === "operator") {
|
||||
return <div key={field}>{renderSearchPicker("operator")}</div>;
|
||||
}
|
||||
if (field === "play") {
|
||||
return (
|
||||
<div key={field} className="grid gap-1.5">
|
||||
<Label htmlFor="report-play">{t("fields.play")}</Label>
|
||||
<Select
|
||||
modal={false}
|
||||
value={filters.play || "__none__"}
|
||||
onValueChange={(value) => updateFilter("play", value === "__none__" ? "" : String(value))}
|
||||
>
|
||||
<SelectTrigger id="report-play" className="h-8 w-full">
|
||||
<SelectValue>{filters.play ? playOptions.find((item) => item.code === filters.play)?.label ?? filters.play : t("placeholders.play")}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start" sideOffset={6}>
|
||||
<SelectItem value="__none__">{t("filterAll")}</SelectItem>
|
||||
{playOptions.map((item) => (
|
||||
<SelectItem key={item.code} value={item.code}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={field} className="grid gap-1.5">
|
||||
<Label htmlFor={`report-${field}`}>{t(`fields.${field}`)}</Label>
|
||||
<Input
|
||||
id={`report-${field}`}
|
||||
value={filters.number}
|
||||
onChange={(e) => updateFilter("number", e.target.value)}
|
||||
placeholder={t(`placeholders.${field}`)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderTable = () => {
|
||||
if (!selectedReport.connected) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-muted-foreground">
|
||||
{t("backendPending")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
if (loading) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-muted-foreground">
|
||||
{t("states.loading", { ns: "common" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-destructive">
|
||||
{error}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
if (!result || result.rows.length === 0) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-muted-foreground">
|
||||
{t("preview.empty")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
if (result.key === "draw_profit") {
|
||||
const summary = result.raw;
|
||||
return (
|
||||
<>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">{summary.draw_no}</TableCell>
|
||||
<TableCell>{summary.draw_status}</TableCell>
|
||||
<TableCell className="text-right">{summary.order_count}</TableCell>
|
||||
<TableCell className="text-right">{summary.ticket_item_count}</TableCell>
|
||||
<TableCell className="text-right">{formatPlainMoney(summary.total_bet_minor, summary.currency_code)}</TableCell>
|
||||
<TableCell className="text-right">{formatPlainMoney(summary.total_payout_minor, summary.currency_code)}</TableCell>
|
||||
<TableCell className="text-right">{formatPlainMoney(summary.approx_house_gross_minor, summary.currency_code)}</TableCell>
|
||||
<TableCell>{summary.settlement_batches.length}</TableCell>
|
||||
</TableRow>
|
||||
{summary.settlement_batches.map((batch) => (
|
||||
<TableRow key={batch.id} className="bg-muted/15">
|
||||
<TableCell>#{batch.id}</TableCell>
|
||||
<TableCell>{batch.status}</TableCell>
|
||||
<TableCell className="text-right">{batch.total_ticket_count}</TableCell>
|
||||
<TableCell className="text-right">{batch.total_win_count}</TableCell>
|
||||
<TableCell className="text-right">-</TableCell>
|
||||
<TableCell className="text-right">{formatPlainMoney(batch.total_payout_amount, summary.currency_code)}</TableCell>
|
||||
<TableCell className="text-right">{formatPlainMoney(batch.total_jackpot_payout_amount, summary.currency_code)}</TableCell>
|
||||
<TableCell>{formatTs(batch.finished_at)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (result.key === "player_transfer") {
|
||||
return result.raw.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="font-mono text-xs">{item.transfer_no}</TableCell>
|
||||
<TableCell>{optionText(item.username, item.nickname) || item.player_id}</TableCell>
|
||||
<TableCell>{item.direction}</TableCell>
|
||||
<TableCell>{item.status}</TableCell>
|
||||
<TableCell className="text-right">{item.currency_code} {item.amount}</TableCell>
|
||||
<TableCell>{item.external_ref_no || "-"}</TableCell>
|
||||
<TableCell>{item.fail_reason || "-"}</TableCell>
|
||||
<TableCell>{formatTs(item.created_at)}</TableCell>
|
||||
</TableRow>
|
||||
));
|
||||
}
|
||||
|
||||
if (result.key === "hot_number_risk") {
|
||||
return (
|
||||
<>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">{result.raw.pool.normalized_number}</TableCell>
|
||||
<TableCell>{result.raw.draw_no}</TableCell>
|
||||
<TableCell className="text-right">{formatPlainMoney(result.raw.pool.total_cap_amount, result.raw.currency_code)}</TableCell>
|
||||
<TableCell className="text-right">{formatPlainMoney(result.raw.pool.locked_amount, result.raw.currency_code)}</TableCell>
|
||||
<TableCell className="text-right">{formatPlainMoney(result.raw.pool.remaining_amount, result.raw.currency_code)}</TableCell>
|
||||
<TableCell>{result.raw.pool.is_sold_out ? t("yes") : t("no")}</TableCell>
|
||||
<TableCell>{result.raw.pool.usage_ratio == null ? "-" : `${result.raw.pool.usage_ratio}%`}</TableCell>
|
||||
<TableCell>v{result.raw.pool.version}</TableCell>
|
||||
</TableRow>
|
||||
{result.raw.logs.items.map((item) => (
|
||||
<TableRow key={item.id} className="bg-muted/15">
|
||||
<TableCell className="font-mono text-xs">#{item.id}</TableCell>
|
||||
<TableCell>{item.action_type}</TableCell>
|
||||
<TableCell className="text-right">{formatPlainMoney(item.amount, result.raw.currency_code)}</TableCell>
|
||||
<TableCell>{item.play_code || "-"}</TableCell>
|
||||
<TableCell>{item.ticket_no || "-"}</TableCell>
|
||||
<TableCell>{item.player_id || "-"}</TableCell>
|
||||
<TableCell>{item.source_reason || "-"}</TableCell>
|
||||
<TableCell>{formatTs(item.created_at)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (result.key === "sold_out_number") {
|
||||
return result.raw.map((item) => (
|
||||
<TableRow key={item.normalized_number}>
|
||||
<TableCell className="font-medium">{item.normalized_number}</TableCell>
|
||||
<TableCell>{filters.drawNo}</TableCell>
|
||||
<TableCell className="text-right">{formatPlainMoney(item.total_cap_amount, null)}</TableCell>
|
||||
<TableCell className="text-right">{formatPlainMoney(item.locked_amount, null)}</TableCell>
|
||||
<TableCell className="text-right">{formatPlainMoney(item.remaining_amount, null)}</TableCell>
|
||||
<TableCell>{item.is_sold_out ? t("yes") : t("no")}</TableCell>
|
||||
<TableCell>{item.usage_ratio == null ? "-" : `${item.usage_ratio}%`}</TableCell>
|
||||
<TableCell>v{item.version}</TableCell>
|
||||
</TableRow>
|
||||
));
|
||||
}
|
||||
|
||||
if (result.key === "daily_profit") {
|
||||
return result.raw.map((item) => (
|
||||
<TableRow key={item.business_date}>
|
||||
<TableCell className="font-medium">{item.business_date}</TableCell>
|
||||
<TableCell>-</TableCell>
|
||||
<TableCell className="text-right">{formatPlainMoney(item.total_bet_minor, "NPR")}</TableCell>
|
||||
<TableCell className="text-right">{formatPlainMoney(item.total_payout_minor, "NPR")}</TableCell>
|
||||
<TableCell className="text-right">{formatPlainMoney(item.approx_house_gross_minor, "NPR")}</TableCell>
|
||||
<TableCell>-</TableCell>
|
||||
<TableCell>-</TableCell>
|
||||
<TableCell>-</TableCell>
|
||||
</TableRow>
|
||||
));
|
||||
}
|
||||
|
||||
if (result.key === "player_win_loss") {
|
||||
return result.raw.map((item) => (
|
||||
<TableRow key={item.player_id}>
|
||||
<TableCell className="font-medium">{item.username}</TableCell>
|
||||
<TableCell>ID {item.player_id}</TableCell>
|
||||
<TableCell className="text-right">{formatPlainMoney(item.total_bet_minor, "NPR")}</TableCell>
|
||||
<TableCell className="text-right">{formatPlainMoney(item.total_payout_minor, "NPR")}</TableCell>
|
||||
<TableCell className="text-right">{formatPlainMoney(item.net_win_loss_minor, "NPR")}</TableCell>
|
||||
<TableCell>-</TableCell>
|
||||
<TableCell>-</TableCell>
|
||||
<TableCell>-</TableCell>
|
||||
</TableRow>
|
||||
));
|
||||
}
|
||||
|
||||
if (result.key === "play_dimension") {
|
||||
return result.raw.map((item) => (
|
||||
<TableRow key={`${item.play_code}-${item.dimension}`}>
|
||||
<TableCell className="font-medium">{item.play_code}</TableCell>
|
||||
<TableCell>{item.dimension}D</TableCell>
|
||||
<TableCell className="text-right">{formatPlainMoney(item.total_bet_minor, "NPR")}</TableCell>
|
||||
<TableCell className="text-right">{formatPlainMoney(item.total_payout_minor, "NPR")}</TableCell>
|
||||
<TableCell className="text-right">{formatPlainMoney(item.approx_house_gross_minor, "NPR")}</TableCell>
|
||||
<TableCell>-</TableCell>
|
||||
<TableCell>-</TableCell>
|
||||
<TableCell>-</TableCell>
|
||||
</TableRow>
|
||||
));
|
||||
}
|
||||
|
||||
if (result.key === "rebate_commission") {
|
||||
return result.raw.map((item) => (
|
||||
<TableRow key={item.play_code}>
|
||||
<TableCell className="font-medium">{item.play_code}</TableCell>
|
||||
<TableCell>{item.order_count}</TableCell>
|
||||
<TableCell className="text-right">{formatPlainMoney(item.total_rebate_minor, "NPR")}</TableCell>
|
||||
<TableCell className="text-right">{item.ticket_item_count}</TableCell>
|
||||
<TableCell>-</TableCell>
|
||||
<TableCell>-</TableCell>
|
||||
<TableCell>-</TableCell>
|
||||
<TableCell>-</TableCell>
|
||||
</TableRow>
|
||||
));
|
||||
}
|
||||
|
||||
if (result.key === "admin_audit") {
|
||||
return result.raw.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="font-mono text-xs">#{item.id}</TableCell>
|
||||
<TableCell>{item.operator_type}</TableCell>
|
||||
<TableCell>{item.operator_id}</TableCell>
|
||||
<TableCell>{item.module_code}</TableCell>
|
||||
<TableCell>{item.action_code}</TableCell>
|
||||
<TableCell>{item.target_type || "-"}</TableCell>
|
||||
<TableCell>{item.ip || "-"}</TableCell>
|
||||
<TableCell>{formatTs(item.created_at)}</TableCell>
|
||||
</TableRow>
|
||||
));
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-lg font-semibold tracking-tight text-[#13315f]">{t("title")}</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t("subtitle")}</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="h-7 px-3">
|
||||
{resultRowCount(result)} {t("preview.exportableRows")}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-[18rem_minmax(0,1fr)]">
|
||||
<Card className="admin-list-card self-start">
|
||||
<CardHeader className="admin-list-header pb-4">
|
||||
<CardTitle className="admin-list-title">{t("chooseReport")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 pt-4">
|
||||
{REPORTS.map((report) => {
|
||||
const Icon = report.icon;
|
||||
const active = report.key === selectedReport.key;
|
||||
return (
|
||||
<button
|
||||
key={report.key}
|
||||
type="button"
|
||||
onClick={() => setSelectedKey(report.key)}
|
||||
className={cn(
|
||||
"flex w-full min-w-0 items-start gap-3 rounded-md border px-3 py-3 text-left transition",
|
||||
active
|
||||
? "border-primary bg-primary/[0.05] shadow-sm ring-1 ring-primary/15"
|
||||
: "border-border/80 bg-card hover:border-primary/35 hover:bg-muted/30",
|
||||
)}
|
||||
>
|
||||
<span className={cn("flex size-8 shrink-0 items-center justify-center rounded-md border", categoryTone(report.category))}>
|
||||
<Icon className="size-4" aria-hidden />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-semibold text-foreground">{t(`items.${report.key}.title`)}</span>
|
||||
<span className="mt-1 block text-xs text-muted-foreground">
|
||||
{t(`categories.${report.category}`)} · {formatKind(report.filterKind, t)}
|
||||
</span>
|
||||
</span>
|
||||
<Badge variant={report.connected ? "secondary" : "outline"} className="shrink-0">
|
||||
{report.connected ? t("connected") : t("pending")}
|
||||
</Badge>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="min-w-0 space-y-5">
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header flex flex-col gap-3 pb-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="admin-list-title">{t("filterPanel")}</CardTitle>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t(`items.${selectedReport.key}.summary`)}</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Badge variant="outline">{t(`categories.${selectedReport.category}`)}</Badge>
|
||||
<Badge variant="secondary">{t(`scopes.${selectedReport.scope}`)}</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 pt-4">
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{selectedReport.fields.map(renderField)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 border-t border-border/60 pt-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{selectedReport.connected ? t("queryHint") : t("backendPending")}
|
||||
</p>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Button type="button" variant="outline" onClick={resetFilters}>
|
||||
{t("reset")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!selectedReport.connected || loading}
|
||||
onClick={() => {
|
||||
setPage(1);
|
||||
void queryReport();
|
||||
}}
|
||||
>
|
||||
<Database data-icon="inline-start" />
|
||||
{loading ? t("querying") : t("query")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-4">
|
||||
{(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) => (
|
||||
<div key={item.label} className={cn("rounded-md border px-4 py-3", statTone(item.tone))}>
|
||||
<div className="text-xs text-muted-foreground">{item.label}</div>
|
||||
<div className="mt-1 truncate text-lg font-semibold tabular-nums">{item.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header flex flex-col gap-3 pb-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<CardTitle className="admin-list-title">{t("preview.title")}</CardTitle>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t("preview.subtitle")}</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Button type="button" variant="outline" disabled={!result || exporting !== null} onClick={() => exportReport("csv")}>
|
||||
<FileDown data-icon="inline-start" />
|
||||
{t("formats.csv")}
|
||||
</Button>
|
||||
<Button type="button" disabled={!result || exporting !== null} onClick={() => exportReport("excel")}>
|
||||
<FileSpreadsheet data-icon="inline-start" />
|
||||
{t("formats.excel")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 pt-4">
|
||||
<Table id="reports-preview-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("preview.columns.primary")}</TableHead>
|
||||
<TableHead>{t("preview.columns.secondary")}</TableHead>
|
||||
<TableHead className="text-right">{t("preview.columns.metricA")}</TableHead>
|
||||
<TableHead className="text-right">{t("preview.columns.metricB")}</TableHead>
|
||||
<TableHead className="text-right">{t("preview.columns.metricC")}</TableHead>
|
||||
<TableHead>{t("preview.columns.status")}</TableHead>
|
||||
<TableHead>{t("preview.columns.extra")}</TableHead>
|
||||
<TableHead>{t("preview.columns.time")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>{renderTable()}</TableBody>
|
||||
</Table>
|
||||
|
||||
{result?.meta ? (
|
||||
<AdminListPaginationFooter
|
||||
selectId="reports-preview-per-page"
|
||||
total={result.meta.total}
|
||||
page={result.meta.page}
|
||||
lastPage={result.meta.lastPage}
|
||||
perPage={result.meta.perPage}
|
||||
loading={loading}
|
||||
onPerPageChange={(next) => {
|
||||
setPerPage(next);
|
||||
setPage(1);
|
||||
}}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
src/types/api/admin-reports.ts
Normal file
48
src/types/api/admin-reports.ts
Normal file
@@ -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<T> = {
|
||||
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;
|
||||
};
|
||||
Reference in New Issue
Block a user