Updated the agent dashboard to include new metrics for today's bets and payouts, improving visibility for users. Enhanced localization files with additional hints and labels for better user experience across English, Nepali, and Chinese. Introduced new functions for formatting business dates and improved the handling of analytics permissions in the dashboard components.
1728 lines
68 KiB
TypeScript
1728 lines
68 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { useSearchParams } from "next/navigation";
|
|
import { useTranslation } from "react-i18next";
|
|
import { toast } from "sonner";
|
|
import {
|
|
CalendarDays,
|
|
CircleDollarSign,
|
|
Database,
|
|
FileDown,
|
|
FileSpreadsheet,
|
|
ListFilter,
|
|
Search,
|
|
ShieldAlert,
|
|
ShieldCheck,
|
|
Ticket,
|
|
Users,
|
|
WalletCards,
|
|
} from "lucide-react";
|
|
|
|
import { getAdminAuditLogs } from "@/api/admin-audit";
|
|
import { useAdminPlayCodeLabel, useAdminPlayTypeCatalog } from "@/hooks/use-admin-play-type-catalog";
|
|
import { useCachedPlayTypeOptions } from "@/hooks/use-cached-play-type-options";
|
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
|
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
|
import { getAdminDraws, getAdminDrawFinanceSummary } from "@/api/admin-draws";
|
|
import { getAdminPlayers } from "@/api/admin-player";
|
|
import { downloadAdminReportJob, postAdminReportJob } from "@/api/admin-report-jobs";
|
|
import {
|
|
getAdminReportDailyProfit,
|
|
getAdminReportPlayDimension,
|
|
getAdminReportPlayerWinLoss,
|
|
getAdminReportRebateCommission,
|
|
} from "@/api/admin-reports";
|
|
import {
|
|
buildReportJobParameters,
|
|
REPORT_UI_TO_JOB_TYPE,
|
|
type ReportUiKey,
|
|
} from "@/lib/report-export-map";
|
|
import { getAdminRiskPoolDetail, getAdminRiskPools } from "@/api/admin-risk";
|
|
import { getAdminUsers } from "@/api/admin-users";
|
|
import { getAdminTransferOrders } from "@/api/admin-wallet";
|
|
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
|
import { PRD_REPORT_EXPORT, PRD_REPORT_VIEW } from "@/lib/admin-prd";
|
|
import { useAdminProfile } from "@/stores/admin-session";
|
|
import { adminAgentDisplayLabel } from "@/components/admin/admin-agent-columns";
|
|
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
|
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
|
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import { useAdminCurrencyCatalog, getCachedAdminCurrencies } from "@/hooks/use-admin-currency-catalog";
|
|
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
|
import { formatAdminInstant } from "@/lib/admin-datetime";
|
|
import { getAdminRequestLocale } from "@/lib/admin-locale";
|
|
import { signedMoneyClass } from "@/lib/admin-signed-money";
|
|
import { cn } from "@/lib/utils";
|
|
import { formatAdminMinorUnits } from "@/lib/money";
|
|
import { LotteryApiBizError } from "@/types/api/errors";
|
|
import type { AdminAuditLogRow } from "@/types/api/admin-audit";
|
|
import type { AdminDrawListItem } from "@/types/api/admin-draws";
|
|
import type { AdminDrawFinanceSummaryData } from "@/types/api/admin-draw-finance";
|
|
import type { AdminPlayerRow } from "@/types/api/admin-player";
|
|
import type { AdminRiskPoolRow, AdminRiskPoolShowData } from "@/types/api/admin-risk";
|
|
import type { AdminUserPermissionRow } from "@/types/api/admin-user";
|
|
import type { AdminTransferOrderItem } from "@/types/api/admin-wallet";
|
|
import type {
|
|
AdminReportDailyProfitRow,
|
|
AdminReportPlayDimensionRow,
|
|
AdminReportPlayerWinLossRow,
|
|
AdminReportRebateCommissionRow,
|
|
} from "@/types/api/admin-reports";
|
|
|
|
export type ReportCategory = "profit" | "wallet" | "risk" | "audit";
|
|
type FilterKind = "draw" | "date" | "player_period" | "draw_number" | "play" | "play_period" | "operator_period";
|
|
type FieldKey = "drawNo" | "number" | "player" | "play" | "operator" | "period";
|
|
type ExportFormat = "csv" | "excel";
|
|
type ExportCell = string | number | null;
|
|
type ExportRow = Record<string, ExportCell>;
|
|
type SearchKind = "draw" | "player" | "operator";
|
|
|
|
type ReportKey =
|
|
| "draw_profit"
|
|
| "daily_profit"
|
|
| "player_win_loss"
|
|
| "player_transfer"
|
|
| "hot_number_risk"
|
|
| "play_dimension"
|
|
| "sold_out_number"
|
|
| "rebate_commission"
|
|
| "admin_audit";
|
|
|
|
type ReportDefinition = {
|
|
key: ReportKey;
|
|
category: ReportCategory;
|
|
icon: typeof FileSpreadsheet;
|
|
filterKind: FilterKind;
|
|
scope: string;
|
|
fields: FieldKey[];
|
|
connected: boolean;
|
|
};
|
|
|
|
type PreviewColumns = {
|
|
primary: string;
|
|
secondary: string;
|
|
metricA: string;
|
|
metricB: string;
|
|
metricC: string;
|
|
status: string;
|
|
extra: string;
|
|
time: string;
|
|
};
|
|
|
|
type ReportFilters = {
|
|
drawNo: string;
|
|
drawId: number | null;
|
|
number: string;
|
|
player: string;
|
|
playerId: number | null;
|
|
play: string;
|
|
operator: string;
|
|
operatorId: number | null;
|
|
dateFrom: string;
|
|
dateTo: string;
|
|
};
|
|
|
|
type ReportMeta = {
|
|
total: number;
|
|
page: number;
|
|
perPage: number;
|
|
lastPage: number;
|
|
};
|
|
|
|
type ReportResult =
|
|
| { key: "draw_profit"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta | null; raw: AdminDrawFinanceSummaryData }
|
|
| { key: "daily_profit"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta; raw: AdminReportDailyProfitRow[] }
|
|
| { key: "player_win_loss"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta; raw: AdminReportPlayerWinLossRow[] }
|
|
| { key: "player_transfer"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta; raw: AdminTransferOrderItem[] }
|
|
| { key: "hot_number_risk"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta | null; raw: AdminRiskPoolShowData }
|
|
| { key: "play_dimension"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta; raw: AdminReportPlayDimensionRow[] }
|
|
| { key: "sold_out_number"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta; raw: AdminRiskPoolRow[] }
|
|
| { key: "rebate_commission"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta; raw: AdminReportRebateCommissionRow[] }
|
|
| { key: "admin_audit"; rows: ExportRow[]; summary: StatCard[]; meta: ReportMeta; raw: AdminAuditLogRow[] };
|
|
|
|
type StatCard = {
|
|
label: string;
|
|
value: string;
|
|
tone?: "default" | "good" | "warn" | "bad";
|
|
};
|
|
|
|
type SearchState = {
|
|
open: SearchKind | null;
|
|
query: string;
|
|
loading: boolean;
|
|
draws: AdminDrawListItem[];
|
|
players: AdminPlayerRow[];
|
|
operators: AdminUserPermissionRow[];
|
|
};
|
|
|
|
type PlayOption = {
|
|
code: string;
|
|
label: string;
|
|
};
|
|
|
|
const REPORTS: ReportDefinition[] = [
|
|
{ key: "draw_profit", category: "profit", icon: Ticket, filterKind: "draw", scope: "drawNo", fields: ["drawNo"], connected: true },
|
|
{ key: "daily_profit", category: "profit", icon: CalendarDays, filterKind: "date", scope: "date", fields: ["period"], connected: true },
|
|
{ key: "player_win_loss", category: "profit", icon: Users, filterKind: "player_period", scope: "playerPeriod", fields: ["player", "period"], connected: true },
|
|
{ key: "player_transfer", category: "wallet", icon: WalletCards, filterKind: "player_period", scope: "playerPeriod", fields: ["player", "period"], connected: true },
|
|
{ key: "hot_number_risk", category: "risk", icon: ShieldAlert, filterKind: "draw_number", scope: "drawNumber", fields: ["drawNo", "number"], connected: true },
|
|
{ key: "play_dimension", category: "profit", icon: ListFilter, filterKind: "play_period", scope: "playPeriod", fields: ["play", "period"], connected: true },
|
|
{ key: "sold_out_number", category: "risk", icon: ShieldCheck, filterKind: "draw", scope: "drawNo", fields: ["drawNo"], connected: true },
|
|
{ key: "rebate_commission", category: "profit", icon: CircleDollarSign, filterKind: "play_period", scope: "playPeriod", fields: ["play", "period"], connected: true },
|
|
{ key: "admin_audit", category: "audit", icon: FileSpreadsheet, filterKind: "operator_period", scope: "operatorPeriod", fields: ["operator", "period"], connected: true },
|
|
];
|
|
|
|
const emptyFilters: ReportFilters = {
|
|
drawNo: "",
|
|
drawId: null,
|
|
number: "",
|
|
player: "",
|
|
playerId: null,
|
|
play: "",
|
|
operator: "",
|
|
operatorId: null,
|
|
dateFrom: "",
|
|
dateTo: "",
|
|
};
|
|
|
|
function isoDateLocal(date: Date): string {
|
|
const year = date.getFullYear();
|
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
const day = String(date.getDate()).padStart(2, "0");
|
|
return `${year}-${month}-${day}`;
|
|
}
|
|
|
|
function defaultReportPeriod(): Pick<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_default)?.code;
|
|
return fallback?.trim() || "NPR";
|
|
}
|
|
|
|
const emptySearch: SearchState = {
|
|
open: null,
|
|
query: "",
|
|
loading: false,
|
|
draws: [],
|
|
players: [],
|
|
operators: [],
|
|
};
|
|
|
|
function categoryTone(category: ReportCategory): string {
|
|
switch (category) {
|
|
case "wallet":
|
|
return "border-emerald-200 bg-emerald-50 text-emerald-700";
|
|
case "risk":
|
|
return "border-red-200 bg-red-50 text-red-700";
|
|
case "audit":
|
|
return "border-slate-200 bg-slate-50 text-slate-700";
|
|
default:
|
|
return "border-blue-200 bg-blue-50 text-blue-700";
|
|
}
|
|
}
|
|
|
|
function statTone(tone: StatCard["tone"]): string {
|
|
switch (tone) {
|
|
case "good":
|
|
return "border-emerald-200 bg-emerald-50/70 text-emerald-900";
|
|
case "warn":
|
|
return "border-amber-200 bg-amber-50/70 text-amber-950";
|
|
case "bad":
|
|
return "border-red-200 bg-red-50/70 text-red-950";
|
|
default:
|
|
return "border-border/70 bg-card text-foreground";
|
|
}
|
|
}
|
|
|
|
function formatKind(kind: FilterKind, t: (key: string) => string): string {
|
|
return t(`filters.${kind}`);
|
|
}
|
|
|
|
function downloadBlob(blob: Blob, filename: string): void {
|
|
const url = URL.createObjectURL(blob);
|
|
const anchor = document.createElement("a");
|
|
anchor.href = url;
|
|
anchor.download = filename;
|
|
document.body.appendChild(anchor);
|
|
anchor.click();
|
|
anchor.remove();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
function normalizeFilenamePart(value: string): string {
|
|
return value.trim().replace(/[\\/:*?"<>|\s]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
}
|
|
|
|
function formatExportInstant(iso: string | null | undefined): ExportCell {
|
|
return formatAdminInstant(iso, { locale: getAdminRequestLocale() });
|
|
}
|
|
|
|
function buildDailyProfitRowsAndSummary(
|
|
items: AdminReportDailyProfitRow[],
|
|
total: number,
|
|
t: (key: string) => string,
|
|
pageScopedLabel: (statKey: string) => string,
|
|
currencyCode: string,
|
|
): Pick<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 buildRebateCommissionRowsAndSummary(
|
|
items: AdminReportRebateCommissionRow[],
|
|
total: number,
|
|
t: (key: string) => string,
|
|
pageScopedLabel: (statKey: string) => string,
|
|
currencyCode: string,
|
|
): Pick<Extract<ReportResult, { key: "rebate_commission" }>, "rows" | "summary"> {
|
|
let totalRebate = 0;
|
|
let totalOrders = 0;
|
|
|
|
const rows = items.map((item) => {
|
|
totalRebate += item.total_rebate_minor;
|
|
totalOrders += item.order_count;
|
|
return {
|
|
play_code: item.play_code,
|
|
total_rebate_minor: item.total_rebate_minor,
|
|
order_count: item.order_count,
|
|
ticket_item_count: item.ticket_item_count,
|
|
};
|
|
});
|
|
|
|
return {
|
|
rows,
|
|
summary: [
|
|
{ label: t("preview.stats.records"), value: String(total) },
|
|
{ label: t("preview.stats.currentPage"), value: String(items.length) },
|
|
{ label: pageScopedLabel("rebate"), value: formatPlainMoney(totalRebate, currencyCode) },
|
|
{ label: pageScopedLabel("orders"), value: String(totalOrders) },
|
|
],
|
|
};
|
|
}
|
|
|
|
function metaFromList(meta: { current_page: number; per_page: number; total: number; last_page: number }): ReportMeta {
|
|
return {
|
|
total: meta.total,
|
|
page: meta.current_page,
|
|
perPage: meta.per_page,
|
|
lastPage: meta.last_page,
|
|
};
|
|
}
|
|
|
|
function formatPlainMoney(value: number, currencyCode: string | null | undefined): string {
|
|
return formatAdminMinorUnits(value, currencyCode || "NPR");
|
|
}
|
|
|
|
function signedProfitCell(amount: number, currencyCode: string | null | undefined): string {
|
|
return cn("text-center tabular-nums", signedMoneyClass(amount, true));
|
|
}
|
|
|
|
function formatUsagePercent(ratio: number | null | undefined): string {
|
|
return ratio == null ? "-" : `${Math.round(ratio * 100)}%`;
|
|
}
|
|
|
|
function optionText(...parts: Array<string | 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 "rebate_commission":
|
|
return [
|
|
{ label: t("preview.stats.records"), value: t("preview.stats.notQueried") },
|
|
{ label: t("fields.play"), value: filters.play || t("filterAll") },
|
|
{ label: t("preview.stats.rebate"), value: t("preview.stats.notQueried") },
|
|
{ label: t("preview.stats.orders"), value: t("preview.stats.notQueried") },
|
|
];
|
|
case "admin_audit":
|
|
return [
|
|
{ label: t("preview.stats.records"), value: t("preview.stats.notQueried") },
|
|
{ label: t("fields.operator"), value: filters.operator || t("preview.stats.notSet") },
|
|
{ label: t("preview.stats.modules"), value: t("preview.stats.notQueried") },
|
|
{ label: t("preview.stats.operators"), value: t("preview.stats.notQueried") },
|
|
];
|
|
default:
|
|
return [
|
|
{ label: t("preview.stats.records"), value: t("preview.stats.notQueried") },
|
|
{ label: t("preview.stats.currentPage"), value: t("preview.stats.notQueried") },
|
|
{ label: t("preview.stats.exportRows"), value: "0" },
|
|
{ label: t("preview.stats.drawNo"), value: filters.drawNo || t("preview.stats.notSet") },
|
|
];
|
|
}
|
|
}
|
|
|
|
export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCategory } = {}) {
|
|
const { t, i18n } = useTranslation(["reports", "common"]);
|
|
const profile = useAdminProfile();
|
|
const canViewReports = adminHasAnyPermission(profile?.permissions, [PRD_REPORT_VIEW]);
|
|
const canExportReports = adminHasAnyPermission(profile?.permissions, [PRD_REPORT_EXPORT]);
|
|
useAdminCurrencyCatalog();
|
|
useAdminPlayTypeCatalog();
|
|
const playCodeLabel = useAdminPlayCodeLabel();
|
|
const formatTs = useAdminDateTimeFormatter();
|
|
const filteredReports = useMemo(
|
|
() => (initialCategory ? REPORTS.filter((report) => report.category === initialCategory) : REPORTS),
|
|
[initialCategory],
|
|
);
|
|
const [selectedKey, setSelectedKey] = useState<ReportKey>(
|
|
filteredReports[0]?.key ?? REPORTS[0].key,
|
|
);
|
|
const [filters, setFilters] = useState<ReportFilters>(createDefaultFilters);
|
|
const [displayCurrency, setDisplayCurrency] = useState<string>(() => resolveDisplayCurrency(null));
|
|
const [result, setResult] = useState<ReportResult | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [page, setPage] = useState(1);
|
|
const [perPage, setPerPage] = useState(20);
|
|
const [exporting, setExporting] = useState<ExportFormat | null>(null);
|
|
const [search, setSearch] = useState<SearchState>(emptySearch);
|
|
const playOptions = useCachedPlayTypeOptions();
|
|
const tRef = useTranslationRef(["reports", "common"]);
|
|
const searchParams = useSearchParams();
|
|
const drawNoFromUrl = (searchParams.get("draw_no") ?? "").trim();
|
|
|
|
const selectedReport = filteredReports.find((report) => report.key === selectedKey) ?? filteredReports[0] ?? REPORTS[0];
|
|
|
|
useEffect(() => {
|
|
if (!filteredReports.some((report) => report.key === selectedKey)) {
|
|
setSelectedKey(filteredReports[0]?.key ?? REPORTS[0].key);
|
|
}
|
|
}, [filteredReports, selectedKey]);
|
|
|
|
const pageScopedLabel = useCallback(
|
|
(statKey: string) => t(`preview.stats.${statKey}`),
|
|
[t],
|
|
);
|
|
|
|
const previewColumns = useMemo<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 "rebate_commission":
|
|
return {
|
|
primary: t("preview.columns.rebateCommission.primary"),
|
|
secondary: t("preview.columns.rebateCommission.secondary"),
|
|
metricA: t("preview.columns.rebateCommission.metricA"),
|
|
metricB: t("preview.columns.rebateCommission.metricB"),
|
|
metricC: t("preview.columns.rebateCommission.metricC"),
|
|
status: t("preview.columns.rebateCommission.status"),
|
|
extra: t("preview.columns.rebateCommission.extra"),
|
|
time: t("preview.columns.rebateCommission.time"),
|
|
};
|
|
case "admin_audit":
|
|
return {
|
|
primary: t("preview.columns.adminAudit.primary"),
|
|
secondary: t("preview.columns.adminAudit.secondary"),
|
|
metricA: t("preview.columns.adminAudit.metricA"),
|
|
metricB: t("preview.columns.adminAudit.metricB"),
|
|
metricC: t("preview.columns.adminAudit.metricC"),
|
|
status: t("preview.columns.adminAudit.status"),
|
|
extra: t("preview.columns.adminAudit.extra"),
|
|
time: t("preview.columns.adminAudit.time"),
|
|
};
|
|
default:
|
|
return {
|
|
primary: t("preview.columns.primary"),
|
|
secondary: t("preview.columns.secondary"),
|
|
metricA: t("preview.columns.metricA"),
|
|
metricB: t("preview.columns.metricB"),
|
|
metricC: t("preview.columns.metricC"),
|
|
status: t("preview.columns.status"),
|
|
extra: t("preview.columns.extra"),
|
|
time: t("preview.columns.time"),
|
|
};
|
|
}
|
|
}, [selectedReport.key, t]);
|
|
|
|
const exportFileBase = useMemo(() => {
|
|
const segments: string[] = [selectedReport.key];
|
|
if (filters.drawNo.trim()) segments.push(filters.drawNo.trim());
|
|
if (filters.number.trim()) segments.push(filters.number.trim());
|
|
if (filters.player.trim()) segments.push(filters.player.trim());
|
|
if (filters.play.trim()) segments.push(filters.play.trim());
|
|
if (filters.operator.trim()) segments.push(filters.operator.trim());
|
|
if (filters.dateFrom) segments.push(filters.dateFrom);
|
|
if (filters.dateTo) segments.push(filters.dateTo);
|
|
return normalizeFilenamePart(segments.join("-")) || selectedReport.key;
|
|
}, [selectedReport.key, filters]);
|
|
|
|
const loadSearchOptions = useCallback(async (kind: SearchKind, query: string) => {
|
|
setSearch((prev) => ({ ...prev, loading: true }));
|
|
try {
|
|
if (kind === "draw") {
|
|
const payload = await getAdminDraws({ draw_no: query.trim() || undefined, page: 1, per_page: 8 });
|
|
setSearch((prev) => ({ ...prev, draws: payload.items, loading: false }));
|
|
return;
|
|
}
|
|
if (kind === "player") {
|
|
const payload = await getAdminPlayers({ keyword: query.trim() || undefined, page: 1, per_page: 8 });
|
|
setSearch((prev) => ({ ...prev, players: payload.items, loading: false }));
|
|
return;
|
|
}
|
|
const payload = await getAdminUsers({ keyword: query.trim() || undefined, page: 1, per_page: 8 });
|
|
setSearch((prev) => ({ ...prev, operators: payload.items, loading: false }));
|
|
} catch {
|
|
setSearch((prev) => ({ ...prev, loading: false }));
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (search.open === null) {
|
|
return;
|
|
}
|
|
const timer = window.setTimeout(() => {
|
|
void loadSearchOptions(search.open as SearchKind, search.query);
|
|
}, 250);
|
|
return () => window.clearTimeout(timer);
|
|
}, [search.open, search.query, loadSearchOptions]);
|
|
|
|
const queryReport = useCallback(async () => {
|
|
if (!canViewReports) {
|
|
return;
|
|
}
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
switch (selectedReport.key) {
|
|
case "draw_profit": {
|
|
const draw = await resolveDraw(filters, {
|
|
drawNoRequired: tRef.current("validation.drawNoRequired", { ns: "reports" }),
|
|
drawNoNotFound: (drawNo) =>
|
|
tRef.current("validation.drawNoNotFound", { ns: "reports", drawNo }),
|
|
});
|
|
const summary = await getAdminDrawFinanceSummary(draw.id);
|
|
setDisplayCurrency(resolveDisplayCurrency(summary.currency_code));
|
|
setResult({
|
|
key: "draw_profit",
|
|
raw: summary,
|
|
rows: drawRowsFromSummary(summary),
|
|
meta: null,
|
|
summary: [
|
|
{ label: t("preview.stats.bet"), value: formatPlainMoney(summary.total_bet_minor, summary.currency_code) },
|
|
{ label: t("preview.stats.payout"), value: formatPlainMoney(summary.total_payout_minor, summary.currency_code) },
|
|
{
|
|
label: t("preview.stats.houseGross"),
|
|
value: formatPlainMoney(summary.approx_house_gross_minor, summary.currency_code),
|
|
tone: summary.approx_house_gross_minor >= 0 ? "good" : "bad",
|
|
},
|
|
{ label: t("preview.stats.orders"), value: String(summary.order_count) },
|
|
],
|
|
});
|
|
break;
|
|
}
|
|
case "daily_profit": {
|
|
const payload = await getAdminReportDailyProfit(
|
|
reportListParams(filters, page, perPage),
|
|
);
|
|
const currencyCode = resolveDisplayCurrency(payload.currency_code);
|
|
setDisplayCurrency(currencyCode);
|
|
const next = buildDailyProfitRowsAndSummary(payload.items, payload.meta.total, t, pageScopedLabel, currencyCode);
|
|
setResult({
|
|
key: "daily_profit",
|
|
raw: payload.items,
|
|
rows: next.rows,
|
|
meta: metaFromList(payload.meta),
|
|
summary: next.summary,
|
|
});
|
|
break;
|
|
}
|
|
case "player_win_loss": {
|
|
const payload = await getAdminReportPlayerWinLoss(
|
|
reportListParams(filters, page, perPage),
|
|
);
|
|
const currencyCode = resolveDisplayCurrency(payload.currency_code);
|
|
setDisplayCurrency(currencyCode);
|
|
const rows = payload.items.map((item) => ({
|
|
player_id: item.player_id,
|
|
username: item.username,
|
|
total_bet_minor: item.total_bet_minor,
|
|
total_payout_minor: item.total_payout_minor,
|
|
net_win_loss_minor: item.net_win_loss_minor,
|
|
}));
|
|
setResult({
|
|
key: "player_win_loss",
|
|
raw: payload.items,
|
|
rows,
|
|
meta: metaFromList(payload.meta),
|
|
summary: [
|
|
{ label: t("preview.stats.records"), value: String(payload.meta.total) },
|
|
{ label: t("preview.stats.currentPage"), value: String(payload.items.length) },
|
|
{
|
|
label: pageScopedLabel("houseGross"),
|
|
value: formatPlainMoney(
|
|
payload.items.reduce((sum, item) => sum - item.net_win_loss_minor, 0),
|
|
currencyCode,
|
|
),
|
|
tone: (() => {
|
|
const houseGross = payload.items.reduce((sum, item) => sum - item.net_win_loss_minor, 0);
|
|
return houseGross >= 0 ? "good" : "bad";
|
|
})(),
|
|
},
|
|
{ label: t("preview.stats.players"), value: String(new Set(payload.items.map((item) => item.player_id)).size) },
|
|
],
|
|
});
|
|
break;
|
|
}
|
|
case "player_transfer": {
|
|
const playerId = filters.playerId ?? parsePositiveInteger(filters.player);
|
|
const payload = await getAdminTransferOrders({
|
|
page,
|
|
per_page: perPage,
|
|
player_id: playerId ?? undefined,
|
|
player_account: playerId ? undefined : filters.player.trim() || undefined,
|
|
created_from: filters.dateFrom || undefined,
|
|
created_to: filters.dateTo || undefined,
|
|
});
|
|
const next = buildPlayerTransferRowsAndSummary(
|
|
payload.items,
|
|
payload.total,
|
|
payload.page,
|
|
payload.per_page,
|
|
t,
|
|
pageScopedLabel,
|
|
);
|
|
setDisplayCurrency(resolveDisplayCurrency(payload.items[0]?.currency_code));
|
|
setResult({
|
|
key: "player_transfer",
|
|
raw: payload.items,
|
|
rows: next.rows,
|
|
meta: next.meta,
|
|
summary: next.summary,
|
|
});
|
|
break;
|
|
}
|
|
case "hot_number_risk": {
|
|
if (!filters.number.trim()) {
|
|
throw new LotteryApiBizError(tRef.current("validation.drawNoNumberRequired"), -1, null);
|
|
}
|
|
const draw = await resolveDraw(filters, {
|
|
drawNoRequired: tRef.current("validation.drawNoRequired", { ns: "reports" }),
|
|
drawNoNotFound: (drawNo) =>
|
|
tRef.current("validation.drawNoNotFound", { ns: "reports", drawNo }),
|
|
});
|
|
const detail = await getAdminRiskPoolDetail(draw.id, filters.number.trim(), { page, per_page: perPage });
|
|
setDisplayCurrency(resolveDisplayCurrency(detail.currency_code));
|
|
const rows: ExportRow[] = [
|
|
{
|
|
row_type: "risk_pool",
|
|
draw_id: detail.draw_id,
|
|
draw_no: detail.draw_no,
|
|
number: detail.pool.normalized_number,
|
|
total_cap_amount: detail.pool.total_cap_amount,
|
|
locked_amount: detail.pool.locked_amount,
|
|
remaining_amount: detail.pool.remaining_amount,
|
|
sold_out_status: detail.pool.sold_out_status,
|
|
is_sold_out: detail.pool.is_sold_out ? 1 : 0,
|
|
usage_ratio: detail.pool.usage_ratio,
|
|
version: detail.pool.version,
|
|
},
|
|
...detail.logs.items.map((item) => ({
|
|
row_type: "lock_log",
|
|
draw_id: detail.draw_id,
|
|
draw_no: detail.draw_no,
|
|
number: detail.pool.normalized_number,
|
|
log_id: item.id,
|
|
action_type: item.action_type,
|
|
amount: item.amount,
|
|
source_reason: item.source_reason,
|
|
ticket_item_id: item.ticket_item_id,
|
|
ticket_no: item.ticket_no,
|
|
play_code: item.play_code,
|
|
player_id: item.player_id,
|
|
created_at: formatExportInstant(item.created_at),
|
|
})),
|
|
];
|
|
setResult({
|
|
key: "hot_number_risk",
|
|
raw: detail,
|
|
rows,
|
|
meta: metaFromList(detail.logs.meta),
|
|
summary: [
|
|
{ label: t("preview.stats.locked"), value: formatPlainMoney(detail.pool.locked_amount, detail.currency_code) },
|
|
{ label: t("preview.stats.remaining"), value: formatPlainMoney(detail.pool.remaining_amount, detail.currency_code), tone: detail.pool.is_sold_out ? "bad" : "good" },
|
|
{ label: t("preview.stats.usage"), value: formatUsagePercent(detail.pool.usage_ratio), tone: detail.pool.is_sold_out ? "bad" : "warn" },
|
|
{ label: t("preview.stats.logs"), value: String(detail.logs.meta.total) },
|
|
],
|
|
});
|
|
break;
|
|
}
|
|
case "sold_out_number": {
|
|
const draw = await resolveDraw(filters, {
|
|
drawNoRequired: tRef.current("validation.drawNoRequired", { ns: "reports" }),
|
|
drawNoNotFound: (drawNo) =>
|
|
tRef.current("validation.drawNoNotFound", { ns: "reports", drawNo }),
|
|
});
|
|
const payload = await getAdminRiskPools(draw.id, { page, per_page: perPage, sold_out_only: true, sort: "number_asc" });
|
|
setDisplayCurrency(resolveDisplayCurrency(payload.currency_code));
|
|
const rows = payload.items.map((item) => ({
|
|
draw_id: payload.draw_id,
|
|
draw_no: payload.draw_no,
|
|
currency_code: payload.currency_code,
|
|
normalized_number: item.normalized_number,
|
|
total_cap_amount: item.total_cap_amount,
|
|
locked_amount: item.locked_amount,
|
|
remaining_amount: item.remaining_amount,
|
|
sold_out_status: item.sold_out_status,
|
|
is_sold_out: item.is_sold_out ? 1 : 0,
|
|
usage_ratio: item.usage_ratio,
|
|
version: item.version,
|
|
}));
|
|
setResult({
|
|
key: "sold_out_number",
|
|
raw: payload.items,
|
|
rows,
|
|
meta: metaFromList(payload.meta),
|
|
summary: [
|
|
{ label: t("preview.stats.records"), value: String(payload.meta.total), tone: payload.meta.total > 0 ? "bad" : "good" },
|
|
{ label: t("preview.stats.currentPage"), value: String(payload.items.length) },
|
|
{ label: t("preview.stats.drawNo"), value: payload.draw_no },
|
|
{ label: t("preview.stats.currency"), value: payload.currency_code || "-" },
|
|
],
|
|
});
|
|
break;
|
|
}
|
|
case "play_dimension": {
|
|
const payload = await getAdminReportPlayDimension(
|
|
reportListParams(filters, page, perPage),
|
|
);
|
|
const currencyCode = resolveDisplayCurrency(payload.currency_code);
|
|
setDisplayCurrency(currencyCode);
|
|
const next = buildPlayDimensionRowsAndSummary(payload.items, payload.meta.total, t, pageScopedLabel, currencyCode);
|
|
setResult({
|
|
key: "play_dimension",
|
|
raw: payload.items,
|
|
rows: next.rows,
|
|
meta: metaFromList(payload.meta),
|
|
summary: next.summary,
|
|
});
|
|
break;
|
|
}
|
|
case "rebate_commission": {
|
|
const payload = await getAdminReportRebateCommission(
|
|
reportListParams(filters, page, perPage),
|
|
);
|
|
const currencyCode = resolveDisplayCurrency(payload.currency_code);
|
|
setDisplayCurrency(currencyCode);
|
|
const next = buildRebateCommissionRowsAndSummary(payload.items, payload.meta.total, t, pageScopedLabel, currencyCode);
|
|
setResult({
|
|
key: "rebate_commission",
|
|
raw: payload.items,
|
|
rows: next.rows,
|
|
meta: metaFromList(payload.meta),
|
|
summary: next.summary,
|
|
});
|
|
break;
|
|
}
|
|
case "admin_audit": {
|
|
const operatorId = filters.operatorId ?? parsePositiveInteger(filters.operator);
|
|
const payload = await getAdminAuditLogs({
|
|
page,
|
|
per_page: perPage,
|
|
operator_id: operatorId ?? undefined,
|
|
operator_type: operatorId ? undefined : filters.operator.trim() || undefined,
|
|
start_date: filters.dateFrom || undefined,
|
|
end_date: filters.dateTo || undefined,
|
|
});
|
|
const rows = payload.items.map((item) => ({
|
|
id: item.id,
|
|
operator_type: item.operator_type,
|
|
operator_id: item.operator_id,
|
|
module_code: item.module_code,
|
|
action_code: item.action_code,
|
|
target_type: item.target_type,
|
|
target_id: item.target_id,
|
|
ip: item.ip,
|
|
user_agent: item.user_agent,
|
|
created_at: formatExportInstant(item.created_at),
|
|
}));
|
|
setResult({
|
|
key: "admin_audit",
|
|
raw: payload.items,
|
|
rows,
|
|
meta: metaFromList(payload.meta),
|
|
summary: [
|
|
{ label: t("preview.stats.records"), value: String(payload.meta.total) },
|
|
{ label: t("preview.stats.currentPage"), value: String(payload.items.length) },
|
|
{ label: t("preview.stats.modules"), value: String(new Set(payload.items.map((item) => item.module_code)).size) },
|
|
{ label: t("preview.stats.operators"), value: String(new Set(payload.items.map((item) => item.operator_id)).size) },
|
|
],
|
|
});
|
|
break;
|
|
}
|
|
default:
|
|
setResult(null);
|
|
setError(tRef.current("loadFailed"));
|
|
}
|
|
} catch (err) {
|
|
setResult(null);
|
|
setError(err instanceof LotteryApiBizError ? err.message : tRef.current("loadFailed"));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [canViewReports, filters, page, perPage, selectedReport]);
|
|
|
|
useEffect(() => {
|
|
queueMicrotask(() => {
|
|
setResult(null);
|
|
setError(null);
|
|
setPage(1);
|
|
});
|
|
}, [selectedKey]);
|
|
|
|
useEffect(() => {
|
|
setFilters((prev) => ({
|
|
...prev,
|
|
drawNo: drawNoFromUrl || prev.drawNo,
|
|
}));
|
|
if (drawNoFromUrl) {
|
|
setSelectedKey("draw_profit");
|
|
}
|
|
}, [drawNoFromUrl]);
|
|
|
|
useEffect(() => {
|
|
queueMicrotask(() => {
|
|
setResult(null);
|
|
setError(null);
|
|
setPage(1);
|
|
});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (result && result.key === selectedReport.key && selectedReport.connected) {
|
|
queueMicrotask(() => {
|
|
void queryReport();
|
|
});
|
|
}
|
|
}, [page, perPage]);
|
|
|
|
function updateFilter<K extends keyof ReportFilters>(key: K, value: ReportFilters[K]): void {
|
|
setFilters((prev) => ({ ...prev, [key]: value }));
|
|
}
|
|
|
|
function resetFilters(): void {
|
|
setFilters(reportHasPeriodField(selectedReport) ? createDefaultFilters() : { ...emptyFilters });
|
|
setResult(null);
|
|
setError(null);
|
|
setPage(1);
|
|
}
|
|
|
|
async function exportReport(format: ExportFormat): Promise<void> {
|
|
if (!canExportReports) {
|
|
return;
|
|
}
|
|
setExporting(format);
|
|
try {
|
|
const parameters = buildReportJobParameters(selectedReport.key as ReportUiKey, {
|
|
dateFrom: filters.dateFrom,
|
|
dateTo: filters.dateTo,
|
|
playerId: filters.playerId,
|
|
play: filters.play,
|
|
operatorId: filters.operatorId,
|
|
drawId: filters.drawId,
|
|
drawNo: filters.drawNo,
|
|
number: filters.number,
|
|
});
|
|
const job = await postAdminReportJob({
|
|
report_type: REPORT_UI_TO_JOB_TYPE[selectedReport.key as ReportUiKey],
|
|
export_format: format === "excel" ? "xlsx" : "csv",
|
|
parameters,
|
|
});
|
|
const { blob, filename } = await downloadAdminReportJob(job.id);
|
|
const ext = job.export_format === "xlsx" ? "xlsx" : "csv";
|
|
downloadBlob(blob, filename ?? `${exportFileBase}.${ext}`);
|
|
toast.success(
|
|
t("exportSuccess", {
|
|
report: t(`items.${selectedReport.key}.title`),
|
|
format: t(`formats.${format}`),
|
|
}),
|
|
);
|
|
} catch (err) {
|
|
toast.error(err instanceof LotteryApiBizError ? err.message : t("exportFailed"));
|
|
} finally {
|
|
setExporting(null);
|
|
}
|
|
}
|
|
|
|
const renderSearchPicker = (kind: SearchKind) => {
|
|
const value =
|
|
kind === "draw" ? filters.drawNo : kind === "player" ? filters.player : filters.operator;
|
|
const labelKey = kind === "draw" ? "drawNo" : kind === "player" ? "player" : "operator";
|
|
const open = search.open === kind;
|
|
|
|
return (
|
|
<div className="grid gap-1.5">
|
|
<Label htmlFor={`report-${kind}`}>{t(`fields.${labelKey}`)}</Label>
|
|
<Popover
|
|
open={open}
|
|
onOpenChange={(nextOpen) => {
|
|
setSearch((prev) =>
|
|
nextOpen
|
|
? {
|
|
...prev,
|
|
open: kind,
|
|
query: value,
|
|
}
|
|
: emptySearch,
|
|
);
|
|
}}
|
|
modal={false}
|
|
>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
id={`report-${kind}`}
|
|
value={value}
|
|
onChange={(e) => {
|
|
const next = e.target.value;
|
|
if (kind === "draw") {
|
|
setFilters((prev) => ({ ...prev, drawNo: next, drawId: null }));
|
|
} else if (kind === "player") {
|
|
setFilters((prev) => ({ ...prev, player: next, playerId: null }));
|
|
} else {
|
|
setFilters((prev) => ({ ...prev, operator: next, operatorId: null }));
|
|
}
|
|
}}
|
|
placeholder={t(`placeholders.${labelKey}`)}
|
|
/>
|
|
<PopoverTrigger render={<Button type="button" variant="outline" className="shrink-0" aria-label={t("searchPicker.open")} />}>
|
|
<Search data-icon="inline-start" />
|
|
{t("searchPicker.select")}
|
|
</PopoverTrigger>
|
|
</div>
|
|
<PopoverContent
|
|
align="start"
|
|
side="bottom"
|
|
sideOffset={6}
|
|
className="z-[80] w-[var(--anchor-width)] min-w-[min(24rem,calc(100vw-2rem))] max-w-[calc(100vw-2rem)] p-2"
|
|
>
|
|
<Input
|
|
value={search.query}
|
|
placeholder={t("searchPicker.keyword")}
|
|
onChange={(e) => setSearch((prev) => ({ ...prev, query: e.target.value }))}
|
|
/>
|
|
<div className="mt-2 max-h-64 overflow-auto">
|
|
{search.loading ? (
|
|
<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>
|
|
<span className="text-xs text-muted-foreground">{item.status}</span>
|
|
</button>
|
|
))
|
|
) : null}
|
|
{!search.loading && kind === "player" ? (
|
|
search.players.map((item) => (
|
|
<button
|
|
key={item.id}
|
|
type="button"
|
|
className="flex w-full items-center justify-between gap-3 rounded-md px-2 py-2 text-left text-sm hover:bg-muted"
|
|
onClick={() => {
|
|
setFilters((prev) => ({
|
|
...prev,
|
|
player: optionText(item.username, item.site_player_id, `ID ${item.id}`),
|
|
playerId: item.id,
|
|
}));
|
|
setSearch(emptySearch);
|
|
}}
|
|
>
|
|
<span className="min-w-0 truncate font-medium">{optionText(item.username, item.nickname, item.site_player_id)}</span>
|
|
<span className="shrink-0 text-xs text-muted-foreground">ID {item.id}</span>
|
|
</button>
|
|
))
|
|
) : null}
|
|
{!search.loading && kind === "operator" ? (
|
|
search.operators.map((item) => (
|
|
<button
|
|
key={item.id}
|
|
type="button"
|
|
className="flex w-full items-center justify-between gap-3 rounded-md px-2 py-2 text-left text-sm hover:bg-muted"
|
|
onClick={() => {
|
|
setFilters((prev) => ({
|
|
...prev,
|
|
operator: optionText(item.username, item.nickname, `ID ${item.id}`),
|
|
operatorId: item.id,
|
|
}));
|
|
setSearch(emptySearch);
|
|
}}
|
|
>
|
|
<span className="min-w-0 truncate font-medium">{optionText(item.username, item.nickname)}</span>
|
|
<span className="shrink-0 text-xs text-muted-foreground">ID {item.id}</span>
|
|
</button>
|
|
))
|
|
) : null}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const renderField = (field: FieldKey) => {
|
|
if (field === "period") {
|
|
return (
|
|
<AdminDateRangeField
|
|
key={field}
|
|
id="report-date-range"
|
|
label={t("fields.period")}
|
|
from={filters.dateFrom}
|
|
to={filters.dateTo}
|
|
onRangeChange={({ from, to }) => {
|
|
setFilters((prev) => ({ ...prev, dateFrom: from, dateTo: to }));
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
if (field === "drawNo") {
|
|
return <div key={field}>{renderSearchPicker("draw")}</div>;
|
|
}
|
|
if (field === "player") {
|
|
return <div key={field}>{renderSearchPicker("player")}</div>;
|
|
}
|
|
if (field === "operator") {
|
|
return <div key={field}>{renderSearchPicker("operator")}</div>;
|
|
}
|
|
if (field === "play") {
|
|
return (
|
|
<div key={field} className="grid gap-1.5">
|
|
<Label htmlFor="report-play">{t("fields.play")}</Label>
|
|
<Select
|
|
modal={false}
|
|
value={filters.play || "__none__"}
|
|
onValueChange={(value) => updateFilter("play", value === "__none__" ? "" : String(value))}
|
|
>
|
|
<SelectTrigger id="report-play" className="h-8 w-full">
|
|
<SelectValue>{filters.play ? playOptions.find((item) => item.code === filters.play)?.label ?? filters.play : t("placeholders.play")}</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent align="start" sideOffset={6}>
|
|
<SelectItem value="__none__">{t("filterAll")}</SelectItem>
|
|
{playOptions.map((item) => (
|
|
<SelectItem key={item.code} value={item.code}>
|
|
{item.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div key={field} className="grid gap-1.5">
|
|
<Label htmlFor={`report-${field}`}>{t(`fields.${field}`)}</Label>
|
|
<Input
|
|
id={`report-${field}`}
|
|
value={filters.number}
|
|
onChange={(e) => updateFilter("number", e.target.value)}
|
|
placeholder={t(`placeholders.${field}`)}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const renderTable = () => {
|
|
if (!selectedReport.connected) {
|
|
return (
|
|
<TableRow>
|
|
<TableCell colSpan={8} className="text-muted-foreground">
|
|
{t("backendPending")}
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
}
|
|
if (loading) {
|
|
return (
|
|
<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} />
|
|
);
|
|
}
|
|
|
|
if (result.key === "draw_profit") {
|
|
const summary = result.raw;
|
|
return (
|
|
<>
|
|
<TableRow>
|
|
<TableCell className="font-medium">{summary.draw_no}</TableCell>
|
|
<TableCell>{summary.draw_status}</TableCell>
|
|
<TableCell className="text-center">{summary.order_count}</TableCell>
|
|
<TableCell className="text-center">{summary.ticket_item_count}</TableCell>
|
|
<TableCell className="text-center">{formatPlainMoney(summary.total_bet_minor, summary.currency_code)}</TableCell>
|
|
<TableCell className="text-center">{formatPlainMoney(summary.total_payout_minor, summary.currency_code)}</TableCell>
|
|
<TableCell className={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>{batch.status}</TableCell>
|
|
<TableCell className="text-center">{batch.total_ticket_count}</TableCell>
|
|
<TableCell className="text-center">{batch.total_win_count}</TableCell>
|
|
<TableCell className="text-center">-</TableCell>
|
|
<TableCell className="text-center">{formatPlainMoney(batch.total_payout_amount, summary.currency_code)}</TableCell>
|
|
<TableCell className="text-center">{formatPlainMoney(batch.total_jackpot_payout_amount, summary.currency_code)}</TableCell>
|
|
<TableCell>{formatTs(batch.finished_at)}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</>
|
|
);
|
|
}
|
|
|
|
if (result.key === "player_transfer") {
|
|
return result.raw.map((item) => (
|
|
<TableRow key={item.id}>
|
|
<TableCell className="font-mono text-xs">{item.transfer_no}</TableCell>
|
|
<TableCell>{optionText(item.username, item.nickname) || item.player_id}</TableCell>
|
|
<TableCell>{item.direction}</TableCell>
|
|
<TableCell>{item.status}</TableCell>
|
|
<TableCell className="text-center">{item.currency_code} {item.amount}</TableCell>
|
|
<TableCell>{item.external_ref_no || "-"}</TableCell>
|
|
<TableCell>{item.fail_reason || "-"}</TableCell>
|
|
<TableCell>{formatTs(item.created_at)}</TableCell>
|
|
</TableRow>
|
|
));
|
|
}
|
|
|
|
if (result.key === "hot_number_risk") {
|
|
return (
|
|
<>
|
|
<TableRow>
|
|
<TableCell className="font-medium">{result.raw.pool.normalized_number}</TableCell>
|
|
<TableCell>{result.raw.draw_no}</TableCell>
|
|
<TableCell className="text-center">{formatPlainMoney(result.raw.pool.total_cap_amount, result.raw.currency_code)}</TableCell>
|
|
<TableCell className="text-center">{formatPlainMoney(result.raw.pool.locked_amount, result.raw.currency_code)}</TableCell>
|
|
<TableCell className="text-center">{formatPlainMoney(result.raw.pool.remaining_amount, result.raw.currency_code)}</TableCell>
|
|
<TableCell>{result.raw.pool.is_sold_out ? t("yes") : t("no")}</TableCell>
|
|
<TableCell>{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 === "rebate_commission") {
|
|
return result.raw.map((item) => (
|
|
<TableRow key={item.play_code}>
|
|
<TableCell className="font-medium">{playCodeLabel(item.play_code)}</TableCell>
|
|
<TableCell>{item.order_count}</TableCell>
|
|
<TableCell className="text-center">{formatPlainMoney(item.total_rebate_minor, displayCurrency)}</TableCell>
|
|
<TableCell className="text-center">{item.ticket_item_count}</TableCell>
|
|
<TableCell>-</TableCell>
|
|
<TableCell>-</TableCell>
|
|
<TableCell>-</TableCell>
|
|
<TableCell>-</TableCell>
|
|
</TableRow>
|
|
));
|
|
}
|
|
|
|
if (result.key === "admin_audit") {
|
|
return result.raw.map((item) => (
|
|
<TableRow key={item.id}>
|
|
<TableCell className="font-mono text-xs">#{item.id}</TableCell>
|
|
<TableCell>{item.operator_type}</TableCell>
|
|
<TableCell>{item.operator_id}</TableCell>
|
|
<TableCell>{item.module_code}</TableCell>
|
|
<TableCell>{item.action_code}</TableCell>
|
|
<TableCell>{item.target_type || "-"}</TableCell>
|
|
<TableCell>{item.ip || "-"}</TableCell>
|
|
<TableCell>{formatTs(item.created_at)}</TableCell>
|
|
</TableRow>
|
|
));
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
return (
|
|
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4">
|
|
<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 gap-2 md:grid-cols-4">
|
|
{(result?.summary ?? defaultSummaryCards(selectedReport.key, filters, t)).map((item) => (
|
|
<div key={item.label} className={cn("rounded-md border px-3 py-2.5", statTone(item.tone))}>
|
|
<div className="text-xs text-muted-foreground">{item.label}</div>
|
|
<div className="mt-0.5 truncate text-base font-semibold tabular-nums">{item.value}</div>
|
|
</div>
|
|
))}
|
|
</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">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={!canExportReports || exporting !== null}
|
|
onClick={() => void exportReport("csv")}
|
|
>
|
|
<FileDown data-icon="inline-start" />
|
|
{t("formats.csv")}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
disabled={!canExportReports || exporting !== null}
|
|
onClick={() => void exportReport("excel")}
|
|
>
|
|
<FileSpreadsheet 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>
|
|
|
|
{result?.meta ? (
|
|
<AdminListPaginationFooter
|
|
selectId="reports-preview-per-page"
|
|
total={result.meta.total}
|
|
page={result.meta.page}
|
|
lastPage={result.meta.lastPage}
|
|
perPage={result.meta.perPage}
|
|
loading={loading}
|
|
onPerPageChange={(next) => {
|
|
setPerPage(next);
|
|
setPage(1);
|
|
}}
|
|
onPageChange={setPage}
|
|
/>
|
|
) : null}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|