使用 AdminPermissionGate 包裹集成站点与开奖相关组件,根据权限进行访问控制。 新增集成管理与开奖管理相关权限常量。 更新相关 UI 组件以适配权限校验逻辑,提升系统安全性与用户体验。 增强国际化支持,在英文、尼泊尔语与中文语言包中新增集成相关文案。
1438 lines
58 KiB
TypeScript
1438 lines
58 KiB
TypeScript
"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<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 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 | 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,
|
|
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<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 [jobRefreshToken, setJobRefreshToken] = useState(0);
|
|
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: 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<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);
|
|
}
|
|
|
|
const usesServerExport = REPORT_UI_SERVER_FULL_EXPORT.has(selectedReport.key as ReportUiKey);
|
|
|
|
async function exportViaServer(format: ExportFormat): Promise<void> {
|
|
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 (
|
|
<div className="grid gap-1.5">
|
|
<Label htmlFor={`report-${kind}`}>{t(`fields.${labelKey}`)}</Label>
|
|
<Popover
|
|
open={open}
|
|
onOpenChange={(nextOpen) => {
|
|
setSearch((prev) =>
|
|
nextOpen
|
|
? {
|
|
...prev,
|
|
open: kind,
|
|
query: value,
|
|
}
|
|
: emptySearch,
|
|
);
|
|
}}
|
|
modal={false}
|
|
>
|
|
<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}`)}
|
|
/>
|
|
<PopoverTrigger render={<Button type="button" variant="outline" className="shrink-0" aria-label={t("searchPicker.open")} />}>
|
|
<Search data-icon="inline-start" />
|
|
{t("searchPicker.select")}
|
|
</PopoverTrigger>
|
|
</div>
|
|
<PopoverContent
|
|
align="start"
|
|
side="bottom"
|
|
sideOffset={6}
|
|
className="z-[80] w-[var(--anchor-width)] min-w-[min(24rem,calc(100vw-2rem))] max-w-[calc(100vw-2rem)] p-2"
|
|
>
|
|
<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>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</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-center">{summary.order_count}</TableCell>
|
|
<TableCell className="text-center">{summary.ticket_item_count}</TableCell>
|
|
<TableCell className="text-center">{formatPlainMoney(summary.total_bet_minor, summary.currency_code)}</TableCell>
|
|
<TableCell className="text-center">{formatPlainMoney(summary.total_payout_minor, summary.currency_code)}</TableCell>
|
|
<TableCell className="text-center">{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-center">{batch.total_ticket_count}</TableCell>
|
|
<TableCell className="text-center">{batch.total_win_count}</TableCell>
|
|
<TableCell className="text-center">-</TableCell>
|
|
<TableCell className="text-center">{formatPlainMoney(batch.total_payout_amount, summary.currency_code)}</TableCell>
|
|
<TableCell className="text-center">{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-center">{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-center">{formatPlainMoney(result.raw.pool.total_cap_amount, result.raw.currency_code)}</TableCell>
|
|
<TableCell className="text-center">{formatPlainMoney(result.raw.pool.locked_amount, result.raw.currency_code)}</TableCell>
|
|
<TableCell className="text-center">{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-center">{formatPlainMoney(item.amount, result.raw.currency_code)}</TableCell>
|
|
<TableCell>{playCodeLabel(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-center">{formatPlainMoney(item.total_cap_amount, null)}</TableCell>
|
|
<TableCell className="text-center">{formatPlainMoney(item.locked_amount, null)}</TableCell>
|
|
<TableCell className="text-center">{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-center">{formatPlainMoney(item.total_bet_minor, "NPR")}</TableCell>
|
|
<TableCell className="text-center">{formatPlainMoney(item.total_payout_minor, "NPR")}</TableCell>
|
|
<TableCell className="text-center">{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-center">{formatPlainMoney(item.total_bet_minor, "NPR")}</TableCell>
|
|
<TableCell className="text-center">{formatPlainMoney(item.total_payout_minor, "NPR")}</TableCell>
|
|
<TableCell className="text-center">{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">{playCodeLabel(item.play_code)}</TableCell>
|
|
<TableCell>{item.dimension}D</TableCell>
|
|
<TableCell className="text-center">{formatPlainMoney(item.total_bet_minor, "NPR")}</TableCell>
|
|
<TableCell className="text-center">{formatPlainMoney(item.total_payout_minor, "NPR")}</TableCell>
|
|
<TableCell className="text-center">{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">{playCodeLabel(item.play_code)}</TableCell>
|
|
<TableCell>{item.order_count}</TableCell>
|
|
<TableCell className="text-center">{formatPlainMoney(item.total_rebate_minor, "NPR")}</TableCell>
|
|
<TableCell className="text-center">{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="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-1.5 pt-3">
|
|
{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-center gap-3 rounded-md border px-3 py-2.5 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-medium text-foreground">{t(`items.${report.key}.title`)}</span>
|
|
</span>
|
|
</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>
|
|
</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-end">
|
|
<div className="flex shrink-0 gap-2">
|
|
<Button type="button" variant="outline" onClick={resetFilters}>
|
|
{t("reset")}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
disabled={!canViewReports || !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>
|
|
</div>
|
|
<div className="flex flex-col items-end gap-2">
|
|
<p className="text-xs text-muted-foreground">{t("exportServerHint")}</p>
|
|
<div className="flex flex-wrap justify-end gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
disabled={!canExportReports || exporting !== null}
|
|
onClick={() => exportReport("csv")}
|
|
>
|
|
<FileDown data-icon="inline-start" />
|
|
{t("formats.csvServer")}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
disabled={!canExportReports || exporting !== null}
|
|
onClick={() => exportReport("excel")}
|
|
>
|
|
<FileSpreadsheet data-icon="inline-start" />
|
|
{t("formats.excelServer")}
|
|
</Button>
|
|
</div>
|
|
{result && result.rows.length > 0 ? (
|
|
<>
|
|
<p className="text-xs text-muted-foreground">{t("exportPreviewHint")}</p>
|
|
<div className="flex flex-wrap justify-end gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
disabled={!canExportReports || exporting !== null}
|
|
onClick={() => exportPreview("csv")}
|
|
>
|
|
{t("formats.csvPreview")}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
disabled={!canExportReports || exporting !== null}
|
|
onClick={() => exportPreview("excel")}
|
|
>
|
|
{t("formats.excelPreview")}
|
|
</Button>
|
|
</div>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
</CardHeader>
|
|
<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-center">{t("preview.columns.metricA")}</TableHead>
|
|
<TableHead className="text-center">{t("preview.columns.metricB")}</TableHead>
|
|
<TableHead className="text-center">{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>
|
|
|
|
<ReportJobsPanel canExport={canExportReports} refreshToken={jobRefreshToken} />
|
|
</div>
|
|
);
|
|
}
|