feat(api, i18n): add agent_node_id to various admin queries and enhance multi-language support

Introduced the agent_node_id field in AdminDrawListQuery, AdminPlayerListQuery, AdminSettlementBatchListQuery, TicketItemsListQuery, and TransferOrderListQuery to improve filtering capabilities. Updated the admin-breadcrumb and admin-sidebar components to include new translations for agent-related terms in English, Nepali, and Chinese, enhancing the overall user experience and multi-language support across the admin interface.
This commit is contained in:
2026-06-02 14:37:08 +08:00
parent a4e7a2d228
commit b15e377187
105 changed files with 5305 additions and 1596 deletions

View File

@@ -20,13 +20,10 @@ import {
} from "lucide-react";
import { getAdminAuditLogs } from "@/api/admin-audit";
import { getAdminPlayTypes } from "@/api/admin-config";
import { useAdminPlayCodeLabel, useAdminPlayTypeCatalog } from "@/hooks/use-admin-play-type-catalog";
import {
getAdminPlayTypesLoadPromise,
getCachedAdminPlayTypes,
resolveAdminPlayTypeDisplayName,
} from "@/lib/admin-play-types";
import { 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";
@@ -49,6 +46,8 @@ 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 { AdminAgentFilter } from "@/components/admin/admin-agent-filter";
import { adminAgentDisplayLabel } from "@/components/admin/admin-agent-columns";
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { Button } from "@/components/ui/button";
@@ -56,6 +55,7 @@ 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,
@@ -121,12 +121,24 @@ type ReportDefinition = {
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;
agentNodeId: number | undefined;
play: string;
operator: string;
operatorId: number | null;
@@ -190,6 +202,7 @@ const emptyFilters: ReportFilters = {
number: "",
player: "",
playerId: null,
agentNodeId: undefined,
play: "",
operator: "",
operatorId: null,
@@ -302,6 +315,10 @@ function formatPlainMoney(value: number, currencyCode: string | null | undefined
return formatAdminMinorUnits(value, currencyCode || "NPR");
}
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(" / ");
}
@@ -314,6 +331,7 @@ function reportListParams(filters: ReportFilters, page: number, perPage: number)
date_to: filters.dateTo || undefined,
player_id: filters.playerId ?? undefined,
play_code: filters.play.trim() || undefined,
agent_node_id: filters.agentNodeId,
};
}
@@ -328,23 +346,22 @@ function parsePositiveInteger(value: string): number | null {
async function resolveDraw(
filters: ReportFilters,
t: (key: string, options?: { ns?: string; drawNo?: string }) => string,
messages: { drawNoRequired: string; drawNoNotFound: (drawNo: string) => string },
): Promise<{ id: number; draw_no: string }> {
if (filters.drawId && filters.drawNo.trim()) {
return { id: filters.drawId, draw_no: filters.drawNo.trim() };
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(t("validation.drawNoRequired", { ns: "reports" }), -1, null);
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(t("validation.drawNoNotFound", { ns: "reports", drawNo }), -1, {
drawNo,
});
throw new LotteryApiBizError(messages.drawNoNotFound(drawNo), -1, { drawNo });
}
return { id: matched.id, draw_no: matched.draw_no };
}
@@ -403,10 +420,131 @@ export function ReportsConsole() {
const [exporting, setExporting] = useState<ExportFormat | null>(null);
const [jobRefreshToken, setJobRefreshToken] = useState(0);
const [search, setSearch] = useState<SearchState>(emptySearch);
const [playOptions, setPlayOptions] = useState<PlayOption[]>([]);
const playOptions = useCachedPlayTypeOptions();
const tRef = useTranslationRef(["reports", "common"]);
const selectedReport = REPORTS.find((report) => report.key === selectedKey) ?? REPORTS[0];
const pageScopedLabel = useCallback(
(statKey: string) => `${t(`preview.stats.${statKey}`)} · ${t("preview.scope.currentPage")}`,
[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());
@@ -419,29 +557,6 @@ export function ReportsConsole() {
return normalizeFilenamePart(segments.join("-")) || selectedReport.key;
}, [selectedReport.key, filters]);
const loadPlayOptions = useCallback(async () => {
try {
await getAdminPlayTypesLoadPromise(getAdminPlayTypes);
setPlayOptions(
getCachedAdminPlayTypes().map((item) => ({
code: item.play_code,
label: optionText(
resolveAdminPlayTypeDisplayName(item.play_code, i18n.language, item),
item.play_code,
),
})),
);
} catch {
setPlayOptions([]);
}
}, [i18n.language]);
useEffect(() => {
queueMicrotask(() => {
void loadPlayOptions();
});
}, [loadPlayOptions]);
const loadSearchOptions = useCallback(async (kind: SearchKind, query: string) => {
setSearch((prev) => ({ ...prev, loading: true }));
try {
@@ -481,7 +596,11 @@ export function ReportsConsole() {
try {
switch (selectedReport.key) {
case "draw_profit": {
const draw = await resolveDraw(filters, t);
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);
setResult({
key: "draw_profit",
@@ -519,10 +638,10 @@ export function ReportsConsole() {
meta: metaFromList(payload.meta),
summary: [
{ label: t("preview.stats.records"), value: String(payload.meta.total) },
{ label: t("preview.stats.bet"), value: formatPlainMoney(totalBet, "NPR") },
{ label: t("preview.stats.payout"), value: formatPlainMoney(totalPayout, "NPR") },
{ label: pageScopedLabel("bet"), value: formatPlainMoney(totalBet, "NPR") },
{ label: pageScopedLabel("payout"), value: formatPlainMoney(totalPayout, "NPR") },
{
label: t("preview.stats.houseGross"),
label: pageScopedLabel("houseGross"),
value: formatPlainMoney(totalGross, "NPR"),
tone: totalGross >= 0 ? "good" : "bad",
},
@@ -548,7 +667,7 @@ export function ReportsConsole() {
{ label: t("preview.stats.records"), value: String(payload.meta.total) },
{ label: t("preview.stats.currentPage"), value: String(payload.items.length) },
{
label: t("preview.stats.houseGross"),
label: pageScopedLabel("houseGross"),
value: formatPlainMoney(
payload.items.reduce((sum, item) => sum - item.net_win_loss_minor, 0),
"NPR",
@@ -592,17 +711,21 @@ export function ReportsConsole() {
summary: [
{ label: t("preview.stats.records"), value: String(payload.total) },
{ label: t("preview.stats.currentPage"), value: String(payload.items.length) },
{ label: t("preview.stats.transferIn"), value: String(payload.items.filter((item) => item.direction === "in").length), tone: "good" },
{ label: t("preview.stats.transferOut"), value: String(payload.items.filter((item) => item.direction === "out").length), tone: "warn" },
{ label: pageScopedLabel("transferIn"), value: String(payload.items.filter((item) => item.direction === "in").length), tone: "good" },
{ label: pageScopedLabel("transferOut"), value: String(payload.items.filter((item) => item.direction === "out").length), tone: "warn" },
],
});
break;
}
case "hot_number_risk": {
if (!filters.number.trim()) {
throw new LotteryApiBizError(t("validation.drawNoNumberRequired"), -1, null);
throw new LotteryApiBizError(tRef.current("validation.drawNoNumberRequired"), -1, null);
}
const draw = await resolveDraw(filters, t);
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 });
const rows: ExportRow[] = [
{
@@ -642,14 +765,18 @@ export function ReportsConsole() {
summary: [
{ label: t("preview.stats.locked"), value: formatPlainMoney(detail.pool.locked_amount, detail.currency_code) },
{ label: t("preview.stats.remaining"), value: formatPlainMoney(detail.pool.remaining_amount, detail.currency_code), tone: detail.pool.is_sold_out ? "bad" : "good" },
{ label: t("preview.stats.usage"), value: detail.pool.usage_ratio == null ? "-" : `${detail.pool.usage_ratio}%`, tone: detail.pool.is_sold_out ? "bad" : "warn" },
{ label: t("preview.stats.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, t);
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" });
const rows = payload.items.map((item) => ({
draw_id: payload.draw_id,
@@ -695,8 +822,8 @@ export function ReportsConsole() {
summary: [
{ label: t("preview.stats.records"), value: String(payload.meta.total) },
{ label: t("preview.stats.currentPage"), value: String(payload.items.length) },
{ label: t("preview.stats.bet"), value: formatPlainMoney(payload.items.reduce((s, i) => s + i.total_bet_minor, 0), "NPR") },
{ label: t("preview.stats.payout"), value: formatPlainMoney(payload.items.reduce((s, i) => s + i.total_payout_minor, 0), "NPR") },
{ label: pageScopedLabel("bet"), value: formatPlainMoney(payload.items.reduce((s, i) => s + i.total_bet_minor, 0), "NPR") },
{ label: pageScopedLabel("payout"), value: formatPlainMoney(payload.items.reduce((s, i) => s + i.total_payout_minor, 0), "NPR") },
],
});
break;
@@ -717,8 +844,8 @@ export function ReportsConsole() {
summary: [
{ label: t("preview.stats.records"), value: String(payload.meta.total) },
{ label: t("preview.stats.currentPage"), value: String(payload.items.length) },
{ label: t("preview.stats.rebate"), value: formatPlainMoney(payload.items.reduce((s, i) => s + i.total_rebate_minor, 0), "NPR") },
{ label: t("preview.stats.orders"), value: String(payload.items.reduce((s, i) => s + i.order_count, 0)) },
{ label: pageScopedLabel("rebate"), value: formatPlainMoney(payload.items.reduce((s, i) => s + i.total_rebate_minor, 0), "NPR") },
{ label: pageScopedLabel("orders"), value: String(payload.items.reduce((s, i) => s + i.order_count, 0)) },
],
});
break;
@@ -761,15 +888,15 @@ export function ReportsConsole() {
}
default:
setResult(null);
setError(t("loadFailed"));
setError(tRef.current("loadFailed"));
}
} catch (err) {
setResult(null);
setError(err instanceof LotteryApiBizError ? err.message : t("loadFailed"));
setError(err instanceof LotteryApiBizError ? err.message : tRef.current("loadFailed"));
} finally {
setLoading(false);
}
}, [canViewReports, filters, page, perPage, selectedReport, t]);
}, [canViewReports, filters, page, perPage, selectedReport]);
useEffect(() => {
queueMicrotask(() => {
@@ -928,7 +1055,7 @@ export function ReportsConsole() {
/>
<div className="mt-2 max-h-64 overflow-auto">
{search.loading ? (
<p className="px-2 py-2 text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
<AdminLoadingInline className="py-2" />
) : null}
{!search.loading && kind === "draw" ? (
search.draws.map((item) => (
@@ -1067,11 +1194,7 @@ export function ReportsConsole() {
}
if (loading) {
return (
<TableRow>
<TableCell colSpan={8} className="text-muted-foreground">
{t("states.loading", { ns: "common" })}
</TableCell>
</TableRow>
<AdminTableLoadingRow colSpan={8} />
);
}
if (error) {
@@ -1148,7 +1271,7 @@ export function ReportsConsole() {
<TableCell className="text-center">{formatPlainMoney(result.raw.pool.locked_amount, result.raw.currency_code)}</TableCell>
<TableCell className="text-center">{formatPlainMoney(result.raw.pool.remaining_amount, result.raw.currency_code)}</TableCell>
<TableCell>{result.raw.pool.is_sold_out ? t("yes") : t("no")}</TableCell>
<TableCell>{result.raw.pool.usage_ratio == null ? "-" : `${result.raw.pool.usage_ratio}%`}</TableCell>
<TableCell>{formatUsagePercent(result.raw.pool.usage_ratio)}</TableCell>
<TableCell>v{result.raw.pool.version}</TableCell>
</TableRow>
{result.raw.logs.items.map((item) => (
@@ -1176,7 +1299,7 @@ export function ReportsConsole() {
<TableCell className="text-center">{formatPlainMoney(item.locked_amount, null)}</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.remaining_amount, null)}</TableCell>
<TableCell>{item.is_sold_out ? t("yes") : t("no")}</TableCell>
<TableCell>{item.usage_ratio == null ? "-" : `${item.usage_ratio}%`}</TableCell>
<TableCell>{formatUsagePercent(item.usage_ratio)}</TableCell>
<TableCell>v{item.version}</TableCell>
</TableRow>
));
@@ -1201,7 +1324,10 @@ export function ReportsConsole() {
return result.raw.map((item) => (
<TableRow key={item.player_id}>
<TableCell className="font-medium">{item.username}</TableCell>
<TableCell>ID {item.player_id}</TableCell>
<TableCell className="text-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, "NPR")}</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.total_payout_minor, "NPR")}</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.net_win_loss_minor, "NPR")}</TableCell>
@@ -1305,6 +1431,13 @@ export function ReportsConsole() {
<CardContent className="space-y-4 pt-4">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{selectedReport.fields.map(renderField)}
{selectedReport.category === "profit" || selectedReport.category === "wallet" ? (
<AdminAgentFilter
id="report-agent-filter"
value={filters.agentNodeId}
onChange={(id) => setFilters((prev) => ({ ...prev, agentNodeId: id }))}
/>
) : null}
</div>
<div className="flex flex-col gap-3 border-t border-border/60 pt-4 sm:flex-row sm:items-center sm:justify-end">
<div className="flex shrink-0 gap-2">
@@ -1395,17 +1528,20 @@ export function ReportsConsole() {
</div>
</CardHeader>
<CardContent className="space-y-4 pt-4">
<div className="rounded-md border border-amber-200 bg-amber-50/70 px-4 py-3 text-sm text-amber-950">
{t("preview.summaryScopeHint")}
</div>
<Table id="reports-preview-table">
<TableHeader>
<TableRow>
<TableHead>{t("preview.columns.primary")}</TableHead>
<TableHead>{t("preview.columns.secondary")}</TableHead>
<TableHead className="text-center">{t("preview.columns.metricA")}</TableHead>
<TableHead className="text-center">{t("preview.columns.metricB")}</TableHead>
<TableHead className="text-center">{t("preview.columns.metricC")}</TableHead>
<TableHead>{t("preview.columns.status")}</TableHead>
<TableHead>{t("preview.columns.extra")}</TableHead>
<TableHead>{t("preview.columns.time")}</TableHead>
<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>