feat(admin): 仪表盘重构、代理线路修复与后台体验优化
Some checks failed
lotteryadmin CI / build (push) Has been cancelled
Some checks failed
lotteryadmin CI / build (push) Has been cancelled
- 各角色仪表盘:KPI 可点击跳转、快捷入口、去重冗余区块,代理/站点降级 analytics - 代理线路:创建玩家权限、侧栏搜索、深链 URL、档案保存与删除预检等修复 - 统一密码最短 6 位;代理模块表格/侧栏背景色一致(admin-table-inset) - 报表、钱包、对账、开奖、结算、配置等模块结构与 i18n 同步更新
This commit is contained in:
420
src/modules/reports/report-preview-tables.tsx
Normal file
420
src/modules/reports/report-preview-tables.tsx
Normal file
@@ -0,0 +1,420 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { adminAgentDisplayLabel } from "@/components/admin/admin-agent-columns";
|
||||
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import { DrawStatusBadge } from "@/modules/draws/draw-status-badge";
|
||||
import { drawStatusLabel } from "@/modules/draws/draw-display";
|
||||
import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
formatPlainMoney,
|
||||
formatUsagePercent,
|
||||
optionText,
|
||||
signedProfitCell,
|
||||
type ReportResult,
|
||||
} from "@/modules/reports/reports-utils";
|
||||
import type { ReportDefinition } from "@/modules/reports/reports-definitions";
|
||||
|
||||
type ReportPreviewTableProps = {
|
||||
report: ReportDefinition;
|
||||
result: ReportResult | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
drawNoLabel?: string;
|
||||
displayCurrency: string;
|
||||
};
|
||||
|
||||
export function ReportPreviewTable({
|
||||
report,
|
||||
result,
|
||||
loading,
|
||||
error,
|
||||
drawNoLabel = "",
|
||||
displayCurrency,
|
||||
}: ReportPreviewTableProps) {
|
||||
const { t } = useTranslation(["reports", "common"]);
|
||||
const playCodeLabel = useAdminPlayCodeLabel();
|
||||
const formatTs = useAdminDateTimeFormatter();
|
||||
|
||||
if (!report.connected) {
|
||||
return <AdminNoResourceState message={t("backendPending")} />;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Table>
|
||||
<TableBody>
|
||||
<AdminTableLoadingRow colSpan={8} />
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Table>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-destructive">
|
||||
{error}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
if (!result || result.key !== report.key || result.rows.length === 0) {
|
||||
return (
|
||||
<Table>
|
||||
<TableBody>
|
||||
<AdminTableNoResourceRow colSpan={8} message={t("preview.empty")} />
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
switch (result.key) {
|
||||
case "draw_profit":
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("preview.columns.drawProfit.primary")}</TableHead>
|
||||
<TableHead>{t("preview.columns.drawProfit.secondary")}</TableHead>
|
||||
<TableHead className="text-right">{t("preview.columns.drawProfit.metricA")}</TableHead>
|
||||
<TableHead className="text-right">{t("preview.columns.drawProfit.metricB")}</TableHead>
|
||||
<TableHead className="text-right">{t("preview.columns.drawProfit.metricC")}</TableHead>
|
||||
<TableHead className="text-right">{t("preview.columns.drawProfit.status")}</TableHead>
|
||||
<TableHead className="text-right">{t("preview.columns.drawProfit.extra")}</TableHead>
|
||||
<TableHead>{t("preview.columns.drawProfit.time")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">{result.raw.draw_no}</TableCell>
|
||||
<TableCell>
|
||||
<DrawStatusBadge
|
||||
status={result.raw.draw_status}
|
||||
label={drawStatusLabel(result.raw.draw_status, t)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{result.raw.order_count}</TableCell>
|
||||
<TableCell className="text-right">{result.raw.ticket_item_count}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatPlainMoney(result.raw.total_bet_minor, result.raw.currency_code)}
|
||||
</TableCell>
|
||||
<TableCell className={signedProfitCell(result.raw.approx_house_gross_minor, result.raw.currency_code)}>
|
||||
{formatPlainMoney(result.raw.approx_house_gross_minor, result.raw.currency_code)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatPlainMoney(result.raw.total_payout_minor, result.raw.currency_code)}
|
||||
</TableCell>
|
||||
<TableCell>—</TableCell>
|
||||
</TableRow>
|
||||
{result.raw.settlement_batches.length > 0 ? (
|
||||
<TableRow className="bg-muted/20">
|
||||
<TableCell colSpan={8} className="py-2 text-xs font-medium text-muted-foreground">
|
||||
{t("preview.sections.settlementBatches")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
{result.raw.settlement_batches.map((batch) => (
|
||||
<TableRow key={batch.id} className="bg-muted/10">
|
||||
<TableCell>#{batch.id}</TableCell>
|
||||
<TableCell>
|
||||
<AdminStatusBadge status={batch.status}>{batch.status}</AdminStatusBadge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{batch.total_ticket_count}</TableCell>
|
||||
<TableCell className="text-right">{batch.total_win_count}</TableCell>
|
||||
<TableCell className="text-right">—</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatPlainMoney(batch.total_payout_amount, result.raw.currency_code)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatPlainMoney(batch.total_jackpot_payout_amount, result.raw.currency_code)}
|
||||
</TableCell>
|
||||
<TableCell>{formatTs(batch.finished_at)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
|
||||
case "daily_profit":
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("preview.columns.dailyProfit.primary")}</TableHead>
|
||||
<TableHead className="text-right">{t("preview.columns.dailyProfit.metricA")}</TableHead>
|
||||
<TableHead className="text-right">{t("preview.columns.dailyProfit.metricB")}</TableHead>
|
||||
<TableHead className="text-right">{t("preview.columns.dailyProfit.metricC")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{result.raw.map((item) => (
|
||||
<TableRow key={item.business_date}>
|
||||
<TableCell className="font-medium">{item.business_date}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatPlainMoney(item.total_bet_minor, displayCurrency)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatPlainMoney(item.total_payout_minor, displayCurrency)}
|
||||
</TableCell>
|
||||
<TableCell className={signedProfitCell(item.approx_house_gross_minor, displayCurrency)}>
|
||||
{formatPlainMoney(item.approx_house_gross_minor, displayCurrency)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
|
||||
case "player_win_loss":
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("preview.columns.playerWinLoss.primary")}</TableHead>
|
||||
<TableHead>{t("preview.columns.playerWinLoss.secondary")}</TableHead>
|
||||
<TableHead className="text-right">{t("preview.columns.playerWinLoss.metricA")}</TableHead>
|
||||
<TableHead className="text-right">{t("preview.columns.playerWinLoss.metricB")}</TableHead>
|
||||
<TableHead className="text-right">{t("preview.columns.playerWinLoss.metricC")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{result.raw.map((item) => (
|
||||
<TableRow key={item.player_id}>
|
||||
<TableCell className="font-medium">{item.username}</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{adminAgentDisplayLabel(item)}
|
||||
<span className="mt-0.5 block text-muted-foreground">ID {item.player_id}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatPlainMoney(item.total_bet_minor, displayCurrency)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatPlainMoney(item.total_payout_minor, displayCurrency)}
|
||||
</TableCell>
|
||||
<TableCell className={signedProfitCell(item.net_win_loss_minor, displayCurrency)}>
|
||||
{formatPlainMoney(item.net_win_loss_minor, displayCurrency)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
|
||||
case "play_dimension":
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("preview.columns.playDimension.primary")}</TableHead>
|
||||
<TableHead>{t("preview.columns.playDimension.secondary")}</TableHead>
|
||||
<TableHead className="text-right">{t("preview.columns.playDimension.metricA")}</TableHead>
|
||||
<TableHead className="text-right">{t("preview.columns.playDimension.metricB")}</TableHead>
|
||||
<TableHead className="text-right">{t("preview.columns.playDimension.metricC")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{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-right">
|
||||
{formatPlainMoney(item.total_bet_minor, displayCurrency)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatPlainMoney(item.total_payout_minor, displayCurrency)}
|
||||
</TableCell>
|
||||
<TableCell className={signedProfitCell(item.approx_house_gross_minor, displayCurrency)}>
|
||||
{formatPlainMoney(item.approx_house_gross_minor, displayCurrency)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
|
||||
case "player_transfer":
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("preview.columns.playerTransfer.primary")}</TableHead>
|
||||
<TableHead>{t("preview.columns.playerTransfer.secondary")}</TableHead>
|
||||
<TableHead>{t("preview.columns.playerTransfer.metricA")}</TableHead>
|
||||
<TableHead>{t("preview.columns.playerTransfer.metricB")}</TableHead>
|
||||
<TableHead className="text-right">{t("preview.columns.playerTransfer.metricC")}</TableHead>
|
||||
<TableHead>{t("preview.columns.playerTransfer.status")}</TableHead>
|
||||
<TableHead>{t("preview.columns.playerTransfer.time")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{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>
|
||||
<AdminStatusBadge status={item.status}>{item.status}</AdminStatusBadge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{item.currency_code} {item.amount}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[12rem] truncate">{item.external_ref_no || "—"}</TableCell>
|
||||
<TableCell>{formatTs(item.created_at)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
|
||||
case "hot_number_risk":
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("preview.columns.hotNumberRisk.primary")}</TableHead>
|
||||
<TableHead>{t("preview.columns.hotNumberRisk.secondary")}</TableHead>
|
||||
<TableHead className="text-right">{t("preview.columns.hotNumberRisk.metricA")}</TableHead>
|
||||
<TableHead className="text-right">{t("preview.columns.hotNumberRisk.metricB")}</TableHead>
|
||||
<TableHead className="text-right">{t("preview.columns.hotNumberRisk.metricC")}</TableHead>
|
||||
<TableHead>{t("preview.columns.hotNumberRisk.status")}</TableHead>
|
||||
<TableHead>{t("preview.columns.hotNumberRisk.extra")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">{result.raw.pool.normalized_number}</TableCell>
|
||||
<TableCell>{result.raw.draw_no}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatPlainMoney(result.raw.pool.total_cap_amount, result.raw.currency_code)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatPlainMoney(result.raw.pool.locked_amount, result.raw.currency_code)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatPlainMoney(result.raw.pool.remaining_amount, result.raw.currency_code)}
|
||||
</TableCell>
|
||||
<TableCell>{result.raw.pool.is_sold_out ? t("yes") : t("no")}</TableCell>
|
||||
<TableCell>{formatUsagePercent(result.raw.pool.usage_ratio)}</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
{result.raw.logs.items.length > 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("preview.sections.lockLogs")}</TableHead>
|
||||
<TableHead>{t("preview.columns.hotNumberRisk.secondary")}</TableHead>
|
||||
<TableHead className="text-right">{t("preview.columns.hotNumberRisk.metricA")}</TableHead>
|
||||
<TableHead>{t("preview.columns.hotNumberRisk.metricB")}</TableHead>
|
||||
<TableHead>{t("preview.columns.hotNumberRisk.metricC")}</TableHead>
|
||||
<TableHead>{t("preview.columns.hotNumberRisk.time")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{result.raw.logs.items.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="font-mono text-xs">#{item.id}</TableCell>
|
||||
<TableCell>{item.action_type}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatPlainMoney(item.amount, result.raw.currency_code)}
|
||||
</TableCell>
|
||||
<TableCell>{playCodeLabel(item.play_code)}</TableCell>
|
||||
<TableCell>{item.ticket_no || "—"}</TableCell>
|
||||
<TableCell>{formatTs(item.created_at)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "sold_out_number":
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("preview.columns.soldOut.primary")}</TableHead>
|
||||
<TableHead>{t("preview.columns.soldOut.secondary")}</TableHead>
|
||||
<TableHead className="text-right">{t("preview.columns.soldOut.metricA")}</TableHead>
|
||||
<TableHead className="text-right">{t("preview.columns.soldOut.metricB")}</TableHead>
|
||||
<TableHead className="text-right">{t("preview.columns.soldOut.metricC")}</TableHead>
|
||||
<TableHead>{t("preview.columns.soldOut.status")}</TableHead>
|
||||
<TableHead>{t("preview.columns.soldOut.extra")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{result.raw.map((item) => (
|
||||
<TableRow key={item.normalized_number}>
|
||||
<TableCell className="font-medium">{item.normalized_number}</TableCell>
|
||||
<TableCell>{drawNoLabel}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatPlainMoney(item.total_cap_amount, displayCurrency)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatPlainMoney(item.locked_amount, displayCurrency)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatPlainMoney(item.remaining_amount, displayCurrency)}
|
||||
</TableCell>
|
||||
<TableCell>{item.is_sold_out ? t("yes") : t("no")}</TableCell>
|
||||
<TableCell>{formatUsagePercent(item.usage_ratio)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
|
||||
case "admin_audit":
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("preview.columns.adminAudit.primary")}</TableHead>
|
||||
<TableHead>{t("preview.columns.adminAudit.secondary")}</TableHead>
|
||||
<TableHead>{t("preview.columns.adminAudit.metricA")}</TableHead>
|
||||
<TableHead>{t("preview.columns.adminAudit.metricB")}</TableHead>
|
||||
<TableHead>{t("preview.columns.adminAudit.metricC")}</TableHead>
|
||||
<TableHead>{t("preview.columns.adminAudit.status")}</TableHead>
|
||||
<TableHead>{t("preview.columns.adminAudit.time")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{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>{formatTs(item.created_at)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
|
||||
default:
|
||||
return <AdminLoadingInline />;
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
CalendarDays,
|
||||
Database,
|
||||
FileDown,
|
||||
FileSpreadsheet,
|
||||
ListFilter,
|
||||
Search,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
Ticket,
|
||||
Users,
|
||||
WalletCards,
|
||||
} from "lucide-react";
|
||||
import { ArrowUpRight, Database, FileDown, FileSpreadsheet, Search } from "lucide-react";
|
||||
|
||||
import { getAdminAuditLogs } from "@/api/admin-audit";
|
||||
import { useAdminPlayCodeLabel, useAdminPlayTypeCatalog } from "@/hooks/use-admin-play-type-catalog";
|
||||
import { 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 { getAdminDrawFinanceSummary } from "@/api/admin-draws";
|
||||
import { DrawStatusBadge } from "@/modules/draws/draw-status-badge";
|
||||
import { drawStatusLabel } from "@/modules/draws/draw-display";
|
||||
import { getAdminPlayers } from "@/api/admin-player";
|
||||
@@ -42,25 +30,17 @@ 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_AUDIT_VIEW,
|
||||
PRD_REPORT_EXPORT,
|
||||
PRD_REPORT_VIEW,
|
||||
PRD_RISK_ACCESS_ANY,
|
||||
PRD_WALLET_TRANSFER_ACCESS_ANY,
|
||||
} from "@/lib/admin-prd";
|
||||
import { PRD_REPORT_EXPORT } 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 { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { AdminNoResourceState } 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 { AdminSubnav, AdminSubnavLink } from "@/components/admin/admin-subnav";
|
||||
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 { AdminLoadingInline } from "@/components/admin/admin-loading-state";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -68,559 +48,87 @@ import {
|
||||
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 { AdminMoneyDisplay } from "@/components/admin/admin-money-display";
|
||||
import { signedMoneyClass } from "@/lib/admin-signed-money";
|
||||
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
||||
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,
|
||||
} from "@/types/api/admin-reports";
|
||||
import { getAdminDraws } from "@/api/admin-draws";
|
||||
|
||||
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<string, ExportCell>;
|
||||
type SearchKind = "draw" | "player" | "operator";
|
||||
import { ReportPreviewTable } from "@/modules/reports/report-preview-tables";
|
||||
import {
|
||||
CATEGORY_SHORTCUT_HREF,
|
||||
REPORT_CATEGORY_ORDER,
|
||||
REPORT_DEFINITIONS,
|
||||
type FieldKey,
|
||||
type ReportCategory,
|
||||
type ReportKey,
|
||||
} from "@/modules/reports/reports-definitions";
|
||||
import {
|
||||
buildDailyProfitRowsAndSummary,
|
||||
buildPlayDimensionRowsAndSummary,
|
||||
buildPlayerTransferRowsAndSummary,
|
||||
createDefaultFilters,
|
||||
downloadBlob,
|
||||
drawRowsFromSummary,
|
||||
emptyFilters,
|
||||
emptySearch,
|
||||
formatExportInstant,
|
||||
formatPlainMoney,
|
||||
formatUsagePercent,
|
||||
headlineStats,
|
||||
metaFromList,
|
||||
normalizeFilenamePart,
|
||||
optionText,
|
||||
parsePositiveInteger,
|
||||
reportHasPeriodField,
|
||||
reportListParams,
|
||||
resolveDisplayCurrency,
|
||||
resolveDraw,
|
||||
type ExportFormat,
|
||||
type ReportFilters,
|
||||
type ReportResult,
|
||||
type SearchKind,
|
||||
type SearchState,
|
||||
} from "@/modules/reports/reports-utils";
|
||||
|
||||
type ReportKey =
|
||||
| "draw_profit"
|
||||
| "daily_profit"
|
||||
| "player_win_loss"
|
||||
| "player_transfer"
|
||||
| "hot_number_risk"
|
||||
| "play_dimension"
|
||||
| "sold_out_number"
|
||||
| "admin_audit";
|
||||
export type { ReportCategory } from "@/modules/reports/reports-definitions";
|
||||
|
||||
type ReportDefinition = {
|
||||
key: ReportKey;
|
||||
category: ReportCategory;
|
||||
icon: typeof FileSpreadsheet;
|
||||
filterKind: FilterKind;
|
||||
scope: string;
|
||||
fields: FieldKey[];
|
||||
connected: boolean;
|
||||
requiredAny: readonly string[];
|
||||
type ReportsConsoleProps = {
|
||||
initialCategory?: ReportCategory;
|
||||
};
|
||||
|
||||
const PRD_REPORTS_VIEW_ACCESS_ANY = [PRD_REPORT_VIEW] as const;
|
||||
|
||||
const REPORTS: ReportDefinition[] = [
|
||||
{ key: "draw_profit", category: "profit", icon: Ticket, filterKind: "draw", scope: "drawNo", fields: ["drawNo"], connected: true, requiredAny: PRD_REPORTS_VIEW_ACCESS_ANY },
|
||||
{ key: "daily_profit", category: "profit", icon: CalendarDays, filterKind: "date", scope: "date", fields: ["period"], connected: true, requiredAny: PRD_REPORTS_VIEW_ACCESS_ANY },
|
||||
{ key: "player_win_loss", category: "profit", icon: Users, filterKind: "player_period", scope: "playerPeriod", fields: ["player", "period"], connected: true, requiredAny: PRD_REPORTS_VIEW_ACCESS_ANY },
|
||||
{ key: "player_transfer", category: "wallet", icon: WalletCards, filterKind: "player_period", scope: "playerPeriod", fields: ["player", "period"], connected: true, requiredAny: PRD_WALLET_TRANSFER_ACCESS_ANY },
|
||||
{ key: "hot_number_risk", category: "risk", icon: ShieldAlert, filterKind: "draw_number", scope: "drawNumber", fields: ["drawNo", "number"], connected: true, requiredAny: PRD_RISK_ACCESS_ANY },
|
||||
{ key: "play_dimension", category: "profit", icon: ListFilter, filterKind: "play_period", scope: "playPeriod", fields: ["play", "period"], connected: true, requiredAny: PRD_REPORTS_VIEW_ACCESS_ANY },
|
||||
{ key: "sold_out_number", category: "risk", icon: ShieldCheck, filterKind: "draw", scope: "drawNo", fields: ["drawNo"], connected: true, requiredAny: PRD_RISK_ACCESS_ANY },
|
||||
{ key: "admin_audit", category: "audit", icon: FileSpreadsheet, filterKind: "operator_period", scope: "operatorPeriod", fields: ["operator", "period"], connected: true, requiredAny: [PRD_AUDIT_VIEW] },
|
||||
];
|
||||
|
||||
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: "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 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<ReportFilters, "dateFrom" | "dateTo"> {
|
||||
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_enabled && row.is_bettable)?.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<Extract<ReportResult, { key: "daily_profit" }>, "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<Extract<ReportResult, { key: "player_transfer" }>, "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<Extract<ReportResult, { key: "play_dimension" }>, "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 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 | 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,
|
||||
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 "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"]);
|
||||
export function ReportsConsole({ initialCategory = "profit" }: ReportsConsoleProps) {
|
||||
const { t } = useTranslation(["reports", "common"]);
|
||||
const profile = useAdminProfile();
|
||||
const canViewReports = adminHasAnyPermission(profile?.permissions, [PRD_REPORT_VIEW]);
|
||||
const canExportReports = adminHasAnyPermission(profile?.permissions, [PRD_REPORT_EXPORT]);
|
||||
const permissionSlugs = useMemo(() => profile?.permissions ?? [], [profile?.permissions]);
|
||||
const canExportReports = adminHasAnyPermission(permissionSlugs, [PRD_REPORT_EXPORT]);
|
||||
|
||||
useAdminCurrencyCatalog();
|
||||
useAdminPlayTypeCatalog();
|
||||
const playCodeLabel = useAdminPlayCodeLabel();
|
||||
const formatTs = useAdminDateTimeFormatter();
|
||||
const filteredReports = useMemo(() => {
|
||||
const visible = REPORTS.filter((report) => adminHasAnyPermission(permissionSlugs, report.requiredAny));
|
||||
return initialCategory ? visible.filter((report) => report.category === initialCategory) : visible;
|
||||
}, [initialCategory, permissionSlugs]);
|
||||
const [selectedKey, setSelectedKey] = useState<ReportKey>(
|
||||
filteredReports[0]?.key ?? REPORTS[0].key,
|
||||
const playOptions = useCachedPlayTypeOptions();
|
||||
const tRef = useTranslationRef(["reports", "common"]);
|
||||
const searchParams = useSearchParams();
|
||||
const drawNoFromUrl = (searchParams.get("draw_no") ?? "").trim();
|
||||
|
||||
const visibleReports = useMemo(
|
||||
() => REPORT_DEFINITIONS.filter((report) => adminHasAnyPermission(permissionSlugs, report.requiredAny)),
|
||||
[permissionSlugs],
|
||||
);
|
||||
|
||||
const categoriesWithReports = useMemo(
|
||||
() => REPORT_CATEGORY_ORDER.filter((cat) => visibleReports.some((report) => report.category === cat)),
|
||||
[visibleReports],
|
||||
);
|
||||
|
||||
const activeCategory = categoriesWithReports.includes(initialCategory)
|
||||
? initialCategory
|
||||
: (categoriesWithReports[0] ?? "profit");
|
||||
|
||||
const categoryReports = useMemo(
|
||||
() => visibleReports.filter((report) => report.category === activeCategory),
|
||||
[activeCategory, visibleReports],
|
||||
);
|
||||
|
||||
const [selectedKey, setSelectedKey] = useState<ReportKey>(categoryReports[0]?.key ?? "daily_profit");
|
||||
const [filters, setFilters] = useState<ReportFilters>(createDefaultFilters);
|
||||
const [displayCurrency, setDisplayCurrency] = useState<string>(() => resolveDisplayCurrency(null));
|
||||
const [result, setResult] = useState<ReportResult | null>(null);
|
||||
@@ -630,128 +138,26 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
const [perPage, setPerPage] = useState(20);
|
||||
const [exporting, setExporting] = useState<ExportFormat | null>(null);
|
||||
const [search, setSearch] = useState<SearchState>(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];
|
||||
const selectedReport =
|
||||
categoryReports.find((report) => report.key === selectedKey) ?? categoryReports[0] ?? REPORT_DEFINITIONS[0];
|
||||
|
||||
const canQuerySelected = adminHasAnyPermission(permissionSlugs, selectedReport.requiredAny);
|
||||
|
||||
useEffect(() => {
|
||||
if (!filteredReports.some((report) => report.key === selectedKey)) {
|
||||
setSelectedKey(filteredReports[0]?.key ?? REPORTS[0].key);
|
||||
if (categoryReports.length === 0) {
|
||||
return;
|
||||
}
|
||||
}, [filteredReports, selectedKey]);
|
||||
if (!categoryReports.some((report) => report.key === selectedKey)) {
|
||||
setSelectedKey(categoryReports[0].key);
|
||||
}
|
||||
}, [categoryReports, selectedKey]);
|
||||
|
||||
const pageScopedLabel = useCallback(
|
||||
(statKey: string) => t(`preview.stats.${statKey}`),
|
||||
[t],
|
||||
);
|
||||
|
||||
const previewColumns = useMemo<PreviewColumns>(() => {
|
||||
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 "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());
|
||||
@@ -795,7 +201,7 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
}, [search.open, search.query, loadSearchOptions]);
|
||||
|
||||
const queryReport = useCallback(async () => {
|
||||
if (!canViewReports) {
|
||||
if (!canQuerySelected) {
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
@@ -816,8 +222,6 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
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),
|
||||
@@ -829,12 +233,16 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
break;
|
||||
}
|
||||
case "daily_profit": {
|
||||
const payload = await getAdminReportDailyProfit(
|
||||
reportListParams(filters, page, perPage),
|
||||
);
|
||||
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);
|
||||
const next = buildDailyProfitRowsAndSummary(
|
||||
payload.items,
|
||||
payload.meta.total,
|
||||
t,
|
||||
pageScopedLabel,
|
||||
currencyCode,
|
||||
);
|
||||
setResult({
|
||||
key: "daily_profit",
|
||||
raw: payload.items,
|
||||
@@ -845,38 +253,28 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
break;
|
||||
}
|
||||
case "player_win_loss": {
|
||||
const payload = await getAdminReportPlayerWinLoss(
|
||||
reportListParams(filters, page, perPage),
|
||||
);
|
||||
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,
|
||||
}));
|
||||
const houseGross = payload.items.reduce((sum, item) => sum - item.net_win_loss_minor, 0);
|
||||
setResult({
|
||||
key: "player_win_loss",
|
||||
raw: payload.items,
|
||||
rows,
|
||||
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,
|
||||
})),
|
||||
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";
|
||||
})(),
|
||||
value: formatPlainMoney(houseGross, currencyCode),
|
||||
tone: houseGross >= 0 ? "good" : "bad",
|
||||
},
|
||||
{ label: t("preview.stats.players"), value: String(new Set(payload.items.map((item) => item.player_id)).size) },
|
||||
],
|
||||
});
|
||||
break;
|
||||
@@ -897,7 +295,6 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
payload.page,
|
||||
payload.per_page,
|
||||
t,
|
||||
pageScopedLabel,
|
||||
);
|
||||
setDisplayCurrency(resolveDisplayCurrency(payload.items[0]?.currency_code));
|
||||
setResult({
|
||||
@@ -920,45 +317,17 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
});
|
||||
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,
|
||||
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.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) },
|
||||
],
|
||||
});
|
||||
@@ -970,42 +339,34 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
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" });
|
||||
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,
|
||||
rows: payload.items.map((item) => ({ normalized_number: item.normalized_number })),
|
||||
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.records"),
|
||||
value: String(payload.meta.total),
|
||||
tone: payload.meta.total > 0 ? "bad" : "good",
|
||||
},
|
||||
{ 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 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);
|
||||
const next = buildPlayDimensionRowsAndSummary(payload.items, payload.meta.total, t, currencyCode);
|
||||
setResult({
|
||||
key: "play_dimension",
|
||||
raw: payload.items,
|
||||
@@ -1025,28 +386,17 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
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,
|
||||
rows: payload.items.map((item) => ({ id: item.id })),
|
||||
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) },
|
||||
{
|
||||
label: t("preview.stats.operators"),
|
||||
value: String(new Set(payload.items.map((item) => item.operator_id)).size),
|
||||
},
|
||||
],
|
||||
});
|
||||
break;
|
||||
@@ -1061,14 +411,12 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [canViewReports, filters, page, perPage, selectedReport]);
|
||||
}, [canQuerySelected, filters, page, perPage, pageScopedLabel, selectedReport.key, t, tRef]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
setResult(null);
|
||||
setError(null);
|
||||
setPage(1);
|
||||
});
|
||||
setResult(null);
|
||||
setError(null);
|
||||
setPage(1);
|
||||
}, [selectedKey]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1076,25 +424,16 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
...prev,
|
||||
drawNo: drawNoFromUrl || prev.drawNo,
|
||||
}));
|
||||
if (drawNoFromUrl && filteredReports.some((report) => report.key === "draw_profit")) {
|
||||
if (drawNoFromUrl && visibleReports.some((report) => report.key === "draw_profit")) {
|
||||
setSelectedKey("draw_profit");
|
||||
}
|
||||
}, [drawNoFromUrl, filteredReports]);
|
||||
}, [drawNoFromUrl, visibleReports]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
setResult(null);
|
||||
setError(null);
|
||||
setPage(1);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (result && result.key === selectedReport.key && selectedReport.connected) {
|
||||
queueMicrotask(() => {
|
||||
void queryReport();
|
||||
});
|
||||
if (!result || result.key !== selectedReport.key || !selectedReport.connected) {
|
||||
return;
|
||||
}
|
||||
void queryReport();
|
||||
}, [page, perPage]);
|
||||
|
||||
function updateFilter<K extends keyof ReportFilters>(key: K, value: ReportFilters[K]): void {
|
||||
@@ -1152,42 +491,48 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
const open = search.open === kind;
|
||||
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor={`report-${kind}`}>{t(`fields.${labelKey}`)}</Label>
|
||||
<div className="min-w-[12rem] flex-1">
|
||||
<Label className="sr-only" htmlFor={`report-${kind}`}>
|
||||
{t(`fields.${labelKey}`)}
|
||||
</Label>
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
setSearch((prev) =>
|
||||
nextOpen
|
||||
? {
|
||||
...prev,
|
||||
open: kind,
|
||||
query: value,
|
||||
}
|
||||
: emptySearch,
|
||||
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 }));
|
||||
<div className="flex gap-1.5">
|
||||
<Input
|
||||
id={`report-${kind}`}
|
||||
className="h-8"
|
||||
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"
|
||||
size="sm"
|
||||
className="h-8 shrink-0 px-2"
|
||||
aria-label={t("searchPicker.open")}
|
||||
/>
|
||||
}
|
||||
}}
|
||||
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")}
|
||||
>
|
||||
<Search className="size-3.5" />
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
<PopoverContent
|
||||
@@ -1202,65 +547,70 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
onChange={(e) => setSearch((prev) => ({ ...prev, query: e.target.value }))}
|
||||
/>
|
||||
<div className="mt-2 max-h-64 overflow-auto">
|
||||
{search.loading ? (
|
||||
<AdminLoadingInline className="py-2" />
|
||||
) : 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>
|
||||
<DrawStatusBadge status={item.status} label={drawStatusLabel(item.status, t)} />
|
||||
</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}
|
||||
{search.loading ? <AdminLoadingInline className="py-2" /> : 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>
|
||||
<DrawStatusBadge
|
||||
status={item.status}
|
||||
label={drawStatusLabel(item.status, t)}
|
||||
/>
|
||||
</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>
|
||||
@@ -1294,15 +644,21 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
}
|
||||
if (field === "play") {
|
||||
return (
|
||||
<div key={field} className="grid gap-1.5">
|
||||
<Label htmlFor="report-play">{t("fields.play")}</Label>
|
||||
<div key={field} className="min-w-[10rem] flex-1">
|
||||
<Label className="sr-only" 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>
|
||||
<SelectValue>
|
||||
{filters.play
|
||||
? (playOptions.find((item) => item.code === filters.play)?.label ?? filters.play)
|
||||
: t("filterAll")}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start" sideOffset={6}>
|
||||
<SelectItem value="__none__">{t("filterAll")}</SelectItem>
|
||||
@@ -1316,12 +672,14 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={field} className="grid gap-1.5">
|
||||
<Label htmlFor={`report-${field}`}>{t(`fields.${field}`)}</Label>
|
||||
<div key={field} className="min-w-[8rem] flex-1">
|
||||
<Label className="sr-only" htmlFor={`report-${field}`}>
|
||||
{t(`fields.${field}`)}
|
||||
</Label>
|
||||
<Input
|
||||
id={`report-${field}`}
|
||||
className="h-8"
|
||||
value={filters.number}
|
||||
onChange={(e) => updateFilter("number", e.target.value)}
|
||||
placeholder={t(`placeholders.${field}`)}
|
||||
@@ -1330,321 +688,141 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
);
|
||||
};
|
||||
|
||||
const renderTable = () => {
|
||||
if (!selectedReport.connected) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-muted-foreground">
|
||||
{t("backendPending")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
if (loading) {
|
||||
return (
|
||||
<AdminTableLoadingRow colSpan={8} />
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-destructive">
|
||||
{error}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
if (!result || result.rows.length === 0) {
|
||||
return (
|
||||
<AdminTableNoResourceRow colSpan={8} />
|
||||
);
|
||||
}
|
||||
const stats = headlineStats(result, selectedReport.key);
|
||||
const shortcutHref = CATEGORY_SHORTCUT_HREF[activeCategory];
|
||||
|
||||
if (result.key === "draw_profit") {
|
||||
const summary = result.raw;
|
||||
return (
|
||||
<>
|
||||
<TableRow>
|
||||
<TableCell className="font-medium">{summary.draw_no}</TableCell>
|
||||
<TableCell>
|
||||
<DrawStatusBadge status={summary.draw_status} label={drawStatusLabel(summary.draw_status, t)} />
|
||||
</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={signedProfitCell(summary.approx_house_gross_minor, summary.currency_code)}>
|
||||
{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>
|
||||
<AdminStatusBadge status={batch.status}>{batch.status}</AdminStatusBadge>
|
||||
</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>
|
||||
<AdminStatusBadge status={item.status}>{item.status}</AdminStatusBadge>
|
||||
</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>{formatUsagePercent(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, displayCurrency)}</TableCell>
|
||||
<TableCell className="text-center">{formatPlainMoney(item.locked_amount, displayCurrency)}</TableCell>
|
||||
<TableCell className="text-center">{formatPlainMoney(item.remaining_amount, displayCurrency)}</TableCell>
|
||||
<TableCell>{item.is_sold_out ? t("yes") : t("no")}</TableCell>
|
||||
<TableCell>{formatUsagePercent(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, displayCurrency)}</TableCell>
|
||||
<TableCell className="text-center">{formatPlainMoney(item.total_payout_minor, displayCurrency)}</TableCell>
|
||||
<TableCell className={signedProfitCell(item.approx_house_gross_minor, displayCurrency)}>
|
||||
{formatPlainMoney(item.approx_house_gross_minor, displayCurrency)}
|
||||
</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 className="text-xs">
|
||||
{adminAgentDisplayLabel(item)}
|
||||
<span className="mt-0.5 block text-muted-foreground">ID {item.player_id}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{formatPlainMoney(item.total_bet_minor, displayCurrency)}</TableCell>
|
||||
<TableCell className="text-center">{formatPlainMoney(item.total_payout_minor, displayCurrency)}</TableCell>
|
||||
<TableCell className={signedProfitCell(item.net_win_loss_minor, displayCurrency)}>
|
||||
{formatPlainMoney(item.net_win_loss_minor, displayCurrency)}
|
||||
</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, displayCurrency)}</TableCell>
|
||||
<TableCell className="text-center">{formatPlainMoney(item.total_payout_minor, displayCurrency)}</TableCell>
|
||||
<TableCell className={signedProfitCell(item.approx_house_gross_minor, displayCurrency)}>
|
||||
{formatPlainMoney(item.approx_house_gross_minor, displayCurrency)}
|
||||
</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;
|
||||
};
|
||||
if (visibleReports.length === 0) {
|
||||
return <AdminNoResourceState message={t("empty")} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4">
|
||||
{filteredReports.length === 0 ? (
|
||||
<AdminNoResourceState message={t("empty")} />
|
||||
) : (
|
||||
<>
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header pb-3">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{filteredReports.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(
|
||||
"inline-flex min-w-0 items-center gap-2 rounded-md border px-2.5 py-1.5 text-sm transition",
|
||||
active
|
||||
? "border-primary bg-primary/[0.06] text-primary shadow-sm"
|
||||
: "border-border/80 bg-card text-muted-foreground hover:border-primary/35 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<span className={cn("flex size-6 shrink-0 items-center justify-center rounded-md border", categoryTone(report.category))}>
|
||||
<Icon className="size-3.5" aria-hidden />
|
||||
</span>
|
||||
<span className="truncate">{t(`items.${report.key}.title`)}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">{t(`items.${selectedReport.key}.summary`)}</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 pt-0">
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
{selectedReport.fields.map(renderField)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 border-t border-border/60 pt-3">
|
||||
<Button type="button" variant="outline" size="sm" onClick={resetFilters}>
|
||||
{t("reset")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={!canViewReports || !selectedReport.connected || loading}
|
||||
onClick={() => {
|
||||
setPage(1);
|
||||
void queryReport();
|
||||
}}
|
||||
>
|
||||
<Database data-icon="inline-start" />
|
||||
{loading ? t("querying") : t("query")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid min-w-0 gap-2 md:grid-cols-4">
|
||||
{(result?.summary ?? defaultSummaryCards(selectedReport.key, filters, t)).map((item) => (
|
||||
<div key={item.label} className={cn("min-w-0 rounded-md border px-3 py-2.5", statTone(item.tone))}>
|
||||
<div className="text-xs text-muted-foreground">{item.label}</div>
|
||||
<AdminMoneyDisplay as="div" value={item.value} size="md" className="mt-0.5">
|
||||
{item.value}
|
||||
</AdminMoneyDisplay>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<AdminSubnav aria-label={t("categories.all")}>
|
||||
{categoriesWithReports.map((category) => (
|
||||
<AdminSubnavLink
|
||||
key={category}
|
||||
href={`/admin/reports/${category}`}
|
||||
active={activeCategory === category}
|
||||
>
|
||||
{t(`categories.${category}`)}
|
||||
</AdminSubnavLink>
|
||||
))}
|
||||
</AdminSubnav>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select
|
||||
value={selectedReport.key}
|
||||
onValueChange={(value) => {
|
||||
if (value) {
|
||||
setSelectedKey(value as ReportKey);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-full min-w-[12rem] max-w-md">
|
||||
<SelectValue>{t(`items.${selectedReport.key}.title`)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categoryReports.map((report) => (
|
||||
<SelectItem key={report.key} value={report.key}>
|
||||
{t(`items.${report.key}.title`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{shortcutHref ? (
|
||||
<Link
|
||||
href={shortcutHref}
|
||||
className="inline-flex h-9 items-center gap-1 rounded-md px-2.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
{t(`shortcuts.${activeCategory}`)}
|
||||
<ArrowUpRight className="size-3.5" />
|
||||
</Link>
|
||||
) : null}
|
||||
{activeCategory === "profit" ? (
|
||||
<span className="text-xs text-muted-foreground">{t("profitScopeHint")}</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header flex flex-col gap-2 pb-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<CardTitle className="admin-list-title">{t("preview.title")}</CardTitle>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<div className="rounded-lg border border-border/60 p-3">
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
{selectedReport.fields.map(renderField)}
|
||||
<div className="flex flex-wrap items-center gap-1.5 sm:ml-auto">
|
||||
<Button type="button" variant="ghost" size="sm" className="h-8" onClick={resetFilters}>
|
||||
{t("reset")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
disabled={!canQuerySelected || !selectedReport.connected || loading}
|
||||
onClick={() => {
|
||||
setPage(1);
|
||||
void queryReport();
|
||||
}}
|
||||
>
|
||||
<Database className="size-3.5" data-icon="inline-start" />
|
||||
{loading ? t("querying") : t("query")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
disabled={!canExportReports || exporting !== null}
|
||||
onClick={() => void exportReport("csv")}
|
||||
>
|
||||
<FileDown data-icon="inline-start" />
|
||||
<FileDown className="size-3.5" data-icon="inline-start" />
|
||||
{t("formats.csv")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
disabled={!canExportReports || exporting !== null}
|
||||
onClick={() => void exportReport("excel")}
|
||||
>
|
||||
<FileSpreadsheet data-icon="inline-start" />
|
||||
<FileSpreadsheet className="size-3.5" data-icon="inline-start" />
|
||||
{t("formats.excel")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 pt-3">
|
||||
<Table id="reports-preview-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{previewColumns.primary}</TableHead>
|
||||
<TableHead>{previewColumns.secondary}</TableHead>
|
||||
<TableHead className="text-center">{previewColumns.metricA}</TableHead>
|
||||
<TableHead className="text-center">{previewColumns.metricB}</TableHead>
|
||||
<TableHead className="text-center">{previewColumns.metricC}</TableHead>
|
||||
<TableHead>{previewColumns.status}</TableHead>
|
||||
<TableHead>{previewColumns.extra}</TableHead>
|
||||
<TableHead>{previewColumns.time}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>{renderTable()}</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{result?.meta ? (
|
||||
{stats.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{stats.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className={cn(
|
||||
"inline-flex items-baseline gap-2 rounded-md border px-3 py-1.5 text-sm",
|
||||
item.tone === "good"
|
||||
? "border-emerald-200/80 bg-emerald-50/50"
|
||||
: item.tone === "bad"
|
||||
? "border-red-200/80 bg-red-50/50"
|
||||
: item.tone === "warn"
|
||||
? "border-amber-200/80 bg-amber-50/50"
|
||||
: "border-border/60 bg-muted/30",
|
||||
)}
|
||||
>
|
||||
<span className="text-muted-foreground">{item.label}</span>
|
||||
<span className="font-semibold tabular-nums">{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-lg border border-border/60">
|
||||
<ReportPreviewTable
|
||||
report={selectedReport}
|
||||
result={result}
|
||||
loading={loading}
|
||||
error={error}
|
||||
drawNoLabel={filters.drawNo}
|
||||
displayCurrency={displayCurrency}
|
||||
/>
|
||||
{result?.meta ? (
|
||||
<div className="border-t border-border/60 px-3 py-2">
|
||||
<AdminListPaginationFooter
|
||||
selectId="reports-preview-per-page"
|
||||
total={result.meta.total}
|
||||
@@ -1658,11 +836,9 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
}}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
141
src/modules/reports/reports-definitions.ts
Normal file
141
src/modules/reports/reports-definitions.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
CalendarDays,
|
||||
FileSpreadsheet,
|
||||
ListFilter,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
Ticket,
|
||||
Users,
|
||||
WalletCards,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
PRD_AUDIT_VIEW,
|
||||
PRD_REPORT_VIEW,
|
||||
PRD_RISK_ACCESS_ANY,
|
||||
PRD_WALLET_TRANSFER_ACCESS_ANY,
|
||||
} from "@/lib/admin-prd";
|
||||
|
||||
export type ReportCategory = "profit" | "wallet" | "risk" | "audit";
|
||||
|
||||
export const REPORT_CATEGORY_ORDER: readonly ReportCategory[] = [
|
||||
"profit",
|
||||
"wallet",
|
||||
"risk",
|
||||
"audit",
|
||||
] as const;
|
||||
|
||||
export type FilterKind =
|
||||
| "draw"
|
||||
| "date"
|
||||
| "player_period"
|
||||
| "draw_number"
|
||||
| "play"
|
||||
| "play_period"
|
||||
| "operator_period";
|
||||
|
||||
export type FieldKey = "drawNo" | "number" | "player" | "play" | "operator" | "period";
|
||||
|
||||
export type ReportKey =
|
||||
| "draw_profit"
|
||||
| "daily_profit"
|
||||
| "player_win_loss"
|
||||
| "player_transfer"
|
||||
| "hot_number_risk"
|
||||
| "play_dimension"
|
||||
| "sold_out_number"
|
||||
| "admin_audit";
|
||||
|
||||
export type ReportDefinition = {
|
||||
key: ReportKey;
|
||||
category: ReportCategory;
|
||||
icon: LucideIcon;
|
||||
filterKind: FilterKind;
|
||||
fields: FieldKey[];
|
||||
connected: boolean;
|
||||
requiredAny: readonly string[];
|
||||
};
|
||||
|
||||
const PRD_REPORTS_VIEW_ACCESS_ANY = [PRD_REPORT_VIEW] as const;
|
||||
|
||||
export const REPORT_DEFINITIONS: ReportDefinition[] = [
|
||||
{
|
||||
key: "draw_profit",
|
||||
category: "profit",
|
||||
icon: Ticket,
|
||||
filterKind: "draw",
|
||||
fields: ["drawNo"],
|
||||
connected: true,
|
||||
requiredAny: PRD_REPORTS_VIEW_ACCESS_ANY,
|
||||
},
|
||||
{
|
||||
key: "daily_profit",
|
||||
category: "profit",
|
||||
icon: CalendarDays,
|
||||
filterKind: "date",
|
||||
fields: ["period"],
|
||||
connected: true,
|
||||
requiredAny: PRD_REPORTS_VIEW_ACCESS_ANY,
|
||||
},
|
||||
{
|
||||
key: "player_win_loss",
|
||||
category: "profit",
|
||||
icon: Users,
|
||||
filterKind: "player_period",
|
||||
fields: ["player", "period"],
|
||||
connected: true,
|
||||
requiredAny: PRD_REPORTS_VIEW_ACCESS_ANY,
|
||||
},
|
||||
{
|
||||
key: "play_dimension",
|
||||
category: "profit",
|
||||
icon: ListFilter,
|
||||
filterKind: "play_period",
|
||||
fields: ["play", "period"],
|
||||
connected: true,
|
||||
requiredAny: PRD_REPORTS_VIEW_ACCESS_ANY,
|
||||
},
|
||||
{
|
||||
key: "player_transfer",
|
||||
category: "wallet",
|
||||
icon: WalletCards,
|
||||
filterKind: "player_period",
|
||||
fields: ["player", "period"],
|
||||
connected: true,
|
||||
requiredAny: PRD_WALLET_TRANSFER_ACCESS_ANY,
|
||||
},
|
||||
{
|
||||
key: "hot_number_risk",
|
||||
category: "risk",
|
||||
icon: ShieldAlert,
|
||||
filterKind: "draw_number",
|
||||
fields: ["drawNo", "number"],
|
||||
connected: true,
|
||||
requiredAny: PRD_RISK_ACCESS_ANY,
|
||||
},
|
||||
{
|
||||
key: "sold_out_number",
|
||||
category: "risk",
|
||||
icon: ShieldCheck,
|
||||
filterKind: "draw",
|
||||
fields: ["drawNo"],
|
||||
connected: true,
|
||||
requiredAny: PRD_RISK_ACCESS_ANY,
|
||||
},
|
||||
{
|
||||
key: "admin_audit",
|
||||
category: "audit",
|
||||
icon: FileSpreadsheet,
|
||||
filterKind: "operator_period",
|
||||
fields: ["operator", "period"],
|
||||
connected: true,
|
||||
requiredAny: [PRD_AUDIT_VIEW],
|
||||
},
|
||||
];
|
||||
|
||||
export const CATEGORY_SHORTCUT_HREF: Partial<Record<ReportCategory, string>> = {
|
||||
wallet: "/admin/wallet/transfer-orders",
|
||||
risk: "/admin/risk",
|
||||
audit: "/admin/audit-logs",
|
||||
};
|
||||
358
src/modules/reports/reports-utils.ts
Normal file
358
src/modules/reports/reports-utils.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
import { getAdminDraws } from "@/api/admin-draws";
|
||||
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 { getCachedAdminCurrencies } from "@/hooks/use-admin-currency-catalog";
|
||||
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,
|
||||
} from "@/types/api/admin-reports";
|
||||
|
||||
import type { FieldKey, ReportDefinition, ReportKey } from "@/modules/reports/reports-definitions";
|
||||
|
||||
export type ExportCell = string | number | null;
|
||||
export type ExportRow = Record<string, ExportCell>;
|
||||
export type ExportFormat = "csv" | "excel";
|
||||
export type SearchKind = "draw" | "player" | "operator";
|
||||
|
||||
export 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;
|
||||
};
|
||||
|
||||
export type ReportMeta = {
|
||||
total: number;
|
||||
page: number;
|
||||
perPage: number;
|
||||
lastPage: number;
|
||||
};
|
||||
|
||||
export type StatCard = {
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: "default" | "good" | "warn" | "bad";
|
||||
};
|
||||
|
||||
export 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: "admin_audit"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta; raw: AdminAuditLogRow[] };
|
||||
|
||||
export type SearchState = {
|
||||
open: SearchKind | null;
|
||||
query: string;
|
||||
loading: boolean;
|
||||
draws: AdminDrawListItem[];
|
||||
players: AdminPlayerRow[];
|
||||
operators: AdminUserPermissionRow[];
|
||||
};
|
||||
|
||||
export const emptyFilters: ReportFilters = {
|
||||
drawNo: "",
|
||||
drawId: null,
|
||||
number: "",
|
||||
player: "",
|
||||
playerId: null,
|
||||
play: "",
|
||||
operator: "",
|
||||
operatorId: null,
|
||||
dateFrom: "",
|
||||
dateTo: "",
|
||||
};
|
||||
|
||||
export const emptySearch: SearchState = {
|
||||
open: null,
|
||||
query: "",
|
||||
loading: false,
|
||||
draws: [],
|
||||
players: [],
|
||||
operators: [],
|
||||
};
|
||||
|
||||
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<ReportFilters, "dateFrom" | "dateTo"> {
|
||||
const to = new Date();
|
||||
const from = new Date();
|
||||
from.setDate(from.getDate() - 29);
|
||||
return { dateFrom: isoDateLocal(from), dateTo: isoDateLocal(to) };
|
||||
}
|
||||
|
||||
export function createDefaultFilters(): ReportFilters {
|
||||
return { ...emptyFilters, ...defaultReportPeriod() };
|
||||
}
|
||||
|
||||
export function reportHasPeriodField(report: ReportDefinition): boolean {
|
||||
return report.fields.includes("period");
|
||||
}
|
||||
|
||||
export function resolveDisplayCurrency(apiCode?: string | null): string {
|
||||
const trimmed = apiCode?.trim();
|
||||
if (trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
const fallback = getCachedAdminCurrencies().find((row) => row.is_enabled && row.is_bettable)?.code;
|
||||
return fallback?.trim() || "NPR";
|
||||
}
|
||||
|
||||
export function formatPlainMoney(value: number, currencyCode: string | null | undefined): string {
|
||||
return formatAdminMinorUnits(value, currencyCode || "NPR");
|
||||
}
|
||||
|
||||
export function signedProfitCell(amount: number, currencyCode: string | null | undefined): string {
|
||||
return cn("tabular-nums", signedMoneyClass(amount, true));
|
||||
}
|
||||
|
||||
export function formatUsagePercent(ratio: number | null | undefined): string {
|
||||
return ratio == null ? "-" : `${Math.round(ratio * 100)}%`;
|
||||
}
|
||||
|
||||
export function optionText(...parts: Array<string | number | null | undefined>): string {
|
||||
return parts.filter((part) => part !== null && part !== undefined && String(part).trim() !== "").join(" / ");
|
||||
}
|
||||
|
||||
export 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,
|
||||
};
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export 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 };
|
||||
}
|
||||
|
||||
export function formatExportInstant(iso: string | null | undefined): ExportCell {
|
||||
return formatAdminInstant(iso, { locale: getAdminRequestLocale() });
|
||||
}
|
||||
|
||||
export 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,
|
||||
};
|
||||
}
|
||||
|
||||
export 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),
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
export function buildDailyProfitRowsAndSummary(
|
||||
items: AdminReportDailyProfitRow[],
|
||||
total: number,
|
||||
t: (key: string) => string,
|
||||
pageScopedLabel: (statKey: string) => string,
|
||||
currencyCode: string,
|
||||
): Pick<Extract<ReportResult, { key: "daily_profit" }>, "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("houseGross"),
|
||||
value: formatPlainMoney(totalGross, currencyCode),
|
||||
tone: totalGross >= 0 ? "good" : "bad",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPlayerTransferRowsAndSummary(
|
||||
items: AdminTransferOrderItem[],
|
||||
total: number,
|
||||
page: number,
|
||||
perPage: number,
|
||||
t: (key: string) => string,
|
||||
): Pick<Extract<ReportResult, { key: "player_transfer" }>, "rows" | "summary" | "meta"> {
|
||||
const rows = 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),
|
||||
}));
|
||||
|
||||
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) },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPlayDimensionRowsAndSummary(
|
||||
items: AdminReportPlayDimensionRow[],
|
||||
total: number,
|
||||
t: (key: string) => string,
|
||||
currencyCode: string,
|
||||
): Pick<Extract<ReportResult, { key: "play_dimension" }>, "rows" | "summary"> {
|
||||
let totalBet = 0;
|
||||
let totalGross = 0;
|
||||
|
||||
const rows = items.map((item) => {
|
||||
totalBet += item.total_bet_minor;
|
||||
totalGross += item.approx_house_gross_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.houseGross"),
|
||||
value: formatPlainMoney(totalGross, currencyCode),
|
||||
tone: totalGross >= 0 ? "good" : "bad",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeFilenamePart(value: string): string {
|
||||
return value.trim().replace(/[\\/:*?"<>|\s]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
export 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);
|
||||
}
|
||||
|
||||
/** Primary headline stats shown above the preview table (max 2). */
|
||||
export function headlineStats(result: ReportResult | null, reportKey: ReportKey): StatCard[] {
|
||||
if (!result || result.key !== reportKey) {
|
||||
return [];
|
||||
}
|
||||
return result.summary.slice(0, 2);
|
||||
}
|
||||
Reference in New Issue
Block a user