feat: 重构管理端列表与风控/结算导航,新增表格导出和结算审核确认
This commit is contained in:
103
package-lock.json
generated
103
package-lock.json
generated
@@ -26,6 +26,7 @@
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"xlsx": "^0.18.5",
|
||||
"zustand": "^5.0.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -3148,6 +3149,14 @@
|
||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/adler-32": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/adler-32/-/adler-32-1.3.1.tgz",
|
||||
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz",
|
||||
@@ -3708,6 +3717,19 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/cfb": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/cfb/-/cfb-1.2.2.tgz",
|
||||
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"crc-32": "~1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz",
|
||||
@@ -3849,6 +3871,15 @@
|
||||
"integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/codepage": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/codepage/-/codepage-1.15.0.tgz",
|
||||
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -3984,6 +4015,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/crc-32": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/crc-32/-/crc-32-1.2.2.tgz",
|
||||
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"crc32": "bin/crc32.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -5429,6 +5472,15 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/frac": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/frac/-/frac-1.1.2.tgz",
|
||||
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/fresh": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/fresh/-/fresh-2.0.0.tgz",
|
||||
@@ -9013,6 +9065,18 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ssf": {
|
||||
"version": "0.11.2",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/ssf/-/ssf-0.11.2.tgz",
|
||||
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"frac": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/stable-hash": {
|
||||
"version": "0.0.5",
|
||||
"resolved": "https://registry.npmmirror.com/stable-hash/-/stable-hash-0.0.5.tgz",
|
||||
@@ -10001,6 +10065,24 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/wmf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/wmf/-/wmf-1.0.2.tgz",
|
||||
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/word": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/word/-/word-0.3.0.tgz",
|
||||
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/word-wrap": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
@@ -10091,6 +10173,27 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/xlsx": {
|
||||
"version": "0.18.5",
|
||||
"resolved": "https://mirrors.cloud.tencent.com/npm/xlsx/-/xlsx-0.18.5.tgz",
|
||||
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"cfb": "~1.2.1",
|
||||
"codepage": "~1.15.0",
|
||||
"crc-32": "~1.2.1",
|
||||
"ssf": "~0.11.2",
|
||||
"wmf": "~1.0.1",
|
||||
"word": "~0.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"xlsx": "bin/xlsx.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz",
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"xlsx": "^0.18.5",
|
||||
"zustand": "^5.0.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
17
src/app/admin/(shell)/draws/[drawId]/risk/hot/page.tsx
Normal file
17
src/app/admin/(shell)/draws/[drawId]/risk/hot/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { RiskPoolsConsole } from "@/modules/risk/risk-pools-console";
|
||||
|
||||
export default async function AdminDrawRiskHotPage(props: {
|
||||
params: Promise<{ drawId: string }>;
|
||||
}) {
|
||||
const { drawId } = await props.params;
|
||||
const id = Number(drawId);
|
||||
|
||||
return (
|
||||
<RiskPoolsConsole
|
||||
drawId={id}
|
||||
title="热门号码监控"
|
||||
soldOutOnly={false}
|
||||
defaultSort="usage_desc"
|
||||
/>
|
||||
);
|
||||
}
|
||||
10
src/app/admin/(shell)/draws/[drawId]/risk/occupancy/page.tsx
Normal file
10
src/app/admin/(shell)/draws/[drawId]/risk/occupancy/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { RiskLockLogsConsole } from "@/modules/risk/risk-lock-logs-console";
|
||||
|
||||
export default async function AdminDrawRiskOccupancyPage(props: {
|
||||
params: Promise<{ drawId: string }>;
|
||||
}) {
|
||||
const { drawId } = await props.params;
|
||||
const id = Number(drawId);
|
||||
|
||||
return <RiskLockLogsConsole drawId={id} />;
|
||||
}
|
||||
9
src/app/admin/(shell)/draws/[drawId]/risk/page.tsx
Normal file
9
src/app/admin/(shell)/draws/[drawId]/risk/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function AdminDrawRiskIndexPage(props: {
|
||||
params: Promise<{ drawId: string }>;
|
||||
}) {
|
||||
const { drawId } = await props.params;
|
||||
|
||||
redirect(`/admin/draws/${drawId}/risk/occupancy`);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { RiskPoolDetailConsole } from "@/modules/risk/risk-pool-detail-console";
|
||||
|
||||
export default async function AdminDrawRiskPoolDetailPage(props: {
|
||||
params: Promise<{ drawId: string; number: string }>;
|
||||
}) {
|
||||
const { drawId, number } = await props.params;
|
||||
const id = Number(drawId);
|
||||
|
||||
return <RiskPoolDetailConsole drawId={id} number4d={number} />;
|
||||
}
|
||||
18
src/app/admin/(shell)/draws/[drawId]/risk/pools/page.tsx
Normal file
18
src/app/admin/(shell)/draws/[drawId]/risk/pools/page.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { RiskPoolsConsole } from "@/modules/risk/risk-pools-console";
|
||||
|
||||
export default async function AdminDrawRiskPoolsPage(props: {
|
||||
params: Promise<{ drawId: string }>;
|
||||
}) {
|
||||
const { drawId } = await props.params;
|
||||
const id = Number(drawId);
|
||||
|
||||
return (
|
||||
<RiskPoolsConsole
|
||||
drawId={id}
|
||||
title="全部风险池"
|
||||
soldOutOnly={false}
|
||||
defaultSort="number_asc"
|
||||
allowSortChange
|
||||
/>
|
||||
);
|
||||
}
|
||||
17
src/app/admin/(shell)/draws/[drawId]/risk/sold-out/page.tsx
Normal file
17
src/app/admin/(shell)/draws/[drawId]/risk/sold-out/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { RiskPoolsConsole } from "@/modules/risk/risk-pools-console";
|
||||
|
||||
export default async function AdminDrawRiskSoldOutPage(props: {
|
||||
params: Promise<{ drawId: string }>;
|
||||
}) {
|
||||
const { drawId } = await props.params;
|
||||
const id = Number(drawId);
|
||||
|
||||
return (
|
||||
<RiskPoolsConsole
|
||||
drawId={id}
|
||||
title="售罄号码列表"
|
||||
soldOutOnly
|
||||
defaultSort="number_asc"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,8 @@
|
||||
import { RiskPoolsConsole } from "@/modules/risk/risk-pools-console";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function AdminRiskHotPage(props: {
|
||||
params: Promise<{ drawId: string }>;
|
||||
}) {
|
||||
const { drawId } = await props.params;
|
||||
const id = Number(drawId);
|
||||
|
||||
return (
|
||||
<RiskPoolsConsole
|
||||
drawId={id}
|
||||
title="热门号码监控"
|
||||
soldOutOnly={false}
|
||||
defaultSort="usage_desc"
|
||||
/>
|
||||
);
|
||||
redirect(`/admin/draws/${drawId}/risk/hot`);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { RiskLockLogsConsole } from "@/modules/risk/risk-lock-logs-console";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function AdminRiskOccupancyPage(props: {
|
||||
params: Promise<{ drawId: string }>;
|
||||
}) {
|
||||
const { drawId } = await props.params;
|
||||
const id = Number(drawId);
|
||||
|
||||
return <RiskLockLogsConsole drawId={id} />;
|
||||
redirect(`/admin/draws/${drawId}/risk/occupancy`);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { RiskPoolDetailConsole } from "@/modules/risk/risk-pool-detail-console";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function AdminRiskPoolDetailPage(props: {
|
||||
params: Promise<{ drawId: string; number: string }>;
|
||||
}) {
|
||||
const { drawId, number } = await props.params;
|
||||
const id = Number(drawId);
|
||||
|
||||
return <RiskPoolDetailConsole drawId={id} number4d={number} />;
|
||||
redirect(`/admin/draws/${drawId}/risk/pools/${number}`);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
import { RiskPoolsConsole } from "@/modules/risk/risk-pools-console";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function AdminRiskPoolsPage(props: {
|
||||
params: Promise<{ drawId: string }>;
|
||||
}) {
|
||||
const { drawId } = await props.params;
|
||||
const id = Number(drawId);
|
||||
|
||||
return (
|
||||
<RiskPoolsConsole
|
||||
drawId={id}
|
||||
title="全部风险池"
|
||||
soldOutOnly={false}
|
||||
defaultSort="number_asc"
|
||||
allowSortChange
|
||||
/>
|
||||
);
|
||||
redirect(`/admin/draws/${drawId}/risk/pools`);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
import { RiskPoolsConsole } from "@/modules/risk/risk-pools-console";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function AdminRiskSoldOutPage(props: {
|
||||
params: Promise<{ drawId: string }>;
|
||||
}) {
|
||||
const { drawId } = await props.params;
|
||||
const id = Number(drawId);
|
||||
|
||||
return (
|
||||
<RiskPoolsConsole
|
||||
drawId={id}
|
||||
title="售罄号码列表"
|
||||
soldOutOnly
|
||||
defaultSort="number_asc"
|
||||
/>
|
||||
);
|
||||
redirect(`/admin/draws/${drawId}/risk/sold-out`);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||
import { RiskIndexConsole } from "@/modules/risk/risk-index-console";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function AdminRiskIndexPage() {
|
||||
return (
|
||||
<ModuleScaffold className="w-full max-w-none">
|
||||
<RiskIndexConsole />
|
||||
</ModuleScaffold>
|
||||
);
|
||||
redirect("/admin/draws");
|
||||
}
|
||||
|
||||
@@ -128,3 +128,56 @@
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.admin-list-card {
|
||||
@apply overflow-hidden border-border/80 shadow-sm;
|
||||
}
|
||||
|
||||
.admin-list-header {
|
||||
@apply border-b bg-muted/20 pb-4;
|
||||
}
|
||||
|
||||
.admin-list-title {
|
||||
@apply text-lg font-semibold tracking-tight;
|
||||
}
|
||||
|
||||
.admin-list-content {
|
||||
@apply space-y-4;
|
||||
}
|
||||
|
||||
.admin-list-toolbar {
|
||||
@apply flex w-full flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center;
|
||||
}
|
||||
|
||||
.admin-list-field {
|
||||
@apply flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center sm:shrink-0;
|
||||
}
|
||||
|
||||
.admin-list-actions {
|
||||
@apply flex shrink-0 flex-wrap gap-2;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.admin-table-shell {
|
||||
@apply overflow-x-auto rounded-xl border border-border bg-background;
|
||||
}
|
||||
|
||||
.admin-table-toolbar {
|
||||
@apply flex items-center justify-end border-b border-border bg-muted/15 px-3 py-2.5;
|
||||
}
|
||||
|
||||
.admin-inline-note {
|
||||
@apply text-sm text-muted-foreground;
|
||||
}
|
||||
|
||||
[data-slot="table-cell"]:has(> [data-slot="button"]),
|
||||
[data-slot="table-cell"]:has(> a) {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
[data-slot="table-cell"] > .flex:has([data-slot="button"]),
|
||||
[data-slot="table-cell"] > .flex:has(a) {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,10 @@ export function AdminAppSidebar() {
|
||||
const { t } = useTranslation(["common", "dashboard", "players", "draws", "config", "wallet", "risk", "settlement", "jackpot", "reconcile", "tickets", "reports", "audit"]);
|
||||
const pathname = usePathname();
|
||||
const profile = useAdminProfile();
|
||||
const visibleNav = useMemo(() => profile?.navigation ?? [], [profile?.navigation]);
|
||||
const visibleNav = useMemo(
|
||||
() => (profile?.navigation ?? []).filter((item) => item.segment !== "risk"),
|
||||
[profile?.navigation],
|
||||
);
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon" className="overflow-hidden">
|
||||
|
||||
99
src/components/admin/admin-table-export-button.tsx
Normal file
99
src/components/admin/admin-table-export-button.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { Download } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import * as XLSX from "xlsx";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const OMIT_HEADER_TOKENS = [
|
||||
"actions",
|
||||
"action",
|
||||
"操作",
|
||||
"下载",
|
||||
"download",
|
||||
] as const;
|
||||
|
||||
function shouldOmitColumn(headerText: string): boolean {
|
||||
const normalized = headerText.trim().toLowerCase();
|
||||
if (normalized === "") {
|
||||
return true;
|
||||
}
|
||||
return OMIT_HEADER_TOKENS.some((token) => normalized.includes(token));
|
||||
}
|
||||
|
||||
function stripOmittedColumns(table: HTMLTableElement): HTMLTableElement {
|
||||
const clone = table.cloneNode(true) as HTMLTableElement;
|
||||
const headerRow = clone.querySelector("thead tr");
|
||||
if (!headerRow) {
|
||||
return clone;
|
||||
}
|
||||
|
||||
const omitIndexes = Array.from(headerRow.children)
|
||||
.map((cell, index) => ({
|
||||
index,
|
||||
text: cell.textContent ?? "",
|
||||
}))
|
||||
.filter((item) => shouldOmitColumn(item.text))
|
||||
.map((item) => item.index)
|
||||
.sort((a, b) => b - a);
|
||||
|
||||
if (omitIndexes.length === 0) {
|
||||
return clone;
|
||||
}
|
||||
|
||||
clone.querySelectorAll("tr").forEach((row) => {
|
||||
const cells = Array.from(row.children);
|
||||
omitIndexes.forEach((index) => {
|
||||
const target = cells[index];
|
||||
if (target) {
|
||||
target.remove();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return clone;
|
||||
}
|
||||
|
||||
export function AdminTableExportButton({
|
||||
tableId,
|
||||
filename,
|
||||
sheetName = "Sheet1",
|
||||
label,
|
||||
}: {
|
||||
tableId: string;
|
||||
filename: string;
|
||||
sheetName?: string;
|
||||
label?: string;
|
||||
}) {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
const onExport = () => {
|
||||
const table = document.getElementById(tableId);
|
||||
if (!(table instanceof HTMLTableElement)) {
|
||||
toast.error(t("errors.loadFailed", { defaultValue: "导出失败" }));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const exportTable = stripOmittedColumns(table);
|
||||
const workbook = XLSX.utils.table_to_book(exportTable, {
|
||||
sheet: sheetName,
|
||||
raw: true,
|
||||
});
|
||||
const safeName = filename.endsWith(".xlsx") ? filename : `${filename}.xlsx`;
|
||||
XLSX.writeFile(workbook, safeName);
|
||||
toast.success(t("actions.done", { defaultValue: "已导出" }));
|
||||
} catch {
|
||||
toast.error(t("errors.loadFailed", { defaultValue: "导出失败" }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button type="button" size="sm" variant="secondary" className="h-8 px-3" onClick={onExport}>
|
||||
<Download className="size-4" />
|
||||
{label ?? t("actions.exportExcel", { defaultValue: "导出 Excel" })}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,13 @@
|
||||
{
|
||||
"title": "Reconcile",
|
||||
"createTitle": "Create reconcile job",
|
||||
"createDesc": "Abnormal flows are checked automatically by scheduled jobs. This section allows finance to trigger jobs manually: choose reconcile type and time range, and optionally fill in target references (player IDs, transfer numbers, or idempotency keys, one per line). Jobs and items are persisted for audit and future automation.",
|
||||
"createDesc": "Manually check abnormal transfers by period or selected references. Scheduled reconciliation still runs automatically.",
|
||||
"reconcileType": "Reconcile type",
|
||||
"walletTransfer": "Wallet transfer (main site ⇄ lottery)",
|
||||
"startTime": "Start time",
|
||||
"endTime": "End time",
|
||||
"scope": "Scope (optional)",
|
||||
"scopePlaceholder": "One reference per line, for example player ID, wallet transfer number, or idempotency key.\nLeave empty to create a scoped job record without explicit refs.",
|
||||
"scopeHint": "When reconciling with wallet transactions in pending_reconcile status, paste the transfer number or idempotency key above.",
|
||||
"advancedToggleOpen": "Show advanced options (custom items JSON)",
|
||||
"advancedToggleClose": "Hide advanced options (custom items JSON)",
|
||||
"advancedJson": "Items JSON (overrides generated rows from the scope above)",
|
||||
"scope": "Targets (optional)",
|
||||
"scopePlaceholder": "One per line: player ID, transfer number, or main-site transaction number.\nLeave empty to check abnormal transfers in the selected period.",
|
||||
"createTask": "Create reconcile job",
|
||||
"submitting": "Submitting…",
|
||||
"loadFailed": "Failed to load",
|
||||
@@ -19,7 +15,6 @@
|
||||
"periodRequired": "Enter both reconcile start and end time",
|
||||
"periodInvalid": "Invalid time range",
|
||||
"periodOrderInvalid": "End time must be later than or equal to start time",
|
||||
"advancedJsonInvalid": "The advanced JSON cannot be parsed",
|
||||
"createSuccess": "Reconcile job created",
|
||||
"createFailed": "Failed to create job",
|
||||
"noCreatePermission": "Current account cannot create reconcile jobs.",
|
||||
|
||||
@@ -5,15 +5,19 @@
|
||||
"status": "Status",
|
||||
"apply": "Apply",
|
||||
"batchList": "Settlement batches",
|
||||
"allStatuses": "All",
|
||||
"loadFailed": "Failed to load",
|
||||
"exportFailed": "Export failed",
|
||||
"actionSuccess": "{{name}} succeeded",
|
||||
"actionFailed": "{{name}} failed",
|
||||
"placeholderDrawNo": "For example 20260511-001",
|
||||
"reviewStatus": "Review status",
|
||||
"totalBet": "Total bet",
|
||||
"actualDeduct": "Actual deduct",
|
||||
"ticketCount": "Ticket count",
|
||||
"winCount": "Win count",
|
||||
"payoutTotal": "Total payout",
|
||||
"platformProfit": "Profit/Loss",
|
||||
"jackpot": "Jackpot",
|
||||
"finishedAt": "Finished at",
|
||||
"details": "Details",
|
||||
@@ -33,10 +37,19 @@
|
||||
"winTotal": "Win count",
|
||||
"payoutAmount": "Payout total",
|
||||
"jackpotPayout": "Jackpot payout",
|
||||
"profitFormula": "Profit/Loss = actual deduct - total payout",
|
||||
"startedAt": "Started",
|
||||
"endedAt": "Ended",
|
||||
"runPayout": "Run payout",
|
||||
"exportSettlementReport": "Export settlement report",
|
||||
"confirmAction": "Confirm {{name}}",
|
||||
"confirmActionDesc": "Confirm settlement amounts and status for draw {{drawNo}}.",
|
||||
"confirmPayoutDesc": "Running payout writes winning amounts to player lottery wallets and cannot be directly undone. Confirm result and settlement amounts first.",
|
||||
"confirmAmountLine": "Actual deduct {{actual}}, payout {{payout}}, profit/loss {{profit}}.",
|
||||
"reviewRemark": "Review remark",
|
||||
"reviewRemarkPlaceholder": "Optional review note",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"loadingSummary": "Loading summary…",
|
||||
"detailTitle": "Settlement details",
|
||||
"ticketNo": "Ticket no.",
|
||||
@@ -48,6 +61,10 @@
|
||||
"statusOptions": {
|
||||
"all": "All",
|
||||
"running": "Running",
|
||||
"pending_review": "Pending review",
|
||||
"approved": "Approved",
|
||||
"rejected": "Rejected",
|
||||
"paid": "Paid",
|
||||
"completed": "Completed",
|
||||
"failed": "Failed"
|
||||
}
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
{
|
||||
"title": "मिलान",
|
||||
"createTitle": "म्यानुअल मिलान कार्य",
|
||||
"createDesc": "असामान्य फ्लोहरू scheduled task ले स्वतः जाँच्छ। यहाँ वित्तले म्यानुअल रूपमा मिलान कार्य सुरु गर्न सक्छ: प्रकार र समय सीमा छान्नुहोस्, अनि आवश्यक परे player ID, transfer no, वा idempotency key जस्ता सन्दर्भहरू प्रति लाइन लेख्नुहोस्।",
|
||||
"createDesc": "समय अवधि वा छानिएका सन्दर्भहरूबाट असामान्य ट्रान्सफर म्यानुअल रूपमा जाँच गर्नुहोस्। scheduled reconciliation स्वतः चलिरहन्छ।",
|
||||
"reconcileType": "मिलान प्रकार",
|
||||
"walletTransfer": "वालेट ट्रान्सफर (मुख्य साइट ⇄ लटरी)",
|
||||
"startTime": "सुरु समय",
|
||||
"endTime": "अन्त्य समय",
|
||||
"scope": "दायरा (वैकल्पिक)",
|
||||
"scopePlaceholder": "प्रति लाइन एउटा सन्दर्भ, जस्तै player ID, wallet transfer no, वा idempotency key.\nखाली छोडेमा केवल कार्य रेकर्ड सिर्जना हुन्छ।",
|
||||
"scopeHint": "pending_reconcile स्थितिको वालेट कारोबारसँग मिलान गर्दा transfer no वा idempotency key माथि टाँस्नुहोस्।",
|
||||
"advancedToggleOpen": "उन्नत विकल्प देखाउनुहोस् (custom items JSON)",
|
||||
"advancedToggleClose": "उन्नत विकल्प लुकाउनुहोस् (custom items JSON)",
|
||||
"advancedJson": "Items JSON (माथिको दायराबाट बनेका row हरूलाई override गर्छ)",
|
||||
"scope": "लक्षित सन्दर्भ (वैकल्पिक)",
|
||||
"scopePlaceholder": "प्रति लाइन एउटा: player ID, transfer no, वा main-site transaction no.\nखाली छोडेमा चयन गरिएको अवधिका असामान्य ट्रान्सफर जाँच हुन्छ।",
|
||||
"createTask": "मिलान कार्य सिर्जना",
|
||||
"submitting": "पेश हुँदैछ…",
|
||||
"loadFailed": "लोड असफल भयो",
|
||||
@@ -19,7 +15,6 @@
|
||||
"periodRequired": "सुरु र अन्त्य समय दुवै लेख्नुहोस्",
|
||||
"periodInvalid": "अवैध समय दायरा",
|
||||
"periodOrderInvalid": "अन्त्य समय सुरु समयभन्दा पछाडि वा बराबर हुनुपर्छ",
|
||||
"advancedJsonInvalid": "उन्नत JSON parse गर्न सकिएन",
|
||||
"createSuccess": "मिलान कार्य सिर्जना भयो",
|
||||
"createFailed": "कार्य सिर्जना असफल भयो",
|
||||
"noCreatePermission": "हालको खातासँग मिलान कार्य सिर्जना गर्ने अनुमति छैन।",
|
||||
|
||||
@@ -5,15 +5,19 @@
|
||||
"status": "स्थिति",
|
||||
"apply": "लागू गर्नुहोस्",
|
||||
"batchList": "सेटलमेन्ट ब्याच",
|
||||
"allStatuses": "सबै",
|
||||
"loadFailed": "लोड असफल भयो",
|
||||
"exportFailed": "निर्यात असफल भयो",
|
||||
"actionSuccess": "{{name}} सफल भयो",
|
||||
"actionFailed": "{{name}} असफल भयो",
|
||||
"placeholderDrawNo": "जस्तै 20260511-001",
|
||||
"reviewStatus": "समीक्षा स्थिति",
|
||||
"totalBet": "कुल बेट",
|
||||
"actualDeduct": "वास्तविक कटौती",
|
||||
"ticketCount": "टिकट संख्या",
|
||||
"winCount": "जित संख्या",
|
||||
"payoutTotal": "कुल भुक्तानी",
|
||||
"platformProfit": "नाफा/घाटा",
|
||||
"jackpot": "Jackpot",
|
||||
"finishedAt": "समाप्त समय",
|
||||
"details": "विवरण",
|
||||
@@ -33,10 +37,19 @@
|
||||
"winTotal": "जित संख्या",
|
||||
"payoutAmount": "कुल भुक्तानी",
|
||||
"jackpotPayout": "Jackpot भुक्तानी",
|
||||
"profitFormula": "नाफा/घाटा = वास्तविक कटौती - कुल भुक्तानी",
|
||||
"startedAt": "सुरु",
|
||||
"endedAt": "समाप्त",
|
||||
"runPayout": "भुक्तानी चलाउनुहोस्",
|
||||
"exportSettlementReport": "सेटलमेन्ट रिपोर्ट निर्यात",
|
||||
"confirmAction": "{{name}} पुष्टि गर्नुहोस्",
|
||||
"confirmActionDesc": "ड्रअ {{drawNo}} को रकम र स्थिति पुष्टि गर्नुहोस्।",
|
||||
"confirmPayoutDesc": "भुक्तानी चलाउँदा जित रकम खेलाडीको लटरी वालेटमा लेखिन्छ र सीधै उल्ट्याउन सकिँदैन। पहिले नतिजा र रकम पुष्टि गर्नुहोस्।",
|
||||
"confirmAmountLine": "वास्तविक कटौती {{actual}}, भुक्तानी {{payout}}, नाफा/घाटा {{profit}}।",
|
||||
"reviewRemark": "समीक्षा टिप्पणी",
|
||||
"reviewRemarkPlaceholder": "वैकल्पिक समीक्षा टिप्पणी",
|
||||
"cancel": "रद्द",
|
||||
"confirm": "पुष्टि",
|
||||
"loadingSummary": "सारांश लोड हुँदैछ…",
|
||||
"detailTitle": "सेटलमेन्ट विवरण",
|
||||
"ticketNo": "टिकट नं.",
|
||||
@@ -48,6 +61,10 @@
|
||||
"statusOptions": {
|
||||
"all": "सबै",
|
||||
"running": "चलिरहेको",
|
||||
"pending_review": "समीक्षा बाँकी",
|
||||
"approved": "स्वीकृत",
|
||||
"rejected": "अस्वीकृत",
|
||||
"paid": "भुक्तानी भयो",
|
||||
"completed": "सम्पन्न",
|
||||
"failed": "असफल"
|
||||
}
|
||||
|
||||
@@ -107,7 +107,11 @@
|
||||
"status": "期号状态",
|
||||
"results": "开奖结果",
|
||||
"finance": "期号收支",
|
||||
"review": "审核与发布"
|
||||
"review": "审核与发布",
|
||||
"riskOccupancy": "风控占用",
|
||||
"riskHot": "热门号码",
|
||||
"riskSoldOut": "售罄号码",
|
||||
"riskPools": "风险池"
|
||||
},
|
||||
"statusOptions": {
|
||||
"all": "不限",
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
{
|
||||
"title": "对账",
|
||||
"createTitle": "人工发起对账",
|
||||
"createDesc": "异常流水由定时任务自动核对。此处供财务按产品文档手动触发:选择对账类型与时间范围;可选填写待核对对象(玩家标识、划转单号或幂等键,每行一条)。任务与明细落库留痕,后续可接自动差异引擎。",
|
||||
"createDesc": "用于按时间段或指定单据人工核对异常转账。系统定时对账仍会自动执行。",
|
||||
"reconcileType": "对账类型",
|
||||
"walletTransfer": "钱包划转(主站 ⇄ 彩票)",
|
||||
"startTime": "对账开始时间",
|
||||
"endTime": "对账结束时间",
|
||||
"scope": "限定范围(可选)",
|
||||
"scopePlaceholder": "每行一条待核对引用,例如:玩家 ID、钱包划转单号、幂等键等。\n留空表示本时间段内不额外指定单据(仅任务留痕)。",
|
||||
"scopeHint": "与「钱包流水」中待对账(pending_reconcile)流水对照使用时,可将单号或幂等键粘贴至上方。",
|
||||
"advancedToggleOpen": "展开高级选项(自定义明细 JSON)",
|
||||
"advancedToggleClose": "收起高级选项(自定义明细 JSON)",
|
||||
"advancedJson": "明细 JSON(将覆盖上方「限定范围」生成的行)",
|
||||
"scope": "指定对象(可选)",
|
||||
"scopePlaceholder": "每行一条:玩家 ID、划转单号或主站流水号。\n留空则核对所选时间段内的异常转账。",
|
||||
"createTask": "创建对账任务",
|
||||
"submitting": "提交中…",
|
||||
"loadFailed": "加载失败",
|
||||
@@ -19,7 +15,6 @@
|
||||
"periodRequired": "请填写对账时间范围(开始与结束)",
|
||||
"periodInvalid": "时间无效,请检查所选日期与时间",
|
||||
"periodOrderInvalid": "结束时间需晚于或等于开始时间",
|
||||
"advancedJsonInvalid": "高级选项中的 JSON 无法解析",
|
||||
"createSuccess": "已创建对账任务",
|
||||
"createFailed": "创建失败",
|
||||
"noCreatePermission": "当前账号无新建对账任务权限。",
|
||||
|
||||
@@ -5,15 +5,19 @@
|
||||
"status": "状态",
|
||||
"apply": "应用",
|
||||
"batchList": "结算批次",
|
||||
"allStatuses": "不限",
|
||||
"loadFailed": "加载失败",
|
||||
"exportFailed": "导出失败",
|
||||
"actionSuccess": "{{name}}成功",
|
||||
"actionFailed": "{{name}}失败",
|
||||
"placeholderDrawNo": "如 20260511-001",
|
||||
"reviewStatus": "审核状态",
|
||||
"totalBet": "总下注",
|
||||
"actualDeduct": "总实扣",
|
||||
"ticketCount": "注单数",
|
||||
"winCount": "中奖笔数",
|
||||
"payoutTotal": "派彩合计",
|
||||
"platformProfit": "盈亏",
|
||||
"jackpot": "Jackpot",
|
||||
"finishedAt": "完成时间",
|
||||
"details": "明细",
|
||||
@@ -33,10 +37,19 @@
|
||||
"winTotal": "中奖笔数",
|
||||
"payoutAmount": "派彩合计",
|
||||
"jackpotPayout": "Jackpot 划出",
|
||||
"profitFormula": "盈亏 = 总实扣 - 总派彩",
|
||||
"startedAt": "开始",
|
||||
"endedAt": "结束",
|
||||
"runPayout": "执行派彩",
|
||||
"exportSettlementReport": "导出结算报表",
|
||||
"confirmAction": "确认{{name}}",
|
||||
"confirmActionDesc": "请确认期号 {{drawNo}} 的结算金额和状态无误。",
|
||||
"confirmPayoutDesc": "执行派彩后会把中奖金额写入玩家彩票钱包,不能直接撤回。请确认开奖结果和结算金额无误。",
|
||||
"confirmAmountLine": "总实扣 {{actual}},总派彩 {{payout}},平台盈亏 {{profit}}。",
|
||||
"reviewRemark": "审核备注",
|
||||
"reviewRemarkPlaceholder": "可填写审核意见,非必填",
|
||||
"cancel": "取消",
|
||||
"confirm": "确认",
|
||||
"loadingSummary": "加载摘要…",
|
||||
"detailTitle": "注单结算明细",
|
||||
"ticketNo": "注单号",
|
||||
@@ -48,6 +61,10 @@
|
||||
"statusOptions": {
|
||||
"all": "不限",
|
||||
"running": "进行中",
|
||||
"pending_review": "待审核",
|
||||
"approved": "已审核",
|
||||
"rejected": "已驳回",
|
||||
"paid": "已派奖",
|
||||
"completed": "已完成",
|
||||
"failed": "失败"
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
putAdminRolePermissions,
|
||||
} from "@/api/admin-users";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
@@ -268,9 +269,16 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
{t("createRole")}
|
||||
</Button>
|
||||
</div>
|
||||
<Button type="button" variant="secondary" onClick={() => void load()}>
|
||||
{t("actions.refresh", { ns: "common" })}
|
||||
</Button>
|
||||
<div className="admin-list-actions">
|
||||
<AdminTableExportButton
|
||||
tableId="admin-roles-table"
|
||||
filename="角色列表"
|
||||
sheetName="角色列表"
|
||||
/>
|
||||
<Button type="button" variant="secondary" onClick={() => void load()}>
|
||||
{t("actions.refresh", { ns: "common" })}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
|
||||
@@ -278,7 +286,7 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
||||
) : null}
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<Table id="admin-roles-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-16">ID</TableHead>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
putAdminUserRoles,
|
||||
} from "@/api/admin-users";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -296,47 +298,61 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-none flex-col gap-6">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row flex-wrap items-end justify-between gap-4">
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||
<CardTitle>{t("listTitle")}</CardTitle>
|
||||
<CardTitle className="admin-list-title">{t("listTitle")}</CardTitle>
|
||||
<Button type="button" size="sm" onClick={() => openCreateAccount()}>
|
||||
{t("createAdmin")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex w-full max-w-lg gap-2">
|
||||
<Input
|
||||
value={keyword}
|
||||
placeholder={t("searchPlaceholder")}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
<div className="admin-list-toolbar">
|
||||
<div className="admin-list-field xl:min-w-0">
|
||||
<Label htmlFor="admin-user-search" className="sm:w-20 sm:shrink-0">
|
||||
{t("actions.search", { ns: "common" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="admin-user-search"
|
||||
value={keyword}
|
||||
className="w-full sm:w-[18rem] xl:w-[24rem]"
|
||||
placeholder={t("searchPlaceholder")}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
setPage(1);
|
||||
setQuery(keyword.trim());
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-list-actions">
|
||||
<AdminTableExportButton
|
||||
tableId="admin-users-table"
|
||||
filename="后台用户列表"
|
||||
sheetName="后台用户"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPage(1);
|
||||
setQuery(keyword.trim());
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPage(1);
|
||||
setQuery(keyword.trim());
|
||||
}}
|
||||
>
|
||||
{t("actions.search", { ns: "common" })}
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" onClick={() => void load()}>
|
||||
{t("actions.refresh", { ns: "common" })}
|
||||
</Button>
|
||||
}}
|
||||
>
|
||||
{t("actions.search", { ns: "common" })}
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" onClick={() => void load()}>
|
||||
{t("actions.refresh", { ns: "common" })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<CardContent className="admin-list-content">
|
||||
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
|
||||
{loading && items.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
||||
) : null}
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<div className="admin-table-shell">
|
||||
<Table id="admin-users-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-16">ID</TableHead>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getAdminAuditLogs } from "@/api/admin-audit";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -66,44 +67,47 @@ export function AuditLogsConsole(): React.ReactElement {
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-none">
|
||||
<CardHeader className="flex flex-row flex-wrap items-end justify-between gap-4">
|
||||
<CardHeader className="flex flex-col gap-4">
|
||||
<div>
|
||||
<CardTitle>{t("title")}</CardTitle>
|
||||
</div>
|
||||
<Button type="button" variant="secondary" size="sm" onClick={() => void load()}>
|
||||
{t("actions.refresh", { ns: "common" })}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="aud-mod">{t("moduleCode")}</Label>
|
||||
<div className="admin-list-toolbar">
|
||||
<div className="admin-list-field">
|
||||
<Label htmlFor="aud-mod" className="sm:w-20 sm:shrink-0">{t("moduleCode")}</Label>
|
||||
<Input
|
||||
id="aud-mod"
|
||||
value={moduleCode}
|
||||
onChange={(e) => setModuleCode(e.target.value)}
|
||||
placeholder={t("exactMatch")}
|
||||
className="w-full sm:w-40"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="aud-act">{t("actionCode")}</Label>
|
||||
<div className="admin-list-field">
|
||||
<Label htmlFor="aud-act" className="sm:w-20 sm:shrink-0">{t("actionCode")}</Label>
|
||||
<Input
|
||||
id="aud-act"
|
||||
value={actionCode}
|
||||
onChange={(e) => setActionCode(e.target.value)}
|
||||
placeholder={t("exactMatch")}
|
||||
className="w-full sm:w-40"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="aud-op">{t("operatorType")}</Label>
|
||||
<div className="admin-list-field">
|
||||
<Label htmlFor="aud-op" className="sm:w-20 sm:shrink-0">{t("operatorType")}</Label>
|
||||
<Input
|
||||
id="aud-op"
|
||||
value={operatorType}
|
||||
onChange={(e) => setOperatorType(e.target.value)}
|
||||
placeholder={t("operatorTypePlaceholder")}
|
||||
className="w-full sm:w-40"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<div className="admin-list-actions">
|
||||
<AdminTableExportButton
|
||||
tableId="audit-logs-table"
|
||||
filename="审计日志"
|
||||
sheetName="审计日志"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -115,9 +119,13 @@ export function AuditLogsConsole(): React.ReactElement {
|
||||
>
|
||||
{t("actions.search", { ns: "common" })}
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" size="sm" onClick={() => void load()}>
|
||||
{t("actions.refresh", { ns: "common" })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
|
||||
{loading && !data ? (
|
||||
<p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p>
|
||||
@@ -126,7 +134,7 @@ export function AuditLogsConsole(): React.ReactElement {
|
||||
{data ? (
|
||||
<>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<Table id="audit-logs-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-20">ID</TableHead>
|
||||
|
||||
@@ -546,7 +546,7 @@ export function DashboardConsole(): ReactElement {
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"mt-1 h-7 border-[#2563eb]/30 px-2 text-xs text-[#2563eb] hover:bg-[#2563eb]/5",
|
||||
)}
|
||||
href={`/admin/risk/draws/${drawId}/occupancy`}
|
||||
href={`/admin/draws/${drawId}/risk/occupancy`}
|
||||
>
|
||||
{t("occupancyDetails")}
|
||||
</Link>
|
||||
@@ -592,7 +592,7 @@ export function DashboardConsole(): ReactElement {
|
||||
</div>
|
||||
{drawId != null ? (
|
||||
<Link
|
||||
href={`/admin/risk/draws/${drawId}/hot`}
|
||||
href={`/admin/draws/${drawId}/risk/hot`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"h-7 border-[#2563eb]/30 px-2 text-xs text-[#2563eb] hover:bg-[#2563eb]/5",
|
||||
@@ -619,7 +619,7 @@ export function DashboardConsole(): ReactElement {
|
||||
</div>
|
||||
{drawId != null ? (
|
||||
<Link
|
||||
href={`/admin/risk/draws/${drawId}/sold-out`}
|
||||
href={`/admin/draws/${drawId}/risk/sold-out`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"h-7 border-[#2563eb]/30 px-2 text-xs text-[#2563eb] hover:bg-[#2563eb]/5",
|
||||
|
||||
@@ -33,6 +33,12 @@ import {
|
||||
PRD_PAYOUT_REVIEW,
|
||||
} from "./draw-prd";
|
||||
|
||||
function drawStatusText(status: string, t: (key: string) => string): string {
|
||||
const key = `statusOptions.${status}`;
|
||||
const translated = t(key);
|
||||
return translated === key ? status : translated;
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="grid gap-1 sm:grid-cols-[10rem_1fr] sm:items-start">
|
||||
@@ -114,12 +120,16 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
||||
<CardTitle className="text-xl">{data.draw_no}</CardTitle>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t("drawDetail")}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<DrawStatusBadge status={data.status} label={data.status} />
|
||||
<div className="flex flex-col items-end gap-1 text-right">
|
||||
<DrawStatusBadge
|
||||
status={data.hall_preview_status}
|
||||
label={t("hallPreviewStatus", { status: data.hall_preview_status })}
|
||||
status={data.status}
|
||||
label={drawStatusText(data.status, t)}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("hallPreviewStatus", {
|
||||
status: drawStatusText(data.hall_preview_status, t),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { getAdminDrawFinanceSummary } from "@/api/admin-draws";
|
||||
import { postAdminRunDrawSettlement } from "@/api/admin-settlement";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
@@ -156,7 +157,14 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
|
||||
<p className="text-muted-foreground text-sm">{t("noSettlementBatches")}</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<Table>
|
||||
<div className="admin-table-toolbar">
|
||||
<AdminTableExportButton
|
||||
tableId={`draw-finance-table-${drawId}`}
|
||||
filename={`期号收支-${data.draw_no}`}
|
||||
sheetName="期号收支"
|
||||
/>
|
||||
</div>
|
||||
<Table id={`draw-finance-table-${drawId}`}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-20">ID</TableHead>
|
||||
|
||||
@@ -12,6 +12,10 @@ const segments = [
|
||||
{ suffix: "/results", key: "results", label: "subnav.results" },
|
||||
{ suffix: "/finance", key: "finance", label: "subnav.finance" },
|
||||
{ suffix: "/review", key: "review", label: "subnav.review" },
|
||||
{ suffix: "/risk/occupancy", key: "riskOccupancy", label: "subnav.riskOccupancy" },
|
||||
{ suffix: "/risk/hot", key: "riskHot", label: "subnav.riskHot" },
|
||||
{ suffix: "/risk/sold-out", key: "riskSoldOut", label: "subnav.riskSoldOut" },
|
||||
{ suffix: "/risk/pools", key: "riskPools", label: "subnav.riskPools" },
|
||||
] as const;
|
||||
|
||||
function isReviewTabActive(pathname: string, base: string): boolean {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { toast } from "sonner";
|
||||
|
||||
import { getAdminDraws, postAdminGenerateDrawPlan } from "@/api/admin-draws";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -140,60 +141,66 @@ export function DrawsIndexConsole() {
|
||||
}, [load]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<CardTitle className="text-lg">{t("statusListTitle")}</CardTitle>
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<CardTitle className="admin-list-title">{t("statusListTitle")}</CardTitle>
|
||||
{canManageDraw ? (
|
||||
<Button type="button" onClick={() => void generatePlan()} disabled={generating}>
|
||||
{generating ? t("generating") : t("generatePlan")}
|
||||
</Button>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Grid:桌面端标签一行 / 控件一行,避免 flex+items-end 与各列实际高度不一致;移动端单列自上而下 */}
|
||||
<div
|
||||
className="grid max-w-full gap-x-6 gap-y-3 sm:grid-cols-[minmax(0,12rem)_minmax(0,11rem)_auto] sm:gap-y-1.5"
|
||||
>
|
||||
<Label htmlFor="draw-filter-no">
|
||||
{t("drawNo")}
|
||||
</Label>
|
||||
<Input
|
||||
id="draw-filter-no"
|
||||
placeholder={t("fuzzyDrawNo")}
|
||||
value={draftDrawNo}
|
||||
className="w-full min-w-0 sm:w-full"
|
||||
onChange={(e) => setDraftDrawNo(e.target.value)}
|
||||
/>
|
||||
<Label htmlFor="draw-filter-status">
|
||||
{t("status")}
|
||||
</Label>
|
||||
<div className="min-w-0">
|
||||
<Select
|
||||
modal={false}
|
||||
value={
|
||||
draftStatus === "" ||
|
||||
!DRAW_STATUS_OPTIONS.some((o) => o.value === draftStatus)
|
||||
? DRAW_FILTER_ALL
|
||||
: draftStatus
|
||||
}
|
||||
onValueChange={(v) =>
|
||||
setDraftStatus(v == null || v === DRAW_FILTER_ALL ? "" : String(v))
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="draw-filter-status" className="h-8 w-full min-w-0 sm:w-44">
|
||||
<SelectValue>{drawStatusTriggerLabel}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start" sideOffset={6}>
|
||||
<SelectItem value={DRAW_FILTER_ALL}>{t("statusOptions.all")}</SelectItem>
|
||||
{DRAW_STATUS_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{t(o.label)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<CardContent className="admin-list-content">
|
||||
<div className="admin-list-toolbar">
|
||||
<div className="admin-list-field xl:min-w-0">
|
||||
<Label htmlFor="draw-filter-no" className="sm:w-10 sm:shrink-0">
|
||||
{t("drawNo")}
|
||||
</Label>
|
||||
<Input
|
||||
id="draw-filter-no"
|
||||
placeholder={t("fuzzyDrawNo")}
|
||||
value={draftDrawNo}
|
||||
className="w-full sm:w-[16rem] xl:w-[20rem]"
|
||||
onChange={(e) => setDraftDrawNo(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="[grid-area:act] flex flex-wrap gap-2">
|
||||
<div className="admin-list-field">
|
||||
<Label htmlFor="draw-filter-status" className="sm:w-10 sm:shrink-0">
|
||||
{t("status")}
|
||||
</Label>
|
||||
<div className="min-w-0">
|
||||
<Select
|
||||
modal={false}
|
||||
value={
|
||||
draftStatus === "" ||
|
||||
!DRAW_STATUS_OPTIONS.some((o) => o.value === draftStatus)
|
||||
? DRAW_FILTER_ALL
|
||||
: draftStatus
|
||||
}
|
||||
onValueChange={(v) =>
|
||||
setDraftStatus(v == null || v === DRAW_FILTER_ALL ? "" : String(v))
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="draw-filter-status" className="h-8 w-full sm:w-44">
|
||||
<SelectValue>{drawStatusTriggerLabel}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start" sideOffset={6}>
|
||||
<SelectItem value={DRAW_FILTER_ALL}>{t("statusOptions.all")}</SelectItem>
|
||||
{DRAW_STATUS_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{t(o.label)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-list-actions">
|
||||
<AdminTableExportButton
|
||||
tableId="draws-index-table"
|
||||
filename="期号列表"
|
||||
sheetName="期号列表"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -224,8 +231,8 @@ export function DrawsIndexConsole() {
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-lg border border-border">
|
||||
<Table>
|
||||
<div className="admin-table-shell">
|
||||
<Table id="draws-index-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("drawNo")}</TableHead>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getAdminJackpotContributions, getAdminJackpotPayoutLogs } from "@/api/admin-jackpot";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -127,7 +128,15 @@ export function JackpotRecordsConsole() {
|
||||
{loadingP && !payouts ? (
|
||||
<p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p>
|
||||
) : (
|
||||
<Table>
|
||||
<>
|
||||
<div className="admin-table-toolbar">
|
||||
<AdminTableExportButton
|
||||
tableId="jackpot-payout-table"
|
||||
filename="Jackpot派彩记录"
|
||||
sheetName="Jackpot派彩"
|
||||
/>
|
||||
</div>
|
||||
<Table id="jackpot-payout-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
@@ -155,6 +164,7 @@ export function JackpotRecordsConsole() {
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</>
|
||||
)}
|
||||
{payouts ? (
|
||||
<AdminListPaginationFooter
|
||||
@@ -182,7 +192,15 @@ export function JackpotRecordsConsole() {
|
||||
{loadingC && !contribs ? (
|
||||
<p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p>
|
||||
) : (
|
||||
<Table>
|
||||
<>
|
||||
<div className="admin-table-toolbar">
|
||||
<AdminTableExportButton
|
||||
tableId="jackpot-contribution-table"
|
||||
filename="Jackpot注入记录"
|
||||
sheetName="Jackpot注入"
|
||||
/>
|
||||
</div>
|
||||
<Table id="jackpot-contribution-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
@@ -212,6 +230,7 @@ export function JackpotRecordsConsole() {
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</>
|
||||
)}
|
||||
{contribs ? (
|
||||
<AdminListPaginationFooter
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
putAdminPlayer,
|
||||
} from "@/api/admin-player";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -249,49 +250,63 @@ export function PlayersConsole(): React.ReactElement {
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-none flex-col gap-6">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row flex-wrap items-end justify-between gap-4">
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||
<CardTitle>{t("listTitle")}</CardTitle>
|
||||
<CardTitle className="admin-list-title">{t("listTitle")}</CardTitle>
|
||||
{canManagePlayers ? (
|
||||
<Button type="button" size="sm" onClick={() => openCreateAccount()}>
|
||||
{t("createPlayer")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex w-full max-w-lg gap-2">
|
||||
<Input
|
||||
value={keyword}
|
||||
placeholder={t("searchPlaceholder")}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
<div className="admin-list-toolbar">
|
||||
<div className="admin-list-field xl:min-w-0">
|
||||
<Label htmlFor="player-search" className="sm:w-20 sm:shrink-0">
|
||||
{t("search")}
|
||||
</Label>
|
||||
<Input
|
||||
id="player-search"
|
||||
value={keyword}
|
||||
className="w-full sm:w-[18rem] xl:w-[24rem]"
|
||||
placeholder={t("searchPlaceholder")}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
setPage(1);
|
||||
setQuery(keyword.trim());
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-list-actions">
|
||||
<AdminTableExportButton
|
||||
tableId="players-table"
|
||||
filename="玩家列表"
|
||||
sheetName="玩家列表"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPage(1);
|
||||
setQuery(keyword.trim());
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPage(1);
|
||||
setQuery(keyword.trim());
|
||||
}}
|
||||
>
|
||||
{t("search")}
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" onClick={() => void load()}>
|
||||
{t("refresh")}
|
||||
</Button>
|
||||
}}
|
||||
>
|
||||
{t("search")}
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" onClick={() => void load()}>
|
||||
{t("refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<CardContent className="admin-list-content">
|
||||
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
|
||||
{loading && items.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
||||
) : null}
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<div className="admin-table-shell">
|
||||
<Table id="players-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-16">ID</TableHead>
|
||||
|
||||
@@ -128,8 +128,6 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
const [periodStartLocal, setPeriodStartLocal] = useState("");
|
||||
const [periodEndLocal, setPeriodEndLocal] = useState("");
|
||||
const [scopeLines, setScopeLines] = useState("");
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [itemsJson, setItemsJson] = useState("[]");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const loadJobs = useCallback(async () => {
|
||||
@@ -194,25 +192,7 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
return;
|
||||
}
|
||||
|
||||
let itemsPayload: Parameters<typeof postAdminReconcileJob>[0]["items"];
|
||||
|
||||
if (showAdvanced) {
|
||||
const trimmed = itemsJson.trim();
|
||||
if (trimmed !== "" && trimmed !== "[]") {
|
||||
try {
|
||||
itemsPayload = JSON.parse(trimmed) as NonNullable<
|
||||
Parameters<typeof postAdminReconcileJob>[0]["items"]
|
||||
>;
|
||||
} catch {
|
||||
toast.error(t("advancedJsonInvalid"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (itemsPayload === undefined) {
|
||||
itemsPayload = scopeLinesToItems(scopeLines);
|
||||
}
|
||||
const itemsPayload = scopeLinesToItems(scopeLines);
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
@@ -225,9 +205,6 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
toast.success(t("createSuccess"));
|
||||
setPage(1);
|
||||
setScopeLines("");
|
||||
if (showAdvanced) {
|
||||
setItemsJson("[]");
|
||||
}
|
||||
await loadJobs();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("createFailed"));
|
||||
@@ -240,17 +217,16 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
const im = items?.meta;
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-none flex-col gap-8">
|
||||
<div className="flex w-full max-w-none flex-col gap-6">
|
||||
{canCreate ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("createTitle")}</CardTitle>
|
||||
<CardDescription>
|
||||
{t("createDesc")}
|
||||
</CardDescription>
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header">
|
||||
<CardTitle className="admin-list-title">{t("createTitle")}</CardTitle>
|
||||
<CardDescription className="mt-1">{t("createDesc")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid max-w-3xl gap-4">
|
||||
<div className="grid gap-1.5">
|
||||
<CardContent className="admin-list-content pt-4">
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(220px,0.9fr)_minmax(180px,0.7fr)_minmax(180px,0.7fr)_auto] lg:items-end">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="rc-type">{t("reconcileType")}</Label>
|
||||
<Select
|
||||
modal={false}
|
||||
@@ -261,7 +237,7 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="rc-type" className="w-full max-w-md">
|
||||
<SelectTrigger id="rc-type" className="w-full">
|
||||
<SelectValue>{reconcileTypeLabel(reconcileType, t)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
@@ -272,8 +248,7 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="rc-start">{t("startTime")}</Label>
|
||||
<Input
|
||||
@@ -292,6 +267,9 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
onChange={(e) => setPeriodEndLocal(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="button" className="w-full lg:w-auto" onClick={() => void onCreate()} disabled={submitting}>
|
||||
{submitting ? t("submitting") : t("createTask")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="rc-scope">{t("scope")}</Label>
|
||||
@@ -299,66 +277,36 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
id="rc-scope"
|
||||
value={scopeLines}
|
||||
onChange={(e) => setScopeLines(e.target.value)}
|
||||
rows={5}
|
||||
rows={3}
|
||||
placeholder={t("scopePlaceholder")}
|
||||
className="min-h-[100px] text-sm"
|
||||
className="min-h-20 text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("scopeHint")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 border-t pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-fit px-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setShowAdvanced((x) => !x)}
|
||||
>
|
||||
{showAdvanced ? t("advancedToggleClose") : t("advancedToggleOpen")}
|
||||
</Button>
|
||||
{showAdvanced ? (
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="rc-items-adv">{t("advancedJson")}</Label>
|
||||
<Textarea
|
||||
id="rc-items-adv"
|
||||
value={itemsJson}
|
||||
onChange={(e) => setItemsJson(e.target.value)}
|
||||
rows={6}
|
||||
className="font-mono text-xs"
|
||||
placeholder='[{"side_a_ref":"TO-1","side_b_ref":"MAIN-1","difference_amount":100,"status":"mismatch"}]'
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<Button type="button" onClick={() => void onCreate()} disabled={submitting}>
|
||||
{submitting ? t("submitting") : t("createTask")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">{t("noCreatePermission")}</p>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row flex-wrap items-end justify-between gap-4">
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header flex flex-row flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>{t("jobsTitle")}</CardTitle>
|
||||
<CardTitle className="admin-list-title">{t("jobsTitle")}</CardTitle>
|
||||
<CardDescription className="mt-1.5">{t("jobsDesc")}</CardDescription>
|
||||
</div>
|
||||
<Button type="button" variant="secondary" size="sm" onClick={() => void loadJobs()}>
|
||||
{t("refresh")}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<CardContent className="admin-list-content pt-4">
|
||||
{jobsErr ? <p className="text-sm text-red-600 dark:text-red-400">{jobsErr}</p> : null}
|
||||
{jobsLoading && !jobs ? (
|
||||
<p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p>
|
||||
) : null}
|
||||
{jobs ? (
|
||||
<>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<div className="admin-table-shell">
|
||||
<Table id="reconcile-jobs-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-24">ID</TableHead>
|
||||
@@ -432,12 +380,12 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
</Card>
|
||||
|
||||
{selectedId != null ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("detailsTitle")}</CardTitle>
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header">
|
||||
<CardTitle className="admin-list-title">{t("detailsTitle")}</CardTitle>
|
||||
<CardDescription className="font-mono text-xs">#{selectedId}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<CardContent className="admin-list-content pt-4">
|
||||
{itemsLoading && !items ? (
|
||||
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
||||
) : null}
|
||||
@@ -446,8 +394,8 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
{items.job_no ? (
|
||||
<p className="font-mono text-sm text-muted-foreground">{t("jobNo")} {items.job_no}</p>
|
||||
) : null}
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<div className="admin-table-shell">
|
||||
<Table id={`reconcile-items-table-${selectedId ?? "none"}`}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-20">ID</TableHead>
|
||||
|
||||
@@ -215,7 +215,7 @@ export function ReportsConsole(): React.ReactElement {
|
||||
{data ? (
|
||||
<>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<Table id="reports-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-24">{t("id")}</TableHead>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getAdminDraws } from "@/api/admin-draws";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -101,17 +102,21 @@ export function RiskIndexConsole() {
|
||||
const lastPage = Math.max(1, data?.meta.last_page ?? 1);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row flex-wrap items-end justify-between gap-4">
|
||||
<CardTitle className="text-lg">{t("center")}</CardTitle>
|
||||
<div className="flex w-full max-w-4xl flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-end">
|
||||
<div className="grid flex-1 gap-2 sm:min-w-[12rem]">
|
||||
<Label htmlFor="risk-index-draw-no" className="text-xs text-muted-foreground">
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<CardTitle className="admin-list-title">{t("center")}</CardTitle>
|
||||
<div className="admin-list-toolbar lg:w-auto">
|
||||
<div className="admin-list-field lg:min-w-0">
|
||||
<Label
|
||||
htmlFor="risk-index-draw-no"
|
||||
className="text-xs text-muted-foreground sm:w-10 sm:shrink-0"
|
||||
>
|
||||
{t("drawNo")}
|
||||
</Label>
|
||||
<Input
|
||||
id="risk-index-draw-no"
|
||||
placeholder={t("fuzzyDrawNo")}
|
||||
className="w-full sm:w-[18rem] lg:w-[24rem]"
|
||||
value={drawNoInput}
|
||||
onChange={(e) => setDrawNoInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
@@ -121,8 +126,11 @@ export function RiskIndexConsole() {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:w-44">
|
||||
<Label htmlFor="risk-index-status" className="text-xs text-muted-foreground">
|
||||
<div className="admin-list-field">
|
||||
<Label
|
||||
htmlFor="risk-index-status"
|
||||
className="text-xs text-muted-foreground sm:w-10 sm:shrink-0"
|
||||
>
|
||||
{t("status")}
|
||||
</Label>
|
||||
<Select
|
||||
@@ -134,7 +142,7 @@ export function RiskIndexConsole() {
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="risk-index-status" size="sm" className="w-full">
|
||||
<SelectTrigger id="risk-index-status" size="sm" className="w-full sm:w-40">
|
||||
<SelectValue>{riskStatusTriggerLabel}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
@@ -147,7 +155,12 @@ export function RiskIndexConsole() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="admin-list-actions">
|
||||
<AdminTableExportButton
|
||||
tableId="risk-index-table"
|
||||
filename="风控中心期号列表"
|
||||
sheetName="风控中心"
|
||||
/>
|
||||
<Button type="button" size="sm" onClick={() => applySearch()}>
|
||||
{t("search")}
|
||||
</Button>
|
||||
@@ -157,13 +170,13 @@ export function RiskIndexConsole() {
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<CardContent className="admin-list-content">
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
{loading && (data?.items.length ?? 0) === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<Table>
|
||||
<div className="admin-table-shell">
|
||||
<Table id="risk-index-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("drawNo")}</TableHead>
|
||||
@@ -191,7 +204,7 @@ export function RiskIndexConsole() {
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Link
|
||||
href={`/admin/risk/draws/${row.id}/occupancy`}
|
||||
href={`/admin/draws/${row.id}/risk/occupancy`}
|
||||
className={cn(buttonVariants({ variant: "secondary", size: "sm" }))}
|
||||
>
|
||||
{t("enterRisk")}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getAdminRiskPoolLockLogs } from "@/api/admin-risk";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -31,6 +32,22 @@ import type { AdminRiskLockLogListData, AdminRiskLockLogRow } from "@/types/api/
|
||||
|
||||
const ACTION_ALL = "__all__";
|
||||
|
||||
function riskActionLabel(
|
||||
value: string,
|
||||
t: (key: string) => string,
|
||||
): string {
|
||||
if (value === ACTION_ALL) {
|
||||
return t("noLimit");
|
||||
}
|
||||
if (value === "lock") {
|
||||
return t("lock");
|
||||
}
|
||||
if (value === "release") {
|
||||
return t("release");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
|
||||
const { t } = useTranslation(["risk", "common"]);
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
@@ -76,25 +93,30 @@ export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
|
||||
}, [load]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">{t("lockLogsTitle")}</CardTitle>
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header">
|
||||
<CardTitle className="admin-list-title">{t("lockLogsTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid max-w-full gap-3 sm:grid-cols-[minmax(0,8rem)_minmax(0,10rem)_auto] sm:items-end">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="risk-log-number">{t("number4d")}</Label>
|
||||
<CardContent className="admin-list-content">
|
||||
<div className="admin-list-toolbar">
|
||||
<div className="admin-list-field">
|
||||
<Label htmlFor="risk-log-number" className="sm:w-20 sm:shrink-0">
|
||||
{t("number4d")}
|
||||
</Label>
|
||||
<Input
|
||||
id="risk-log-number"
|
||||
inputMode="numeric"
|
||||
maxLength={4}
|
||||
value={draftNumber}
|
||||
className="w-full sm:w-32"
|
||||
onChange={(e) => setDraftNumber(e.target.value.replace(/\D/g, "").slice(0, 4))}
|
||||
placeholder={t("optional")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="risk-log-action">{t("actionFilter")}</Label>
|
||||
<div className="admin-list-field">
|
||||
<Label htmlFor="risk-log-action" className="sm:w-20 sm:shrink-0">
|
||||
{t("actionFilter")}
|
||||
</Label>
|
||||
<Select
|
||||
modal={false}
|
||||
value={draftAction}
|
||||
@@ -102,8 +124,8 @@ export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
|
||||
if (v) setDraftAction(v);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="risk-log-action" size="sm" className="w-full">
|
||||
<SelectValue />
|
||||
<SelectTrigger id="risk-log-action" size="sm" className="w-full sm:w-40">
|
||||
<SelectValue>{riskActionLabel(draftAction, t)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ACTION_ALL}>{t("noLimit")}</SelectItem>
|
||||
@@ -112,7 +134,12 @@ export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex gap-2 sm:justify-end">
|
||||
<div className="admin-list-actions">
|
||||
<AdminTableExportButton
|
||||
tableId={`risk-lock-logs-table-${drawId}`}
|
||||
filename="风险占用流水"
|
||||
sheetName="风险占用流水"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
@@ -133,8 +160,8 @@ export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
|
||||
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<Table>
|
||||
<div className="admin-table-shell">
|
||||
<Table id={`risk-lock-logs-table-${drawId}`}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("time")}</TableHead>
|
||||
@@ -153,7 +180,9 @@ export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
|
||||
{row.created_at ? formatDt(row.created_at) : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">{row.normalized_number}</TableCell>
|
||||
<TableCell className="text-sm">{row.action_type}</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{riskActionLabel(row.action_type, t)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm tabular-nums">
|
||||
{formatAdminMinorUnits(row.amount)}
|
||||
</TableCell>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getAdminRiskPoolDetail } from "@/api/admin-risk";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
@@ -68,7 +69,7 @@ export function RiskPoolDetailConsole({
|
||||
<CardContent className="space-y-2">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
<Link
|
||||
href={`/admin/risk/draws/${drawId}/pools`}
|
||||
href={`/admin/draws/${drawId}/risk/pools`}
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
|
||||
>
|
||||
{t("backToList")}
|
||||
@@ -92,7 +93,7 @@ export function RiskPoolDetailConsole({
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Link
|
||||
href={`/admin/risk/draws/${drawId}/pools`}
|
||||
href={`/admin/draws/${drawId}/risk/pools`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}
|
||||
>
|
||||
← {t("backToAllPools")}
|
||||
@@ -142,7 +143,14 @@ export function RiskPoolDetailConsole({
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<Table>
|
||||
<div className="admin-table-toolbar">
|
||||
<AdminTableExportButton
|
||||
tableId={`risk-pool-detail-table-${drawId}-${number4d}`}
|
||||
filename={`风险池详情-${number4d}`}
|
||||
sheetName="风险池详情"
|
||||
/>
|
||||
</div>
|
||||
<Table id={`risk-pool-detail-table-${drawId}-${number4d}`}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("time")}</TableHead>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
postAdminRiskPoolRecover,
|
||||
} from "@/api/admin-risk";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -44,6 +45,14 @@ const SORT_OPTIONS: { value: "usage_desc" | "locked_desc" | "remaining_asc" | "n
|
||||
{ value: "number_asc", label: "sortNumberAsc" },
|
||||
];
|
||||
|
||||
function riskSortLabel(
|
||||
value: "usage_desc" | "locked_desc" | "remaining_asc" | "number_asc",
|
||||
t: (key: string) => string,
|
||||
): string {
|
||||
const option = SORT_OPTIONS.find((item) => item.value === value);
|
||||
return option ? t(option.label) : value;
|
||||
}
|
||||
|
||||
type RiskFilter = "all" | "sold_out" | "high_risk";
|
||||
|
||||
type RiskPoolsConsoleProps = {
|
||||
@@ -130,26 +139,28 @@ export function RiskPoolsConsole({
|
||||
);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="space-y-2">
|
||||
<CardTitle className="text-lg">{title}</CardTitle>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="risk-pool-number">{t("searchNumber")}</Label>
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header space-y-3">
|
||||
<CardTitle className="admin-list-title">{title}</CardTitle>
|
||||
<div className="admin-list-toolbar">
|
||||
<div className="admin-list-field">
|
||||
<Label htmlFor="risk-pool-number" className="sm:w-16 sm:shrink-0">
|
||||
{t("searchNumber")}
|
||||
</Label>
|
||||
<Input
|
||||
id="risk-pool-number"
|
||||
value={number}
|
||||
maxLength={4}
|
||||
placeholder={t("searchNumberPlaceholder")}
|
||||
className="h-9 w-32 font-mono"
|
||||
className="h-8 w-full font-mono sm:w-52"
|
||||
onChange={(event) => {
|
||||
setNumber(event.target.value.replace(/\D/g, "").slice(0, 4));
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("riskFilter")}</Label>
|
||||
<div className="admin-list-field">
|
||||
<Label className="sm:w-16 sm:shrink-0">{t("riskFilter")}</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
["all", t("filterAll")],
|
||||
@@ -172,8 +183,10 @@ export function RiskPoolsConsole({
|
||||
</div>
|
||||
</div>
|
||||
{allowSortChange ? (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="risk-pool-sort">{t("sort")}</Label>
|
||||
<div className="admin-list-field">
|
||||
<Label htmlFor="risk-pool-sort" className="sm:w-10 sm:shrink-0">
|
||||
{t("sort")}
|
||||
</Label>
|
||||
<Select
|
||||
modal={false}
|
||||
value={sort}
|
||||
@@ -184,7 +197,7 @@ export function RiskPoolsConsole({
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="risk-pool-sort" size="sm" className="w-full sm:w-52">
|
||||
<SelectValue />
|
||||
<SelectValue>{riskSortLabel(sort, t)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SORT_OPTIONS.map((o) => (
|
||||
@@ -196,16 +209,23 @@ export function RiskPoolsConsole({
|
||||
</Select>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="admin-list-actions">
|
||||
<AdminTableExportButton
|
||||
tableId={`risk-pools-table-${drawId}`}
|
||||
filename={title}
|
||||
sheetName="风险池"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<CardContent className="admin-list-content">
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
{loading && !data ? (
|
||||
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<Table>
|
||||
<div className="admin-table-shell">
|
||||
<Table id={`risk-pools-table-${drawId}`}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("searchNumber")}</TableHead>
|
||||
@@ -272,7 +292,7 @@ export function RiskPoolsConsole({
|
||||
{row.is_sold_out ? t("recover") : t("close")}
|
||||
</Button>
|
||||
<Link
|
||||
href={`/admin/risk/draws/${drawId}/pools/${row.normalized_number}`}
|
||||
href={`/admin/draws/${drawId}/risk/pools/${row.normalized_number}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "link", size: "sm" }),
|
||||
"h-8 px-0",
|
||||
|
||||
@@ -17,7 +17,7 @@ const segments = [
|
||||
export function RiskSubnav({ drawId }: { drawId: string }) {
|
||||
const { t } = useTranslation("risk");
|
||||
const pathname = usePathname();
|
||||
const base = `/admin/risk/draws/${drawId}`;
|
||||
const base = `/admin/draws/${drawId}/risk`;
|
||||
|
||||
return (
|
||||
<nav className="mb-6 flex flex-wrap gap-2 border-b border-border pb-3">
|
||||
@@ -38,7 +38,7 @@ export function RiskSubnav({ drawId }: { drawId: string }) {
|
||||
);
|
||||
})}
|
||||
<Link
|
||||
href="/admin/risk"
|
||||
href="/admin/draws"
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "ml-auto")}
|
||||
>
|
||||
{t("changeDraw")}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const settlementModuleMeta = {
|
||||
title: "Settlement",
|
||||
title: "结算",
|
||||
description: "",
|
||||
} as const;
|
||||
|
||||
@@ -17,6 +17,15 @@ import { AdminListPaginationFooter } from "@/components/admin/admin-list-paginat
|
||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -25,9 +34,13 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { formatAdminMinorUnits } from "@/lib/money";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PRD_PAYOUT_MANAGE, PRD_PAYOUT_REVIEW } from "@/modules/draws/draw-prd";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type {
|
||||
AdminSettlementBatchDetailsData,
|
||||
@@ -38,8 +51,13 @@ type Props = {
|
||||
batchId: number;
|
||||
};
|
||||
|
||||
type SettlementAction = "approve" | "reject" | "payout";
|
||||
|
||||
export function SettlementBatchDetailsConsole({ batchId }: Props) {
|
||||
const { t } = useTranslation(["settlement", "common"]);
|
||||
const profile = useAdminProfile();
|
||||
const canReviewSettlement = adminHasAnyPermission(profile?.permissions, [PRD_PAYOUT_REVIEW]);
|
||||
const canManagePayout = adminHasAnyPermission(profile?.permissions, [PRD_PAYOUT_MANAGE]);
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
const [summary, setSummary] = useState<AdminSettlementBatchShowData | null>(null);
|
||||
const [details, setDetails] = useState<AdminSettlementBatchDetailsData | null>(null);
|
||||
@@ -48,6 +66,8 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(25);
|
||||
const [acting, setActing] = useState<string | null>(null);
|
||||
const [pendingAction, setPendingAction] = useState<SettlementAction | null>(null);
|
||||
const [reviewRemark, setReviewRemark] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -73,6 +93,8 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
|
||||
try {
|
||||
await action();
|
||||
toast.success(t("actionSuccess", { name: label }));
|
||||
setPendingAction(null);
|
||||
setReviewRemark("");
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("actionFailed", { name: label }));
|
||||
@@ -81,6 +103,37 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
const actionLabel = (action: SettlementAction): string => {
|
||||
if (action === "approve") {
|
||||
return t("approve");
|
||||
}
|
||||
if (action === "reject") {
|
||||
return t("reject");
|
||||
}
|
||||
return t("runPayout");
|
||||
};
|
||||
|
||||
const confirmPendingAction = (): void => {
|
||||
if (!summary || pendingAction === null) {
|
||||
return;
|
||||
}
|
||||
const remark = reviewRemark.trim() || undefined;
|
||||
if (pendingAction === "approve") {
|
||||
void runAction(actionLabel(pendingAction), () => postAdminApproveSettlementBatch(batchId, remark));
|
||||
return;
|
||||
}
|
||||
if (pendingAction === "reject") {
|
||||
void runAction(actionLabel(pendingAction), () => postAdminRejectSettlementBatch(batchId, remark));
|
||||
return;
|
||||
}
|
||||
void runAction(actionLabel(pendingAction), () => postAdminPayoutSettlementBatch(batchId));
|
||||
};
|
||||
|
||||
const openActionDialog = (action: SettlementAction): void => {
|
||||
setReviewRemark("");
|
||||
setPendingAction(action);
|
||||
};
|
||||
|
||||
async function exportCsv(): Promise<void> {
|
||||
setActing(t("export"));
|
||||
try {
|
||||
@@ -156,6 +209,14 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
|
||||
<span className="text-muted-foreground">{t("winTotal")}</span>{" "}
|
||||
<span className="tabular-nums">{summary.total_win_count}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">{t("totalBet")}</span>{" "}
|
||||
<span className="font-mono tabular-nums">{formatAdminMinorUnits(summary.total_bet_amount)}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">{t("actualDeduct")}</span>{" "}
|
||||
<span className="font-mono tabular-nums">{formatAdminMinorUnits(summary.total_actual_deduct)}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">{t("payoutAmount")}</span>{" "}
|
||||
<span className="font-mono tabular-nums">{formatAdminMinorUnits(summary.total_payout_amount)}</span>
|
||||
@@ -166,6 +227,17 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
|
||||
{formatAdminMinorUnits(summary.total_jackpot_payout_amount)}
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">{t("platformProfit")}</span>{" "}
|
||||
<span
|
||||
className={cn(
|
||||
"font-mono tabular-nums",
|
||||
summary.platform_profit < 0 ? "text-destructive" : "text-emerald-700",
|
||||
)}
|
||||
>
|
||||
{formatAdminMinorUnits(summary.platform_profit)}
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">{t("startedAt")}</span> {formatDt(summary.started_at)}
|
||||
</p>
|
||||
@@ -177,8 +249,8 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={acting !== null || summary.status !== "pending_review"}
|
||||
onClick={() => void runAction(t("approve"), () => postAdminApproveSettlementBatch(batchId))}
|
||||
disabled={!canReviewSettlement || acting !== null || summary.status !== "pending_review"}
|
||||
onClick={() => openActionDialog("approve")}
|
||||
>
|
||||
{t("approve")}
|
||||
</Button>
|
||||
@@ -186,16 +258,16 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={acting !== null || summary.status !== "pending_review"}
|
||||
onClick={() => void runAction(t("reject"), () => postAdminRejectSettlementBatch(batchId))}
|
||||
disabled={!canReviewSettlement || acting !== null || summary.status !== "pending_review"}
|
||||
onClick={() => openActionDialog("reject")}
|
||||
>
|
||||
{t("reject")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={acting !== null || summary.status !== "approved"}
|
||||
onClick={() => void runAction(t("runPayout"), () => postAdminPayoutSettlementBatch(batchId))}
|
||||
disabled={!canManagePayout || acting !== null || summary.status !== "approved"}
|
||||
onClick={() => openActionDialog("payout")}
|
||||
>
|
||||
{t("runPayout")}
|
||||
</Button>
|
||||
@@ -216,7 +288,7 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
|
||||
<CardContent>
|
||||
{details ? (
|
||||
<>
|
||||
<Table>
|
||||
<Table id={`settlement-details-table-${batchId}`}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("ticketNo")}</TableHead>
|
||||
@@ -267,6 +339,71 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Dialog
|
||||
open={pendingAction !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && acting === null) {
|
||||
setPendingAction(null);
|
||||
setReviewRemark("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
{summary && pendingAction ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("confirmAction", { name: actionLabel(pendingAction) })}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{pendingAction === "payout"
|
||||
? t("confirmPayoutDesc")
|
||||
: t("confirmActionDesc", { drawNo: summary.draw_no ?? "—" })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<p className="rounded-md border bg-muted/30 p-3 text-sm">
|
||||
{t("confirmAmountLine", {
|
||||
actual: formatAdminMinorUnits(summary.total_actual_deduct),
|
||||
payout: formatAdminMinorUnits(summary.total_payout_amount),
|
||||
profit: formatAdminMinorUnits(summary.platform_profit),
|
||||
})}
|
||||
</p>
|
||||
{pendingAction !== "payout" ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="settlement-detail-review-remark">{t("reviewRemark")}</Label>
|
||||
<Textarea
|
||||
id="settlement-detail-review-remark"
|
||||
value={reviewRemark}
|
||||
placeholder={t("reviewRemarkPlaceholder")}
|
||||
onChange={(event) => setReviewRemark(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={acting !== null}
|
||||
onClick={() => {
|
||||
setPendingAction(null);
|
||||
setReviewRemark("");
|
||||
}}
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={pendingAction === "reject" ? "destructive" : "default"}
|
||||
disabled={acting !== null}
|
||||
onClick={confirmPendingAction}
|
||||
>
|
||||
{t("confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</ModuleScaffold>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,16 +6,24 @@ import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
downloadAdminSettlementBatchExport,
|
||||
getAdminSettlementBatches,
|
||||
postAdminApproveSettlementBatch,
|
||||
postAdminPayoutSettlementBatch,
|
||||
postAdminRejectSettlementBatch,
|
||||
} from "@/api/admin-settlement";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
@@ -33,25 +41,44 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { formatAdminMinorUnits } from "@/lib/money";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PRD_PAYOUT_MANAGE, PRD_PAYOUT_REVIEW } from "@/modules/draws/draw-prd";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { AdminSettlementBatchListData, AdminSettlementBatchRow } from "@/types/api/admin-settlement";
|
||||
|
||||
import { settlementModuleMeta } from "@/modules/settlement/meta";
|
||||
|
||||
const STATUS_ALL = "__all__";
|
||||
const STATUS_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: STATUS_ALL, label: "statusOptions.all" },
|
||||
{ value: "running", label: "statusOptions.running" },
|
||||
{ value: "pending_review", label: "statusOptions.pending_review" },
|
||||
{ value: "approved", label: "statusOptions.approved" },
|
||||
{ value: "rejected", label: "statusOptions.rejected" },
|
||||
{ value: "paid", label: "statusOptions.paid" },
|
||||
{ value: "completed", label: "statusOptions.completed" },
|
||||
{ value: "failed", label: "statusOptions.failed" },
|
||||
];
|
||||
|
||||
type SettlementAction = "approve" | "reject" | "payout";
|
||||
|
||||
type PendingAction = {
|
||||
row: AdminSettlementBatchRow;
|
||||
action: SettlementAction;
|
||||
};
|
||||
|
||||
function settlementStatusText(value: string, t: (key: string) => string): string {
|
||||
const option = STATUS_OPTIONS.find((item) => item.value === value);
|
||||
return option ? t(option.label) : value;
|
||||
}
|
||||
|
||||
export function SettlementBatchesConsole() {
|
||||
const { t } = useTranslation(["settlement", "common"]);
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
const profile = useAdminProfile();
|
||||
const canReviewSettlement = adminHasAnyPermission(profile?.permissions, [PRD_PAYOUT_REVIEW]);
|
||||
const canManagePayout = adminHasAnyPermission(profile?.permissions, [PRD_PAYOUT_MANAGE]);
|
||||
const [data, setData] = useState<AdminSettlementBatchListData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -62,6 +89,8 @@ export function SettlementBatchesConsole() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(20);
|
||||
const [actingId, setActingId] = useState<number | null>(null);
|
||||
const [pendingAction, setPendingAction] = useState<PendingAction | null>(null);
|
||||
const [reviewRemark, setReviewRemark] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -101,6 +130,8 @@ export function SettlementBatchesConsole() {
|
||||
try {
|
||||
await action();
|
||||
toast.success(t("actionSuccess", { name: label }));
|
||||
setPendingAction(null);
|
||||
setReviewRemark("");
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("actionFailed", { name: label }));
|
||||
@@ -109,172 +140,184 @@ export function SettlementBatchesConsole() {
|
||||
}
|
||||
}
|
||||
|
||||
async function exportBatch(batchId: number): Promise<void> {
|
||||
setActingId(batchId);
|
||||
try {
|
||||
const blob = await downloadAdminSettlementBatchExport(batchId);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `settlement-${batchId}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("exportFailed"));
|
||||
} finally {
|
||||
setActingId(null);
|
||||
const actionLabel = (action: SettlementAction): string => {
|
||||
if (action === "approve") {
|
||||
return t("approve");
|
||||
}
|
||||
}
|
||||
if (action === "reject") {
|
||||
return t("reject");
|
||||
}
|
||||
return t("runPayout");
|
||||
};
|
||||
|
||||
const confirmPendingAction = (): void => {
|
||||
if (!pendingAction) {
|
||||
return;
|
||||
}
|
||||
const { row, action } = pendingAction;
|
||||
const remark = reviewRemark.trim() || undefined;
|
||||
if (action === "approve") {
|
||||
void runBatchAction(row.id, actionLabel(action), () => postAdminApproveSettlementBatch(row.id, remark));
|
||||
return;
|
||||
}
|
||||
if (action === "reject") {
|
||||
void runBatchAction(row.id, actionLabel(action), () => postAdminRejectSettlementBatch(row.id, remark));
|
||||
return;
|
||||
}
|
||||
void runBatchAction(row.id, actionLabel(action), () => postAdminPayoutSettlementBatch(row.id));
|
||||
};
|
||||
|
||||
const openActionDialog = (row: AdminSettlementBatchRow, action: SettlementAction): void => {
|
||||
setReviewRemark("");
|
||||
setPendingAction({ row, action });
|
||||
};
|
||||
|
||||
return (
|
||||
<ModuleScaffold>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-lg font-semibold tracking-tight">{settlementModuleMeta.title}</h1>
|
||||
</div>
|
||||
<Card className="mb-6">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">{t("filter")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:items-end">
|
||||
<div className="flex min-w-[12rem] flex-1 flex-col gap-1.5">
|
||||
<Label htmlFor="sb-draw-no">{t("drawNo")}</Label>
|
||||
<Input
|
||||
id="sb-draw-no"
|
||||
value={draftDrawNo}
|
||||
onChange={(e) => setDraftDrawNo(e.target.value)}
|
||||
placeholder={t("placeholderDrawNo")}
|
||||
className="font-mono"
|
||||
/>
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header space-y-6 pb-6">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<CardTitle className="admin-list-title">{t("batchList")}</CardTitle>
|
||||
</div>
|
||||
<div className="flex min-w-[10rem] flex-col gap-1.5">
|
||||
<Label>{t("status")}</Label>
|
||||
<Select value={draftStatus} onValueChange={(v) => setDraftStatus(v ?? STATUS_ALL)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{t(o.label)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="admin-list-toolbar">
|
||||
<div className="admin-list-field">
|
||||
<Label htmlFor="sb-draw-no" className="sm:w-10 sm:shrink-0">
|
||||
{t("drawNo")}
|
||||
</Label>
|
||||
<Input
|
||||
id="sb-draw-no"
|
||||
value={draftDrawNo}
|
||||
onChange={(e) => setDraftDrawNo(e.target.value)}
|
||||
placeholder={t("placeholderDrawNo")}
|
||||
className="w-full font-mono sm:w-[18rem] xl:w-[20rem]"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-list-field">
|
||||
<Label className="sm:w-10 sm:shrink-0">{t("status")}</Label>
|
||||
<Select value={draftStatus} onValueChange={(v) => setDraftStatus(v ?? STATUS_ALL)}>
|
||||
<SelectTrigger className="w-full sm:w-40">
|
||||
<SelectValue>{settlementStatusText(draftStatus, t)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{t(o.label)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="admin-list-actions">
|
||||
<AdminTableExportButton
|
||||
tableId="settlement-batches-table"
|
||||
filename="结算批次"
|
||||
sheetName="结算批次"
|
||||
/>
|
||||
<Button type="button" className="xl:shrink-0" onClick={applyFilters}>
|
||||
{t("apply")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="button" onClick={applyFilters}>
|
||||
{t("apply")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("batchList")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="admin-list-content pt-0">
|
||||
{error ? <p className="text-destructive text-sm">{error}</p> : null}
|
||||
{loading && !data ? (
|
||||
<p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>{t("drawNo")}</TableHead>
|
||||
<TableHead>{t("version", { ns: "draws", version: "" }).replace(" v", "").trim()}</TableHead>
|
||||
<TableHead>{t("reviewStatus")}</TableHead>
|
||||
<TableHead>{t("status")}</TableHead>
|
||||
<TableHead className="text-right">{t("ticketCount")}</TableHead>
|
||||
<TableHead className="text-right">{t("winCount")}</TableHead>
|
||||
<TableHead className="text-right">{t("payoutTotal")}</TableHead>
|
||||
<TableHead className="text-right">{t("jackpot")}</TableHead>
|
||||
<TableHead>{t("finishedAt")}</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(data?.items ?? []).map((row: AdminSettlementBatchRow) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="font-mono text-xs">{row.id}</TableCell>
|
||||
<TableCell className="font-mono text-sm">{row.draw_no ?? "—"}</TableCell>
|
||||
<TableCell className="font-mono text-xs">v{row.settle_version}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{row.review_status ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
<div className="admin-table-shell">
|
||||
<Table id="settlement-batches-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>{t("drawNo")}</TableHead>
|
||||
<TableHead className="text-right">{t("totalBet")}</TableHead>
|
||||
<TableHead className="text-right">{t("actualDeduct")}</TableHead>
|
||||
<TableHead className="text-right">{t("payoutTotal")}</TableHead>
|
||||
<TableHead className="text-right">{t("platformProfit")}</TableHead>
|
||||
<TableHead>{t("reviewStatus")}</TableHead>
|
||||
<TableHead>{t("status")}</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(data?.items ?? []).map((row: AdminSettlementBatchRow) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="font-mono text-xs">{row.id}</TableCell>
|
||||
<TableCell className="font-mono text-sm">{row.draw_no ?? "—"}</TableCell>
|
||||
<TableCell className="text-right font-mono text-xs tabular-nums">
|
||||
{formatAdminMinorUnits(row.total_bet_amount)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-xs tabular-nums">
|
||||
{formatAdminMinorUnits(row.total_actual_deduct)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-xs tabular-nums">
|
||||
{formatAdminMinorUnits(row.total_payout_amount)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={cn(
|
||||
"rounded px-1.5 py-0.5 text-xs font-medium",
|
||||
row.status === "completed" && "bg-emerald-500/15 text-emerald-800",
|
||||
row.status === "running" && "bg-amber-500/15 text-amber-900",
|
||||
row.status === "failed" && "bg-destructive/15 text-destructive",
|
||||
"text-right font-mono text-xs tabular-nums",
|
||||
row.platform_profit < 0 ? "text-destructive" : "text-emerald-700",
|
||||
)}
|
||||
>
|
||||
{row.status}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{row.total_ticket_count}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{row.total_win_count}</TableCell>
|
||||
<TableCell className="text-right font-mono text-xs tabular-nums">
|
||||
{formatAdminMinorUnits(row.total_payout_amount)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-xs tabular-nums">
|
||||
{formatAdminMinorUnits(row.total_jackpot_payout_amount)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{formatDt(row.finished_at ?? row.started_at)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap justify-end gap-1.5">
|
||||
<Link
|
||||
href={`/admin/settlement-batches/${row.id}/details`}
|
||||
className={cn(buttonVariants({ variant: "link", size: "sm" }), "px-0")}
|
||||
{formatAdminMinorUnits(row.platform_profit)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{row.review_status ?? "—"}</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded px-1.5 py-0.5 text-xs font-medium",
|
||||
["completed", "paid"].includes(row.status) && "bg-emerald-500/15 text-emerald-800",
|
||||
row.status === "running" && "bg-amber-500/15 text-amber-900",
|
||||
row.status === "pending_review" && "bg-blue-500/15 text-blue-800",
|
||||
row.status === "approved" && "bg-indigo-500/15 text-indigo-800",
|
||||
row.status === "rejected" && "bg-muted text-muted-foreground",
|
||||
row.status === "failed" && "bg-destructive/15 text-destructive",
|
||||
)}
|
||||
>
|
||||
{t("details")}
|
||||
</Link>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={actingId !== null || row.status !== "pending_review"}
|
||||
onClick={() => void runBatchAction(row.id, t("approve"), () => postAdminApproveSettlementBatch(row.id))}
|
||||
>
|
||||
{t("pass")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={actingId !== null || row.status !== "pending_review"}
|
||||
onClick={() => void runBatchAction(row.id, t("reject"), () => postAdminRejectSettlementBatch(row.id))}
|
||||
>
|
||||
{t("reject")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={actingId !== null || row.status !== "approved"}
|
||||
onClick={() => void runBatchAction(row.id, t("runPayout"), () => postAdminPayoutSettlementBatch(row.id))}
|
||||
>
|
||||
{t("payout")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={actingId !== null}
|
||||
onClick={() => void exportBatch(row.id)}
|
||||
>
|
||||
{t("export")}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{settlementStatusText(row.status, t)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap justify-end gap-1.5">
|
||||
<Link
|
||||
href={`/admin/settlement-batches/${row.id}/details`}
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "!border-border")}
|
||||
>
|
||||
{t("details")}
|
||||
</Link>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!canReviewSettlement || actingId !== null || row.status !== "pending_review"}
|
||||
onClick={() => openActionDialog(row, "approve")}
|
||||
>
|
||||
{t("pass")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!canReviewSettlement || actingId !== null || row.status !== "pending_review"}
|
||||
onClick={() => openActionDialog(row, "reject")}
|
||||
>
|
||||
{t("reject")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={!canManagePayout || actingId !== null || row.status !== "approved"}
|
||||
onClick={() => openActionDialog(row, "payout")}
|
||||
>
|
||||
{t("payout")}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
{data ? (
|
||||
<AdminListPaginationFooter
|
||||
@@ -293,6 +336,71 @@ export function SettlementBatchesConsole() {
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Dialog
|
||||
open={pendingAction !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && actingId === null) {
|
||||
setPendingAction(null);
|
||||
setReviewRemark("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
{pendingAction ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("confirmAction", { name: actionLabel(pendingAction.action) })}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{pendingAction.action === "payout"
|
||||
? t("confirmPayoutDesc")
|
||||
: t("confirmActionDesc", { drawNo: pendingAction.row.draw_no ?? "—" })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<p className="rounded-md border bg-muted/30 p-3 text-sm">
|
||||
{t("confirmAmountLine", {
|
||||
actual: formatAdminMinorUnits(pendingAction.row.total_actual_deduct),
|
||||
payout: formatAdminMinorUnits(pendingAction.row.total_payout_amount),
|
||||
profit: formatAdminMinorUnits(pendingAction.row.platform_profit),
|
||||
})}
|
||||
</p>
|
||||
{pendingAction.action !== "payout" ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="settlement-review-remark">{t("reviewRemark")}</Label>
|
||||
<Textarea
|
||||
id="settlement-review-remark"
|
||||
value={reviewRemark}
|
||||
placeholder={t("reviewRemarkPlaceholder")}
|
||||
onChange={(event) => setReviewRemark(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={actingId !== null}
|
||||
onClick={() => {
|
||||
setPendingAction(null);
|
||||
setReviewRemark("");
|
||||
}}
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={pendingAction.action === "reject" ? "destructive" : "default"}
|
||||
disabled={actingId !== null}
|
||||
onClick={confirmPendingAction}
|
||||
>
|
||||
{t("confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</ModuleScaffold>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getAdminPlayerTicketItems } from "@/api/admin-player-tickets";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -75,36 +76,43 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-none">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("playerTicketQuery")}</CardTitle>
|
||||
<Card className="admin-list-card w-full max-w-none">
|
||||
<CardHeader className="admin-list-header">
|
||||
<CardTitle className="admin-list-title">{t("playerTicketQuery")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="pt-player">{t("playerId")}</Label>
|
||||
<CardContent className="admin-list-content">
|
||||
<div className="admin-list-toolbar">
|
||||
<div className="admin-list-field">
|
||||
<Label htmlFor="pt-player" className="sm:w-20 sm:shrink-0">{t("playerId")}</Label>
|
||||
<Input
|
||||
id="pt-player"
|
||||
inputMode="numeric"
|
||||
className="w-40 font-mono"
|
||||
className="w-full font-mono sm:w-36"
|
||||
placeholder="players.id"
|
||||
value={playerIdDraft}
|
||||
onChange={(e) => setPlayerIdDraft(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid min-w-[10rem] flex-1 gap-1.5">
|
||||
<Label htmlFor="pt-draw">{t("drawNoOptional")}</Label>
|
||||
<div className="admin-list-field xl:min-w-0">
|
||||
<Label htmlFor="pt-draw" className="sm:w-20 sm:shrink-0">{t("drawNoOptional")}</Label>
|
||||
<Input
|
||||
id="pt-draw"
|
||||
className="font-mono text-sm"
|
||||
className="w-full font-mono text-sm sm:w-[16rem] xl:w-[20rem]"
|
||||
placeholder={t("drawNoPlaceholder")}
|
||||
value={drawNoDraft}
|
||||
onChange={(e) => setDrawNoDraft(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="button" onClick={() => runSearch()}>
|
||||
{t("query")}
|
||||
</Button>
|
||||
<div className="admin-list-actions">
|
||||
<AdminTableExportButton
|
||||
tableId="player-tickets-table"
|
||||
filename="玩家注单"
|
||||
sheetName="玩家注单"
|
||||
/>
|
||||
<Button type="button" onClick={() => runSearch()}>
|
||||
{t("query")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{err ? <p className="text-sm text-destructive">{err}</p> : null}
|
||||
@@ -114,8 +122,8 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
|
||||
{data ? (
|
||||
<>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<div className="admin-table-shell">
|
||||
<Table id="player-tickets-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("ticketNo")}</TableHead>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "@/api/admin-wallet";
|
||||
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -410,6 +411,11 @@ export function TransferOrdersPanel(): React.ReactElement {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<AdminTableExportButton
|
||||
tableId="wallet-transfer-orders-table"
|
||||
filename="钱包转账订单"
|
||||
sheetName="转账订单"
|
||||
/>
|
||||
<Button type="button" size="sm" onClick={() => runSearch()}>
|
||||
{t("search")}
|
||||
</Button>
|
||||
@@ -429,7 +435,7 @@ export function TransferOrdersPanel(): React.ReactElement {
|
||||
{data ? (
|
||||
<>
|
||||
<div className="rounded-md border">
|
||||
<Table className="table-fixed">
|
||||
<Table id="wallet-transfer-orders-table" className="table-fixed">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-0 max-w-[14rem]">{t("localTransferNo")}</TableHead>
|
||||
@@ -729,6 +735,11 @@ export function WalletTxnsPanel(): React.ReactElement {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<AdminTableExportButton
|
||||
tableId="wallet-transactions-table"
|
||||
filename="钱包流水"
|
||||
sheetName="钱包流水"
|
||||
/>
|
||||
<Button type="button" size="sm" onClick={() => runSearch()}>
|
||||
{t("search")}
|
||||
</Button>
|
||||
@@ -748,7 +759,7 @@ export function WalletTxnsPanel(): React.ReactElement {
|
||||
{data ? (
|
||||
<>
|
||||
<div className="rounded-md border">
|
||||
<Table className="table-fixed">
|
||||
<Table id="wallet-transactions-table" className="table-fixed">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-0 max-w-[14rem]">{t("txnNo")}</TableHead>
|
||||
@@ -868,6 +879,11 @@ export function PlayerWalletPanel(): React.ReactElement {
|
||||
className="w-40"
|
||||
/>
|
||||
</div>
|
||||
<AdminTableExportButton
|
||||
tableId="player-wallet-table"
|
||||
filename="玩家钱包"
|
||||
sheetName="玩家钱包"
|
||||
/>
|
||||
<Button type="button" onClick={() => void query()} disabled={loading}>
|
||||
{loading ? t("querying") : t("query")}
|
||||
</Button>
|
||||
@@ -879,7 +895,7 @@ export function PlayerWalletPanel(): React.ReactElement {
|
||||
<span className="text-muted-foreground">{t("sitePlayer")}</span>{" "}
|
||||
{result.player.site_code}:{result.player.site_player_id}
|
||||
</p>
|
||||
<Table>
|
||||
<Table id="player-wallet-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("walletType")}</TableHead>
|
||||
|
||||
@@ -10,8 +10,11 @@ export type AdminSettlementBatchRow = {
|
||||
paid_at: string | null;
|
||||
total_ticket_count: number;
|
||||
total_win_count: number;
|
||||
total_bet_amount: number;
|
||||
total_actual_deduct: number;
|
||||
total_payout_amount: number;
|
||||
total_jackpot_payout_amount: number;
|
||||
platform_profit: number;
|
||||
started_at: string | null;
|
||||
finished_at: string | null;
|
||||
created_at: string | null;
|
||||
@@ -44,8 +47,11 @@ export type AdminSettlementBatchShowData = {
|
||||
paid_at: string | null;
|
||||
total_ticket_count: number;
|
||||
total_win_count: number;
|
||||
total_bet_amount: number;
|
||||
total_actual_deduct: number;
|
||||
total_payout_amount: number;
|
||||
total_jackpot_payout_amount: number;
|
||||
platform_profit: number;
|
||||
started_at: string | null;
|
||||
finished_at: string | null;
|
||||
created_at: string | null;
|
||||
|
||||
Reference in New Issue
Block a user