"use client"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useSearchParams } from "next/navigation"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { CalendarDays, CircleDollarSign, Database, FileDown, FileSpreadsheet, ListFilter, Search, ShieldAlert, ShieldCheck, Ticket, Users, WalletCards, } from "lucide-react"; import { getAdminAuditLogs } from "@/api/admin-audit"; import { useAdminPlayCodeLabel, useAdminPlayTypeCatalog } from "@/hooks/use-admin-play-type-catalog"; import { useCachedPlayTypeOptions } from "@/hooks/use-cached-play-type-options"; import { useAsyncEffect } from "@/hooks/use-async-effect"; import { useTranslationRef } from "@/hooks/use-translation-ref"; import { getAdminDraws, getAdminDrawFinanceSummary } from "@/api/admin-draws"; import { getAdminPlayers } from "@/api/admin-player"; import { downloadAdminReportJob, postAdminReportJob } from "@/api/admin-report-jobs"; import { getAdminReportDailyProfit, getAdminReportPlayDimension, getAdminReportPlayerWinLoss, getAdminReportRebateCommission, } from "@/api/admin-reports"; import { buildReportJobParameters, REPORT_UI_TO_JOB_TYPE, type ReportUiKey, } from "@/lib/report-export-map"; import { getAdminRiskPoolDetail, getAdminRiskPools } from "@/api/admin-risk"; import { getAdminUsers } from "@/api/admin-users"; import { getAdminTransferOrders } from "@/api/admin-wallet"; import { adminHasAnyPermission } from "@/lib/admin-permissions"; import { PRD_REPORT_EXPORT, PRD_REPORT_VIEW } from "@/lib/admin-prd"; import { useAdminProfile } from "@/stores/admin-session"; import { adminAgentDisplayLabel } from "@/components/admin/admin-agent-columns"; import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state"; import { AdminDateRangeField } from "@/components/admin/admin-date-range-field"; import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer"; 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 { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { useAdminCurrencyCatalog, getCachedAdminCurrencies } from "@/hooks/use-admin-currency-catalog"; import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"; import { formatAdminInstant } from "@/lib/admin-datetime"; import { getAdminRequestLocale } from "@/lib/admin-locale"; import { signedMoneyClass } from "@/lib/admin-signed-money"; 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"; export 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; 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 PreviewColumns = { primary: string; secondary: string; metricA: string; metricB: string; metricC: string; status: string; extra: string; time: string; }; 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: "profit", 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: "", }; function isoDateLocal(date: Date): string { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0"); return `${year}-${month}-${day}`; } function defaultReportPeriod(): Pick { const to = new Date(); const from = new Date(); from.setDate(from.getDate() - 29); return { dateFrom: isoDateLocal(from), dateTo: isoDateLocal(to) }; } function createDefaultFilters(): ReportFilters { return { ...emptyFilters, ...defaultReportPeriod() }; } function reportHasPeriodField(report: ReportDefinition): boolean { return report.fields.includes("period"); } function resolveDisplayCurrency(apiCode?: string | null): string { const trimmed = apiCode?.trim(); if (trimmed) { return trimmed; } const fallback = getCachedAdminCurrencies().find((row) => row.is_default)?.code; return fallback?.trim() || "NPR"; } 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 formatExportInstant(iso: string | null | undefined): ExportCell { return formatAdminInstant(iso, { locale: getAdminRequestLocale() }); } function buildDailyProfitRowsAndSummary( items: AdminReportDailyProfitRow[], total: number, t: (key: string) => string, pageScopedLabel: (statKey: string) => string, currencyCode: string, ): Pick, "rows" | "summary"> { let totalBet = 0; let totalPayout = 0; let totalGross = 0; const rows = items.map((item) => { totalBet += item.total_bet_minor; totalPayout += item.total_payout_minor; totalGross += item.approx_house_gross_minor; return { 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, }; }); return { rows, summary: [ { label: t("preview.stats.records"), value: String(total) }, { label: pageScopedLabel("bet"), value: formatPlainMoney(totalBet, currencyCode) }, { label: pageScopedLabel("payout"), value: formatPlainMoney(totalPayout, currencyCode) }, { label: pageScopedLabel("houseGross"), value: formatPlainMoney(totalGross, currencyCode), tone: totalGross >= 0 ? "good" : "bad", }, ], }; } function buildPlayerTransferRowsAndSummary( items: AdminTransferOrderItem[], total: number, page: number, perPage: number, t: (key: string) => string, pageScopedLabel: (statKey: string) => string, ): Pick, "rows" | "summary" | "meta"> { let transferInCount = 0; let transferOutCount = 0; const rows = items.map((item) => { if (item.direction === "in") { transferInCount += 1; } else if (item.direction === "out") { transferOutCount += 1; } return { 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: formatExportInstant(item.created_at), finished_at: formatExportInstant(item.finished_at), }; }); return { rows, meta: { total, page, perPage, lastPage: Math.max(1, Math.ceil(total / perPage)) }, summary: [ { label: t("preview.stats.records"), value: String(total) }, { label: t("preview.stats.currentPage"), value: String(items.length) }, { label: pageScopedLabel("transferIn"), value: String(transferInCount), tone: "good" }, { label: pageScopedLabel("transferOut"), value: String(transferOutCount), tone: "warn" }, ], }; } function buildPlayDimensionRowsAndSummary( items: AdminReportPlayDimensionRow[], total: number, t: (key: string) => string, pageScopedLabel: (statKey: string) => string, currencyCode: string, ): Pick, "rows" | "summary"> { let totalBet = 0; let totalPayout = 0; const rows = items.map((item) => { totalBet += item.total_bet_minor; totalPayout += item.total_payout_minor; return { 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, }; }); return { rows, summary: [ { label: t("preview.stats.records"), value: String(total) }, { label: t("preview.stats.currentPage"), value: String(items.length) }, { label: pageScopedLabel("bet"), value: formatPlainMoney(totalBet, currencyCode) }, { label: pageScopedLabel("payout"), value: formatPlainMoney(totalPayout, currencyCode) }, ], }; } function buildRebateCommissionRowsAndSummary( items: AdminReportRebateCommissionRow[], total: number, t: (key: string) => string, pageScopedLabel: (statKey: string) => string, currencyCode: string, ): Pick, "rows" | "summary"> { let totalRebate = 0; let totalOrders = 0; const rows = items.map((item) => { totalRebate += item.total_rebate_minor; totalOrders += item.order_count; return { play_code: item.play_code, total_rebate_minor: item.total_rebate_minor, order_count: item.order_count, ticket_item_count: item.ticket_item_count, }; }); return { rows, summary: [ { label: t("preview.stats.records"), value: String(total) }, { label: t("preview.stats.currentPage"), value: String(items.length) }, { label: pageScopedLabel("rebate"), value: formatPlainMoney(totalRebate, currencyCode) }, { label: pageScopedLabel("orders"), value: String(totalOrders) }, ], }; } 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 signedProfitCell(amount: number, currencyCode: string | null | undefined): string { return cn("text-center tabular-nums", signedMoneyClass(amount, true)); } function formatUsagePercent(ratio: number | null | undefined): string { return ratio == null ? "-" : `${Math.round(ratio * 100)}%`; } function optionText(...parts: Array): 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, messages: { drawNoRequired: string; drawNoNotFound: (drawNo: string) => string }, ): Promise<{ id: number; draw_no: string }> { if (filters.drawId != null && filters.drawId > 0) { const drawNo = filters.drawNo.trim(); return { id: filters.drawId, draw_no: drawNo || String(filters.drawId) }; } const drawNo = filters.drawNo.trim(); if (!drawNo) { throw new LotteryApiBizError(messages.drawNoRequired, -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(messages.drawNoNotFound(drawNo), -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: formatExportInstant(batch.finished_at), })), ]; } function resultRowCount(result: ReportResult | null): number { return result?.rows.length ?? 0; } function defaultSummaryCards( reportKey: ReportKey, filters: ReportFilters, t: (key: string) => string, ): StatCard[] { const periodLabel = filters.dateFrom && filters.dateTo ? `${filters.dateFrom} ~ ${filters.dateTo}` : filters.dateFrom || filters.dateTo || t("preview.stats.notQueried"); switch (reportKey) { case "draw_profit": return [ { label: t("preview.stats.bet"), value: t("preview.stats.notQueried") }, { label: t("preview.stats.payout"), value: t("preview.stats.notQueried") }, { label: t("preview.stats.houseGross"), value: t("preview.stats.notQueried") }, { label: t("preview.stats.drawNo"), value: filters.drawNo || t("preview.stats.notSet") }, ]; case "daily_profit": return [ { label: t("preview.stats.records"), value: t("preview.stats.notQueried") }, { label: t("fields.period"), value: periodLabel }, { label: t("preview.stats.bet"), value: t("preview.stats.notQueried") }, { label: t("preview.stats.houseGross"), value: t("preview.stats.notQueried") }, ]; case "player_win_loss": return [ { label: t("preview.stats.records"), value: t("preview.stats.notQueried") }, { label: t("fields.player"), value: filters.player || t("preview.stats.notSet") }, { label: t("preview.stats.players"), value: t("preview.stats.notQueried") }, { label: t("preview.stats.houseGross"), value: t("preview.stats.notQueried") }, ]; case "player_transfer": return [ { label: t("preview.stats.records"), value: t("preview.stats.notQueried") }, { label: t("fields.player"), value: filters.player || t("preview.stats.notSet") }, { label: t("preview.stats.transferIn"), value: t("preview.stats.notQueried") }, { label: t("preview.stats.transferOut"), value: t("preview.stats.notQueried") }, ]; case "hot_number_risk": return [ { label: t("preview.stats.drawNo"), value: filters.drawNo || t("preview.stats.notSet") }, { label: t("fields.number"), value: filters.number || t("preview.stats.notSet") }, { label: t("preview.stats.usage"), value: t("preview.stats.notQueried") }, { label: t("preview.stats.logs"), value: t("preview.stats.notQueried") }, ]; case "play_dimension": return [ { label: t("preview.stats.records"), value: t("preview.stats.notQueried") }, { label: t("fields.play"), value: filters.play || t("filterAll") }, { label: t("preview.stats.bet"), value: t("preview.stats.notQueried") }, { label: t("preview.stats.payout"), value: t("preview.stats.notQueried") }, ]; case "sold_out_number": return [ { label: t("preview.stats.records"), value: t("preview.stats.notQueried") }, { label: t("preview.stats.drawNo"), value: filters.drawNo || t("preview.stats.notSet") }, { label: t("preview.stats.currency"), value: t("preview.stats.notQueried") }, { label: t("preview.stats.usage"), value: t("preview.stats.notQueried") }, ]; case "rebate_commission": return [ { label: t("preview.stats.records"), value: t("preview.stats.notQueried") }, { label: t("fields.play"), value: filters.play || t("filterAll") }, { label: t("preview.stats.rebate"), value: t("preview.stats.notQueried") }, { label: t("preview.stats.orders"), value: t("preview.stats.notQueried") }, ]; case "admin_audit": return [ { label: t("preview.stats.records"), value: t("preview.stats.notQueried") }, { label: t("fields.operator"), value: filters.operator || t("preview.stats.notSet") }, { label: t("preview.stats.modules"), value: t("preview.stats.notQueried") }, { label: t("preview.stats.operators"), value: t("preview.stats.notQueried") }, ]; default: return [ { label: t("preview.stats.records"), value: t("preview.stats.notQueried") }, { label: t("preview.stats.currentPage"), value: t("preview.stats.notQueried") }, { label: t("preview.stats.exportRows"), value: "0" }, { label: t("preview.stats.drawNo"), value: filters.drawNo || t("preview.stats.notSet") }, ]; } } export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCategory } = {}) { const { t, i18n } = useTranslation(["reports", "common"]); const profile = useAdminProfile(); const canViewReports = adminHasAnyPermission(profile?.permissions, [PRD_REPORT_VIEW]); const canExportReports = adminHasAnyPermission(profile?.permissions, [PRD_REPORT_EXPORT]); useAdminCurrencyCatalog(); useAdminPlayTypeCatalog(); const playCodeLabel = useAdminPlayCodeLabel(); const formatTs = useAdminDateTimeFormatter(); const filteredReports = useMemo( () => (initialCategory ? REPORTS.filter((report) => report.category === initialCategory) : REPORTS), [initialCategory], ); const [selectedKey, setSelectedKey] = useState( filteredReports[0]?.key ?? REPORTS[0].key, ); const [filters, setFilters] = useState(createDefaultFilters); const [displayCurrency, setDisplayCurrency] = useState(() => resolveDisplayCurrency(null)); const [result, setResult] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [page, setPage] = useState(1); const [perPage, setPerPage] = useState(20); const [exporting, setExporting] = useState(null); const [search, setSearch] = useState(emptySearch); const playOptions = useCachedPlayTypeOptions(); const tRef = useTranslationRef(["reports", "common"]); const searchParams = useSearchParams(); const drawNoFromUrl = (searchParams.get("draw_no") ?? "").trim(); const selectedReport = filteredReports.find((report) => report.key === selectedKey) ?? filteredReports[0] ?? REPORTS[0]; useEffect(() => { if (!filteredReports.some((report) => report.key === selectedKey)) { setSelectedKey(filteredReports[0]?.key ?? REPORTS[0].key); } }, [filteredReports, selectedKey]); const pageScopedLabel = useCallback( (statKey: string) => t(`preview.stats.${statKey}`), [t], ); const previewColumns = useMemo(() => { switch (selectedReport.key) { case "draw_profit": return { primary: t("preview.columns.drawProfit.primary"), secondary: t("preview.columns.drawProfit.secondary"), metricA: t("preview.columns.drawProfit.metricA"), metricB: t("preview.columns.drawProfit.metricB"), metricC: t("preview.columns.drawProfit.metricC"), status: t("preview.columns.drawProfit.status"), extra: t("preview.columns.drawProfit.extra"), time: t("preview.columns.drawProfit.time"), }; case "daily_profit": return { primary: t("preview.columns.dailyProfit.primary"), secondary: t("preview.columns.dailyProfit.secondary"), metricA: t("preview.columns.dailyProfit.metricA"), metricB: t("preview.columns.dailyProfit.metricB"), metricC: t("preview.columns.dailyProfit.metricC"), status: t("preview.columns.dailyProfit.status"), extra: t("preview.columns.dailyProfit.extra"), time: t("preview.columns.dailyProfit.time"), }; case "player_win_loss": return { primary: t("preview.columns.playerWinLoss.primary"), secondary: t("agentColumns.agent", { ns: "common" }), metricA: t("preview.columns.playerWinLoss.metricA"), metricB: t("preview.columns.playerWinLoss.metricB"), metricC: t("preview.columns.playerWinLoss.metricC"), status: t("preview.columns.playerWinLoss.status"), extra: t("preview.columns.playerWinLoss.extra"), time: t("preview.columns.playerWinLoss.time"), }; case "player_transfer": return { primary: t("preview.columns.playerTransfer.primary"), secondary: t("preview.columns.playerTransfer.secondary"), metricA: t("preview.columns.playerTransfer.metricA"), metricB: t("preview.columns.playerTransfer.metricB"), metricC: t("preview.columns.playerTransfer.metricC"), status: t("preview.columns.playerTransfer.status"), extra: t("preview.columns.playerTransfer.extra"), time: t("preview.columns.playerTransfer.time"), }; case "hot_number_risk": return { primary: t("preview.columns.hotNumberRisk.primary"), secondary: t("preview.columns.hotNumberRisk.secondary"), metricA: t("preview.columns.hotNumberRisk.metricA"), metricB: t("preview.columns.hotNumberRisk.metricB"), metricC: t("preview.columns.hotNumberRisk.metricC"), status: t("preview.columns.hotNumberRisk.status"), extra: t("preview.columns.hotNumberRisk.extra"), time: t("preview.columns.hotNumberRisk.time"), }; case "play_dimension": return { primary: t("preview.columns.playDimension.primary"), secondary: t("preview.columns.playDimension.secondary"), metricA: t("preview.columns.playDimension.metricA"), metricB: t("preview.columns.playDimension.metricB"), metricC: t("preview.columns.playDimension.metricC"), status: t("preview.columns.playDimension.status"), extra: t("preview.columns.playDimension.extra"), time: t("preview.columns.playDimension.time"), }; case "sold_out_number": return { primary: t("preview.columns.soldOut.primary"), secondary: t("preview.columns.soldOut.secondary"), metricA: t("preview.columns.soldOut.metricA"), metricB: t("preview.columns.soldOut.metricB"), metricC: t("preview.columns.soldOut.metricC"), status: t("preview.columns.soldOut.status"), extra: t("preview.columns.soldOut.extra"), time: t("preview.columns.soldOut.time"), }; case "rebate_commission": return { primary: t("preview.columns.rebateCommission.primary"), secondary: t("preview.columns.rebateCommission.secondary"), metricA: t("preview.columns.rebateCommission.metricA"), metricB: t("preview.columns.rebateCommission.metricB"), metricC: t("preview.columns.rebateCommission.metricC"), status: t("preview.columns.rebateCommission.status"), extra: t("preview.columns.rebateCommission.extra"), time: t("preview.columns.rebateCommission.time"), }; case "admin_audit": return { primary: t("preview.columns.adminAudit.primary"), secondary: t("preview.columns.adminAudit.secondary"), metricA: t("preview.columns.adminAudit.metricA"), metricB: t("preview.columns.adminAudit.metricB"), metricC: t("preview.columns.adminAudit.metricC"), status: t("preview.columns.adminAudit.status"), extra: t("preview.columns.adminAudit.extra"), time: t("preview.columns.adminAudit.time"), }; default: return { primary: t("preview.columns.primary"), secondary: t("preview.columns.secondary"), metricA: t("preview.columns.metricA"), metricB: t("preview.columns.metricB"), metricC: t("preview.columns.metricC"), status: t("preview.columns.status"), extra: t("preview.columns.extra"), time: t("preview.columns.time"), }; } }, [selectedReport.key, t]); const exportFileBase = useMemo(() => { const segments: string[] = [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 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 () => { if (!canViewReports) { return; } setLoading(true); setError(null); try { switch (selectedReport.key) { case "draw_profit": { const draw = await resolveDraw(filters, { drawNoRequired: tRef.current("validation.drawNoRequired", { ns: "reports" }), drawNoNotFound: (drawNo) => tRef.current("validation.drawNoNotFound", { ns: "reports", drawNo }), }); const summary = await getAdminDrawFinanceSummary(draw.id); setDisplayCurrency(resolveDisplayCurrency(summary.currency_code)); 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 currencyCode = resolveDisplayCurrency(payload.currency_code); setDisplayCurrency(currencyCode); const next = buildDailyProfitRowsAndSummary(payload.items, payload.meta.total, t, pageScopedLabel, currencyCode); setResult({ key: "daily_profit", raw: payload.items, rows: next.rows, meta: metaFromList(payload.meta), summary: next.summary, }); break; } case "player_win_loss": { const payload = await getAdminReportPlayerWinLoss( reportListParams(filters, page, perPage), ); const currencyCode = resolveDisplayCurrency(payload.currency_code); setDisplayCurrency(currencyCode); 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: pageScopedLabel("houseGross"), value: formatPlainMoney( payload.items.reduce((sum, item) => sum - item.net_win_loss_minor, 0), currencyCode, ), tone: (() => { const houseGross = payload.items.reduce((sum, item) => sum - item.net_win_loss_minor, 0); return houseGross >= 0 ? "good" : "bad"; })(), }, { label: t("preview.stats.players"), value: String(new Set(payload.items.map((item) => item.player_id)).size) }, ], }); 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 next = buildPlayerTransferRowsAndSummary( payload.items, payload.total, payload.page, payload.per_page, t, pageScopedLabel, ); setDisplayCurrency(resolveDisplayCurrency(payload.items[0]?.currency_code)); setResult({ key: "player_transfer", raw: payload.items, rows: next.rows, meta: next.meta, summary: next.summary, }); break; } case "hot_number_risk": { if (!filters.number.trim()) { throw new LotteryApiBizError(tRef.current("validation.drawNoNumberRequired"), -1, null); } const draw = await resolveDraw(filters, { drawNoRequired: tRef.current("validation.drawNoRequired", { ns: "reports" }), drawNoNotFound: (drawNo) => tRef.current("validation.drawNoNotFound", { ns: "reports", drawNo }), }); const detail = await getAdminRiskPoolDetail(draw.id, filters.number.trim(), { page, per_page: perPage }); setDisplayCurrency(resolveDisplayCurrency(detail.currency_code)); 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: formatExportInstant(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: formatUsagePercent(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, { drawNoRequired: tRef.current("validation.drawNoRequired", { ns: "reports" }), drawNoNotFound: (drawNo) => tRef.current("validation.drawNoNotFound", { ns: "reports", drawNo }), }); const payload = await getAdminRiskPools(draw.id, { page, per_page: perPage, sold_out_only: true, sort: "number_asc" }); setDisplayCurrency(resolveDisplayCurrency(payload.currency_code)); 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 currencyCode = resolveDisplayCurrency(payload.currency_code); setDisplayCurrency(currencyCode); const next = buildPlayDimensionRowsAndSummary(payload.items, payload.meta.total, t, pageScopedLabel, currencyCode); setResult({ key: "play_dimension", raw: payload.items, rows: next.rows, meta: metaFromList(payload.meta), summary: next.summary, }); break; } case "rebate_commission": { const payload = await getAdminReportRebateCommission( reportListParams(filters, page, perPage), ); const currencyCode = resolveDisplayCurrency(payload.currency_code); setDisplayCurrency(currencyCode); const next = buildRebateCommissionRowsAndSummary(payload.items, payload.meta.total, t, pageScopedLabel, currencyCode); setResult({ key: "rebate_commission", raw: payload.items, rows: next.rows, meta: metaFromList(payload.meta), summary: next.summary, }); 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: formatExportInstant(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(tRef.current("loadFailed")); } } catch (err) { setResult(null); setError(err instanceof LotteryApiBizError ? err.message : tRef.current("loadFailed")); } finally { setLoading(false); } }, [canViewReports, filters, page, perPage, selectedReport]); useEffect(() => { queueMicrotask(() => { setResult(null); setError(null); setPage(1); }); }, [selectedKey]); useEffect(() => { setFilters((prev) => ({ ...prev, drawNo: drawNoFromUrl || prev.drawNo, })); if (drawNoFromUrl) { setSelectedKey("draw_profit"); } }, [drawNoFromUrl]); useEffect(() => { queueMicrotask(() => { setResult(null); setError(null); setPage(1); }); }, []); useEffect(() => { if (result && result.key === selectedReport.key && selectedReport.connected) { queueMicrotask(() => { void queryReport(); }); } }, [page, perPage]); function updateFilter(key: K, value: ReportFilters[K]): void { setFilters((prev) => ({ ...prev, [key]: value })); } function resetFilters(): void { setFilters(reportHasPeriodField(selectedReport) ? createDefaultFilters() : { ...emptyFilters }); setResult(null); setError(null); setPage(1); } async function exportReport(format: ExportFormat): Promise { if (!canExportReports) { return; } setExporting(format); try { const parameters = buildReportJobParameters(selectedReport.key as ReportUiKey, { dateFrom: filters.dateFrom, dateTo: filters.dateTo, playerId: filters.playerId, play: filters.play, operatorId: filters.operatorId, drawId: filters.drawId, drawNo: filters.drawNo, number: filters.number, }); const job = await postAdminReportJob({ report_type: REPORT_UI_TO_JOB_TYPE[selectedReport.key as ReportUiKey], export_format: format === "excel" ? "xlsx" : "csv", parameters, }); const { blob, filename } = await downloadAdminReportJob(job.id); const ext = job.export_format === "xlsx" ? "xlsx" : "csv"; downloadBlob(blob, filename ?? `${exportFileBase}.${ext}`); 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"; const open = search.open === kind; return (
{ setSearch((prev) => nextOpen ? { ...prev, open: kind, query: value, } : emptySearch, ); }} modal={false} >
{ 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}`)} /> }> {t("searchPicker.select")}
setSearch((prev) => ({ ...prev, query: e.target.value }))} />
{search.loading ? ( ) : 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}
); }; 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 ( ); } if (error) { return ( {error} ); } if (!result || result.rows.length === 0) { return ( ); } 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")} {formatUsagePercent(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)} {playCodeLabel(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, displayCurrency)} {formatPlainMoney(item.locked_amount, displayCurrency)} {formatPlainMoney(item.remaining_amount, displayCurrency)} {item.is_sold_out ? t("yes") : t("no")} {formatUsagePercent(item.usage_ratio)} v{item.version} )); } if (result.key === "daily_profit") { return result.raw.map((item) => ( {item.business_date} - {formatPlainMoney(item.total_bet_minor, displayCurrency)} {formatPlainMoney(item.total_payout_minor, displayCurrency)} {formatPlainMoney(item.approx_house_gross_minor, displayCurrency)} - - - )); } if (result.key === "player_win_loss") { return result.raw.map((item) => ( {item.username} {adminAgentDisplayLabel(item)} ID {item.player_id} {formatPlainMoney(item.total_bet_minor, displayCurrency)} {formatPlainMoney(item.total_payout_minor, displayCurrency)} {formatPlainMoney(item.net_win_loss_minor, displayCurrency)} - - - )); } if (result.key === "play_dimension") { return result.raw.map((item) => ( {playCodeLabel(item.play_code)} {item.dimension}D {formatPlainMoney(item.total_bet_minor, displayCurrency)} {formatPlainMoney(item.total_payout_minor, displayCurrency)} {formatPlainMoney(item.approx_house_gross_minor, displayCurrency)} - - - )); } if (result.key === "rebate_commission") { return result.raw.map((item) => ( {playCodeLabel(item.play_code)} {item.order_count} {formatPlainMoney(item.total_rebate_minor, displayCurrency)} {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 (
{filteredReports.map((report) => { const Icon = report.icon; const active = report.key === selectedReport.key; return ( ); })}
{t(`items.${selectedReport.key}.summary`)}
{selectedReport.fields.map(renderField)}
{(result?.summary ?? defaultSummaryCards(selectedReport.key, filters, t)).map((item) => (
{item.label}
{item.value}
))}
{t("preview.title")}
{previewColumns.primary} {previewColumns.secondary} {previewColumns.metricA} {previewColumns.metricB} {previewColumns.metricC} {previewColumns.status} {previewColumns.extra} {previewColumns.time} {renderTable()}
{result?.meta ? ( { setPerPage(next); setPage(1); }} onPageChange={setPage} /> ) : null}
); }