"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 { useAdminPlayCodeLabel, useAdminPlayTypeCatalog } from "@/hooks/use-admin-play-type-catalog"; import { getAdminPlayTypesLoadPromise, getCachedAdminPlayTypes, resolveAdminPlayTypeDisplayName, } from "@/lib/admin-play-types"; 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_SERVER_FULL_EXPORT, REPORT_UI_TO_JOB_TYPE, type ReportUiKey, } from "@/lib/report-export-map"; import { ReportJobsPanel } from "@/modules/reports/report-jobs-panel"; 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 { 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 { 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 { formatAdminInstant } from "@/lib/admin-datetime"; import { getAdminRequestLocale } from "@/lib/admin-locale"; 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; 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 formatExportInstant(iso: string | null | undefined): ExportCell { return formatAdminInstant(iso, { locale: getAdminRequestLocale() }); } function toCsvValue(value: ExportCell): string { if (value == null) { return ""; } const stringValue = String(value); if (/[",\n]/.test(stringValue)) { return `"${stringValue.replace(/"/g, '""')}"`; } return stringValue; } 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 { 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, t: (key: string, options?: { ns?: string; drawNo?: string }) => string, ): 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(t("validation.drawNoRequired", { ns: "reports" }), -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(t("validation.drawNoNotFound", { ns: "reports", 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; } export function ReportsConsole() { 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 [selectedKey, setSelectedKey] = useState(REPORTS[0].key); const [filters, setFilters] = useState(emptyFilters); 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 [jobRefreshToken, setJobRefreshToken] = useState(0); const [search, setSearch] = useState(emptySearch); const [playOptions, setPlayOptions] = useState([]); const selectedReport = REPORTS.find((report) => report.key === selectedKey) ?? REPORTS[0]; 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 loadPlayOptions = useCallback(async () => { try { await getAdminPlayTypesLoadPromise(getAdminPlayTypes); setPlayOptions( getCachedAdminPlayTypes().map((item) => ({ code: item.play_code, label: optionText( resolveAdminPlayTypeDisplayName(item.play_code, i18n.language, item), item.play_code, ), })), ); } catch { setPlayOptions([]); } }, [i18n.language]); useEffect(() => { queueMicrotask(() => { 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 () => { if (!canViewReports) { return; } setLoading(true); setError(null); try { switch (selectedReport.key) { case "draw_profit": { const draw = await resolveDraw(filters, t); 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: formatExportInstant(item.created_at), finished_at: formatExportInstant(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, t); 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: 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: 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, t); 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: 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(t("loadFailed")); } } catch (err) { setResult(null); setError(err instanceof LotteryApiBizError ? err.message : t("loadFailed")); } finally { setLoading(false); } }, [canViewReports, filters, page, perPage, selectedReport, t]); useEffect(() => { queueMicrotask(() => { setResult(null); setError(null); setPage(1); }); }, [selectedKey]); useEffect(() => { if (result && result.key === selectedReport.key && selectedReport.connected) { queueMicrotask(() => { void queryReport(); }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [page, perPage]); function updateFilter(key: K, value: ReportFilters[K]): void { setFilters((prev) => ({ ...prev, [key]: value })); } function resetFilters(): void { setFilters(emptyFilters); setResult(null); setError(null); setPage(1); } const usesServerExport = REPORT_UI_SERVER_FULL_EXPORT.has(selectedReport.key as ReportUiKey); async function exportViaServer(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, }); setJobRefreshToken((n) => n + 1); const { blob, filename } = await downloadAdminReportJob(job.id); const ext = job.export_format === "xlsx" ? "xlsx" : "csv"; downloadBlob(blob, filename ?? `${exportFileBase}.${ext}`); toast.success( t("exportServerSuccess", { report: t(`items.${selectedReport.key}.title`), format: t(`formats.${format}`), jobNo: job.job_no, }), ); } catch (err) { toast.error(err instanceof LotteryApiBizError ? err.message : t("exportFailed")); } finally { setExporting(null); } } function exportPreview(format: ExportFormat): void { if (!canExportReports) { return; } if (!result || result.rows.length === 0) { toast.info(t("empty")); return; } setExporting(format); try { exportRows(result.rows, exportFileBase, t(`items.${selectedReport.key}.title`), format); toast.success(t("exportSuccess", { report: t(`items.${selectedReport.key}.title`), format: t(`formats.${format}`) })); } catch (err) { toast.error(err instanceof LotteryApiBizError ? err.message : t("exportFailed")); } finally { setExporting(null); } } function exportReport(format: ExportFormat): void { if (!canExportReports) { return; } if (usesServerExport) { void exportViaServer(format); return; } exportPreview(format); } const renderSearchPicker = (kind: SearchKind) => { const value = kind === "draw" ? filters.drawNo : kind === "player" ? filters.player : filters.operator; 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 ? (

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

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

{t("exportServerHint")}

{result && result.rows.length > 0 ? ( <>

{t("exportPreviewHint")}

) : null}
{t("preview.columns.primary")} {t("preview.columns.secondary")} {t("preview.columns.metricA")} {t("preview.columns.metricB")} {t("preview.columns.metricC")} {t("preview.columns.status")} {t("preview.columns.extra")} {t("preview.columns.time")} {renderTable()}
{result?.meta ? ( { setPerPage(next); setPage(1); }} onPageChange={setPage} /> ) : null}
); }