feat(admin, i18n): enhance reports, draws, config, and player workflows
This commit is contained in:
@@ -93,7 +93,7 @@ import type {
|
||||
AdminReportRebateCommissionRow,
|
||||
} from "@/types/api/admin-reports";
|
||||
|
||||
export type ReportCategory = "profit" | "wallet" | "risk" | "audit" | "legacy";
|
||||
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";
|
||||
@@ -192,7 +192,7 @@ const REPORTS: ReportDefinition[] = [
|
||||
{ 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: "legacy", icon: CircleDollarSign, filterKind: "play_period", scope: "playPeriod", fields: ["play", "period"], 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 },
|
||||
];
|
||||
|
||||
@@ -226,8 +226,6 @@ function categoryTone(category: ReportCategory): string {
|
||||
return "border-red-200 bg-red-50 text-red-700";
|
||||
case "audit":
|
||||
return "border-slate-200 bg-slate-50 text-slate-700";
|
||||
case "legacy":
|
||||
return "border-amber-200 bg-amber-50 text-amber-800";
|
||||
default:
|
||||
return "border-blue-200 bg-blue-50 text-blue-700";
|
||||
}
|
||||
@@ -405,6 +403,90 @@ 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();
|
||||
@@ -1426,189 +1508,179 @@ export function ReportsConsole({ initialCategory }: { initialCategory?: ReportCa
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
|
||||
<div className="grid gap-5 lg:grid-cols-[18rem_minmax(0,1fr)]">
|
||||
<Card className="admin-list-card self-start">
|
||||
<CardHeader className="admin-list-header pb-4">
|
||||
<CardTitle className="admin-list-title">{t("chooseReport")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1.5 pt-3">
|
||||
{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(
|
||||
"flex w-full min-w-0 items-center gap-3 rounded-md border px-3 py-2.5 text-left transition",
|
||||
active
|
||||
? "border-primary bg-primary/[0.05] shadow-sm ring-1 ring-primary/15"
|
||||
: "border-border/80 bg-card hover:border-primary/35 hover:bg-muted/30",
|
||||
)}
|
||||
>
|
||||
<span className={cn("flex size-8 shrink-0 items-center justify-center rounded-md border", categoryTone(report.category))}>
|
||||
<Icon className="size-4" aria-hidden />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-foreground">{t(`items.${report.key}.title`)}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="min-w-0 space-y-5">
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header flex flex-col gap-3 pb-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="admin-list-title">{t("filterPanel")}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 pt-4">
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{selectedReport.fields.map(renderField)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 border-t border-border/60 pt-4 sm:flex-row sm:items-center sm:justify-end">
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Button type="button" variant="outline" onClick={resetFilters}>
|
||||
{t("reset")}
|
||||
</Button>
|
||||
<Button
|
||||
<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"
|
||||
disabled={!canViewReports || !selectedReport.connected || loading}
|
||||
onClick={() => {
|
||||
setPage(1);
|
||||
void queryReport();
|
||||
}}
|
||||
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",
|
||||
)}
|
||||
>
|
||||
<Database data-icon="inline-start" />
|
||||
{loading ? t("querying") : t("query")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-4">
|
||||
{(result?.summary ?? [
|
||||
{ label: t("preview.stats.records"), value: "-" },
|
||||
{ label: t("preview.stats.currentPage"), value: "-" },
|
||||
{ label: t("preview.stats.drawNo"), value: filters.drawNo || "-" },
|
||||
{ label: t("preview.stats.exportRows"), value: String(resultRowCount(result)) },
|
||||
]).map((item) => (
|
||||
<div key={item.label} className={cn("rounded-md border px-4 py-3", statTone(item.tone))}>
|
||||
<div className="text-xs text-muted-foreground">{item.label}</div>
|
||||
<div className="mt-1 truncate text-lg font-semibold tabular-nums">{item.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedReport.key === "rebate_commission" ? (
|
||||
<div className="rounded-md border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-950">
|
||||
{t("items.rebate_commission.disclaimer", {
|
||||
defaultValue:
|
||||
"本报表为钱包盘「下注立减回水/佣金」口径,不属于信用占成盘账期结算。占成盘请使用「代理 → 代理账单」中的账期报表。",
|
||||
<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>
|
||||
) : null}
|
||||
<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 flex-col gap-2 border-t border-border/60 pt-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="text-xs text-muted-foreground">{t("filterPanel")}</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<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>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header flex flex-col gap-3 pb-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<CardTitle className="admin-list-title">{t("preview.title")}</CardTitle>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<p className="text-xs text-muted-foreground">{t("exportServerHint")}</p>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!canExportReports || exporting !== null}
|
||||
onClick={() => exportReport("csv")}
|
||||
>
|
||||
<FileDown data-icon="inline-start" />
|
||||
{t("formats.csvServer")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!canExportReports || exporting !== null}
|
||||
onClick={() => exportReport("excel")}
|
||||
>
|
||||
<FileSpreadsheet data-icon="inline-start" />
|
||||
{t("formats.excelServer")}
|
||||
</Button>
|
||||
</div>
|
||||
{result && result.rows.length > 0 ? (
|
||||
<>
|
||||
<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>
|
||||
|
||||
{selectedReport.key === "rebate_commission" ? (
|
||||
<div className="rounded-md border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-950">
|
||||
{t("items.rebate_commission.disclaimer", {
|
||||
defaultValue:
|
||||
"本报表为钱包盘「下注立减回水/佣金」口径,不属于信用占成盘账期结算。占成盘请使用「代理 → 代理账单」中的账期报表。",
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<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-col items-end gap-1.5">
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!canExportReports || exporting !== null}
|
||||
onClick={() => exportReport("csv")}
|
||||
>
|
||||
<FileDown data-icon="inline-start" />
|
||||
{t("formats.csvServer")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={!canExportReports || exporting !== null}
|
||||
onClick={() => exportReport("excel")}
|
||||
>
|
||||
<FileSpreadsheet data-icon="inline-start" />
|
||||
{t("formats.excelServer")}
|
||||
</Button>
|
||||
</div>
|
||||
{result && result.rows.length > 0 ? (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground">{t("exportPreviewHint")}</p>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={!canExportReports || exporting !== null}
|
||||
onClick={() => exportPreview("csv")}
|
||||
>
|
||||
{t("formats.csvPreview")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={!canExportReports || exporting !== null}
|
||||
onClick={() => exportPreview("excel")}
|
||||
>
|
||||
{t("formats.excelPreview")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
size="sm"
|
||||
disabled={!canExportReports || exporting !== null}
|
||||
onClick={() => exportPreview("csv")}
|
||||
>
|
||||
{t("formats.csvPreview")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={!canExportReports || exporting !== null}
|
||||
onClick={() => exportPreview("excel")}
|
||||
>
|
||||
{t("formats.excelPreview")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 pt-4">
|
||||
<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>{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>
|
||||
<CardContent className="space-y-3 pt-3">
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50/70 px-3 py-2 text-xs text-amber-950">
|
||||
{t("preview.summaryScopeHint")}
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
{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>
|
||||
|
||||
<ReportJobsPanel canExport={canExportReports} refreshToken={jobRefreshToken} />
|
||||
<ReportJobsPanel
|
||||
canExport={canExportReports}
|
||||
refreshToken={jobRefreshToken}
|
||||
reportType={REPORT_UI_TO_JOB_TYPE[selectedReport.key as ReportUiKey]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user