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