feat: 重构管理端列表与风控/结算导航,新增表格导出和结算审核确认
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
||||
putAdminRolePermissions,
|
||||
} from "@/api/admin-users";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
@@ -268,9 +269,16 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
{t("createRole")}
|
||||
</Button>
|
||||
</div>
|
||||
<Button type="button" variant="secondary" onClick={() => void load()}>
|
||||
{t("actions.refresh", { ns: "common" })}
|
||||
</Button>
|
||||
<div className="admin-list-actions">
|
||||
<AdminTableExportButton
|
||||
tableId="admin-roles-table"
|
||||
filename="角色列表"
|
||||
sheetName="角色列表"
|
||||
/>
|
||||
<Button type="button" variant="secondary" onClick={() => void load()}>
|
||||
{t("actions.refresh", { ns: "common" })}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
|
||||
@@ -278,7 +286,7 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
||||
) : null}
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<Table id="admin-roles-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-16">ID</TableHead>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
putAdminUserRoles,
|
||||
} from "@/api/admin-users";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -296,47 +298,61 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-none flex-col gap-6">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row flex-wrap items-end justify-between gap-4">
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||
<CardTitle>{t("listTitle")}</CardTitle>
|
||||
<CardTitle className="admin-list-title">{t("listTitle")}</CardTitle>
|
||||
<Button type="button" size="sm" onClick={() => openCreateAccount()}>
|
||||
{t("createAdmin")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex w-full max-w-lg gap-2">
|
||||
<Input
|
||||
value={keyword}
|
||||
placeholder={t("searchPlaceholder")}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
<div className="admin-list-toolbar">
|
||||
<div className="admin-list-field xl:min-w-0">
|
||||
<Label htmlFor="admin-user-search" className="sm:w-20 sm:shrink-0">
|
||||
{t("actions.search", { ns: "common" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="admin-user-search"
|
||||
value={keyword}
|
||||
className="w-full sm:w-[18rem] xl:w-[24rem]"
|
||||
placeholder={t("searchPlaceholder")}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
setPage(1);
|
||||
setQuery(keyword.trim());
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-list-actions">
|
||||
<AdminTableExportButton
|
||||
tableId="admin-users-table"
|
||||
filename="后台用户列表"
|
||||
sheetName="后台用户"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPage(1);
|
||||
setQuery(keyword.trim());
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPage(1);
|
||||
setQuery(keyword.trim());
|
||||
}}
|
||||
>
|
||||
{t("actions.search", { ns: "common" })}
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" onClick={() => void load()}>
|
||||
{t("actions.refresh", { ns: "common" })}
|
||||
</Button>
|
||||
}}
|
||||
>
|
||||
{t("actions.search", { ns: "common" })}
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" onClick={() => void load()}>
|
||||
{t("actions.refresh", { ns: "common" })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<CardContent className="admin-list-content">
|
||||
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
|
||||
{loading && items.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
||||
) : null}
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<div className="admin-table-shell">
|
||||
<Table id="admin-users-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-16">ID</TableHead>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getAdminAuditLogs } from "@/api/admin-audit";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -66,44 +67,47 @@ export function AuditLogsConsole(): React.ReactElement {
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-none">
|
||||
<CardHeader className="flex flex-row flex-wrap items-end justify-between gap-4">
|
||||
<CardHeader className="flex flex-col gap-4">
|
||||
<div>
|
||||
<CardTitle>{t("title")}</CardTitle>
|
||||
</div>
|
||||
<Button type="button" variant="secondary" size="sm" onClick={() => void load()}>
|
||||
{t("actions.refresh", { ns: "common" })}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="aud-mod">{t("moduleCode")}</Label>
|
||||
<div className="admin-list-toolbar">
|
||||
<div className="admin-list-field">
|
||||
<Label htmlFor="aud-mod" className="sm:w-20 sm:shrink-0">{t("moduleCode")}</Label>
|
||||
<Input
|
||||
id="aud-mod"
|
||||
value={moduleCode}
|
||||
onChange={(e) => setModuleCode(e.target.value)}
|
||||
placeholder={t("exactMatch")}
|
||||
className="w-full sm:w-40"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="aud-act">{t("actionCode")}</Label>
|
||||
<div className="admin-list-field">
|
||||
<Label htmlFor="aud-act" className="sm:w-20 sm:shrink-0">{t("actionCode")}</Label>
|
||||
<Input
|
||||
id="aud-act"
|
||||
value={actionCode}
|
||||
onChange={(e) => setActionCode(e.target.value)}
|
||||
placeholder={t("exactMatch")}
|
||||
className="w-full sm:w-40"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="aud-op">{t("operatorType")}</Label>
|
||||
<div className="admin-list-field">
|
||||
<Label htmlFor="aud-op" className="sm:w-20 sm:shrink-0">{t("operatorType")}</Label>
|
||||
<Input
|
||||
id="aud-op"
|
||||
value={operatorType}
|
||||
onChange={(e) => setOperatorType(e.target.value)}
|
||||
placeholder={t("operatorTypePlaceholder")}
|
||||
className="w-full sm:w-40"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<div className="admin-list-actions">
|
||||
<AdminTableExportButton
|
||||
tableId="audit-logs-table"
|
||||
filename="审计日志"
|
||||
sheetName="审计日志"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -115,9 +119,13 @@ export function AuditLogsConsole(): React.ReactElement {
|
||||
>
|
||||
{t("actions.search", { ns: "common" })}
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" size="sm" onClick={() => void load()}>
|
||||
{t("actions.refresh", { ns: "common" })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
|
||||
{loading && !data ? (
|
||||
<p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p>
|
||||
@@ -126,7 +134,7 @@ export function AuditLogsConsole(): React.ReactElement {
|
||||
{data ? (
|
||||
<>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<Table id="audit-logs-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-20">ID</TableHead>
|
||||
|
||||
@@ -546,7 +546,7 @@ export function DashboardConsole(): ReactElement {
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"mt-1 h-7 border-[#2563eb]/30 px-2 text-xs text-[#2563eb] hover:bg-[#2563eb]/5",
|
||||
)}
|
||||
href={`/admin/risk/draws/${drawId}/occupancy`}
|
||||
href={`/admin/draws/${drawId}/risk/occupancy`}
|
||||
>
|
||||
{t("occupancyDetails")}
|
||||
</Link>
|
||||
@@ -592,7 +592,7 @@ export function DashboardConsole(): ReactElement {
|
||||
</div>
|
||||
{drawId != null ? (
|
||||
<Link
|
||||
href={`/admin/risk/draws/${drawId}/hot`}
|
||||
href={`/admin/draws/${drawId}/risk/hot`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"h-7 border-[#2563eb]/30 px-2 text-xs text-[#2563eb] hover:bg-[#2563eb]/5",
|
||||
@@ -619,7 +619,7 @@ export function DashboardConsole(): ReactElement {
|
||||
</div>
|
||||
{drawId != null ? (
|
||||
<Link
|
||||
href={`/admin/risk/draws/${drawId}/sold-out`}
|
||||
href={`/admin/draws/${drawId}/risk/sold-out`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline", size: "sm" }),
|
||||
"h-7 border-[#2563eb]/30 px-2 text-xs text-[#2563eb] hover:bg-[#2563eb]/5",
|
||||
|
||||
@@ -33,6 +33,12 @@ import {
|
||||
PRD_PAYOUT_REVIEW,
|
||||
} from "./draw-prd";
|
||||
|
||||
function drawStatusText(status: string, t: (key: string) => string): string {
|
||||
const key = `statusOptions.${status}`;
|
||||
const translated = t(key);
|
||||
return translated === key ? status : translated;
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="grid gap-1 sm:grid-cols-[10rem_1fr] sm:items-start">
|
||||
@@ -114,12 +120,16 @@ export function DrawDetailConsole({ drawId }: { drawId: string }) {
|
||||
<CardTitle className="text-xl">{data.draw_no}</CardTitle>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t("drawDetail")}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<DrawStatusBadge status={data.status} label={data.status} />
|
||||
<div className="flex flex-col items-end gap-1 text-right">
|
||||
<DrawStatusBadge
|
||||
status={data.hall_preview_status}
|
||||
label={t("hallPreviewStatus", { status: data.hall_preview_status })}
|
||||
status={data.status}
|
||||
label={drawStatusText(data.status, t)}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("hallPreviewStatus", {
|
||||
status: drawStatusText(data.hall_preview_status, t),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { getAdminDrawFinanceSummary } from "@/api/admin-draws";
|
||||
import { postAdminRunDrawSettlement } from "@/api/admin-settlement";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
@@ -156,7 +157,14 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
|
||||
<p className="text-muted-foreground text-sm">{t("noSettlementBatches")}</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<Table>
|
||||
<div className="admin-table-toolbar">
|
||||
<AdminTableExportButton
|
||||
tableId={`draw-finance-table-${drawId}`}
|
||||
filename={`期号收支-${data.draw_no}`}
|
||||
sheetName="期号收支"
|
||||
/>
|
||||
</div>
|
||||
<Table id={`draw-finance-table-${drawId}`}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-20">ID</TableHead>
|
||||
|
||||
@@ -12,6 +12,10 @@ const segments = [
|
||||
{ suffix: "/results", key: "results", label: "subnav.results" },
|
||||
{ suffix: "/finance", key: "finance", label: "subnav.finance" },
|
||||
{ suffix: "/review", key: "review", label: "subnav.review" },
|
||||
{ suffix: "/risk/occupancy", key: "riskOccupancy", label: "subnav.riskOccupancy" },
|
||||
{ suffix: "/risk/hot", key: "riskHot", label: "subnav.riskHot" },
|
||||
{ suffix: "/risk/sold-out", key: "riskSoldOut", label: "subnav.riskSoldOut" },
|
||||
{ suffix: "/risk/pools", key: "riskPools", label: "subnav.riskPools" },
|
||||
] as const;
|
||||
|
||||
function isReviewTabActive(pathname: string, base: string): boolean {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { toast } from "sonner";
|
||||
|
||||
import { getAdminDraws, postAdminGenerateDrawPlan } from "@/api/admin-draws";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -140,60 +141,66 @@ export function DrawsIndexConsole() {
|
||||
}, [load]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<CardTitle className="text-lg">{t("statusListTitle")}</CardTitle>
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<CardTitle className="admin-list-title">{t("statusListTitle")}</CardTitle>
|
||||
{canManageDraw ? (
|
||||
<Button type="button" onClick={() => void generatePlan()} disabled={generating}>
|
||||
{generating ? t("generating") : t("generatePlan")}
|
||||
</Button>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Grid:桌面端标签一行 / 控件一行,避免 flex+items-end 与各列实际高度不一致;移动端单列自上而下 */}
|
||||
<div
|
||||
className="grid max-w-full gap-x-6 gap-y-3 sm:grid-cols-[minmax(0,12rem)_minmax(0,11rem)_auto] sm:gap-y-1.5"
|
||||
>
|
||||
<Label htmlFor="draw-filter-no">
|
||||
{t("drawNo")}
|
||||
</Label>
|
||||
<Input
|
||||
id="draw-filter-no"
|
||||
placeholder={t("fuzzyDrawNo")}
|
||||
value={draftDrawNo}
|
||||
className="w-full min-w-0 sm:w-full"
|
||||
onChange={(e) => setDraftDrawNo(e.target.value)}
|
||||
/>
|
||||
<Label htmlFor="draw-filter-status">
|
||||
{t("status")}
|
||||
</Label>
|
||||
<div className="min-w-0">
|
||||
<Select
|
||||
modal={false}
|
||||
value={
|
||||
draftStatus === "" ||
|
||||
!DRAW_STATUS_OPTIONS.some((o) => o.value === draftStatus)
|
||||
? DRAW_FILTER_ALL
|
||||
: draftStatus
|
||||
}
|
||||
onValueChange={(v) =>
|
||||
setDraftStatus(v == null || v === DRAW_FILTER_ALL ? "" : String(v))
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="draw-filter-status" className="h-8 w-full min-w-0 sm:w-44">
|
||||
<SelectValue>{drawStatusTriggerLabel}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start" sideOffset={6}>
|
||||
<SelectItem value={DRAW_FILTER_ALL}>{t("statusOptions.all")}</SelectItem>
|
||||
{DRAW_STATUS_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{t(o.label)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<CardContent className="admin-list-content">
|
||||
<div className="admin-list-toolbar">
|
||||
<div className="admin-list-field xl:min-w-0">
|
||||
<Label htmlFor="draw-filter-no" className="sm:w-10 sm:shrink-0">
|
||||
{t("drawNo")}
|
||||
</Label>
|
||||
<Input
|
||||
id="draw-filter-no"
|
||||
placeholder={t("fuzzyDrawNo")}
|
||||
value={draftDrawNo}
|
||||
className="w-full sm:w-[16rem] xl:w-[20rem]"
|
||||
onChange={(e) => setDraftDrawNo(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="[grid-area:act] flex flex-wrap gap-2">
|
||||
<div className="admin-list-field">
|
||||
<Label htmlFor="draw-filter-status" className="sm:w-10 sm:shrink-0">
|
||||
{t("status")}
|
||||
</Label>
|
||||
<div className="min-w-0">
|
||||
<Select
|
||||
modal={false}
|
||||
value={
|
||||
draftStatus === "" ||
|
||||
!DRAW_STATUS_OPTIONS.some((o) => o.value === draftStatus)
|
||||
? DRAW_FILTER_ALL
|
||||
: draftStatus
|
||||
}
|
||||
onValueChange={(v) =>
|
||||
setDraftStatus(v == null || v === DRAW_FILTER_ALL ? "" : String(v))
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="draw-filter-status" className="h-8 w-full sm:w-44">
|
||||
<SelectValue>{drawStatusTriggerLabel}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start" sideOffset={6}>
|
||||
<SelectItem value={DRAW_FILTER_ALL}>{t("statusOptions.all")}</SelectItem>
|
||||
{DRAW_STATUS_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{t(o.label)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-list-actions">
|
||||
<AdminTableExportButton
|
||||
tableId="draws-index-table"
|
||||
filename="期号列表"
|
||||
sheetName="期号列表"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -224,8 +231,8 @@ export function DrawsIndexConsole() {
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-lg border border-border">
|
||||
<Table>
|
||||
<div className="admin-table-shell">
|
||||
<Table id="draws-index-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("drawNo")}</TableHead>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getAdminJackpotContributions, getAdminJackpotPayoutLogs } from "@/api/admin-jackpot";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -127,7 +128,15 @@ export function JackpotRecordsConsole() {
|
||||
{loadingP && !payouts ? (
|
||||
<p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p>
|
||||
) : (
|
||||
<Table>
|
||||
<>
|
||||
<div className="admin-table-toolbar">
|
||||
<AdminTableExportButton
|
||||
tableId="jackpot-payout-table"
|
||||
filename="Jackpot派彩记录"
|
||||
sheetName="Jackpot派彩"
|
||||
/>
|
||||
</div>
|
||||
<Table id="jackpot-payout-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
@@ -155,6 +164,7 @@ export function JackpotRecordsConsole() {
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</>
|
||||
)}
|
||||
{payouts ? (
|
||||
<AdminListPaginationFooter
|
||||
@@ -182,7 +192,15 @@ export function JackpotRecordsConsole() {
|
||||
{loadingC && !contribs ? (
|
||||
<p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p>
|
||||
) : (
|
||||
<Table>
|
||||
<>
|
||||
<div className="admin-table-toolbar">
|
||||
<AdminTableExportButton
|
||||
tableId="jackpot-contribution-table"
|
||||
filename="Jackpot注入记录"
|
||||
sheetName="Jackpot注入"
|
||||
/>
|
||||
</div>
|
||||
<Table id="jackpot-contribution-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
@@ -212,6 +230,7 @@ export function JackpotRecordsConsole() {
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</>
|
||||
)}
|
||||
{contribs ? (
|
||||
<AdminListPaginationFooter
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
putAdminPlayer,
|
||||
} from "@/api/admin-player";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -249,49 +250,63 @@ export function PlayersConsole(): React.ReactElement {
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-none flex-col gap-6">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row flex-wrap items-end justify-between gap-4">
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||
<CardTitle>{t("listTitle")}</CardTitle>
|
||||
<CardTitle className="admin-list-title">{t("listTitle")}</CardTitle>
|
||||
{canManagePlayers ? (
|
||||
<Button type="button" size="sm" onClick={() => openCreateAccount()}>
|
||||
{t("createPlayer")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex w-full max-w-lg gap-2">
|
||||
<Input
|
||||
value={keyword}
|
||||
placeholder={t("searchPlaceholder")}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
<div className="admin-list-toolbar">
|
||||
<div className="admin-list-field xl:min-w-0">
|
||||
<Label htmlFor="player-search" className="sm:w-20 sm:shrink-0">
|
||||
{t("search")}
|
||||
</Label>
|
||||
<Input
|
||||
id="player-search"
|
||||
value={keyword}
|
||||
className="w-full sm:w-[18rem] xl:w-[24rem]"
|
||||
placeholder={t("searchPlaceholder")}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
setPage(1);
|
||||
setQuery(keyword.trim());
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-list-actions">
|
||||
<AdminTableExportButton
|
||||
tableId="players-table"
|
||||
filename="玩家列表"
|
||||
sheetName="玩家列表"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPage(1);
|
||||
setQuery(keyword.trim());
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPage(1);
|
||||
setQuery(keyword.trim());
|
||||
}}
|
||||
>
|
||||
{t("search")}
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" onClick={() => void load()}>
|
||||
{t("refresh")}
|
||||
</Button>
|
||||
}}
|
||||
>
|
||||
{t("search")}
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" onClick={() => void load()}>
|
||||
{t("refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<CardContent className="admin-list-content">
|
||||
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
|
||||
{loading && items.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
||||
) : null}
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<div className="admin-table-shell">
|
||||
<Table id="players-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-16">ID</TableHead>
|
||||
|
||||
@@ -128,8 +128,6 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
const [periodStartLocal, setPeriodStartLocal] = useState("");
|
||||
const [periodEndLocal, setPeriodEndLocal] = useState("");
|
||||
const [scopeLines, setScopeLines] = useState("");
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [itemsJson, setItemsJson] = useState("[]");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const loadJobs = useCallback(async () => {
|
||||
@@ -194,25 +192,7 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
return;
|
||||
}
|
||||
|
||||
let itemsPayload: Parameters<typeof postAdminReconcileJob>[0]["items"];
|
||||
|
||||
if (showAdvanced) {
|
||||
const trimmed = itemsJson.trim();
|
||||
if (trimmed !== "" && trimmed !== "[]") {
|
||||
try {
|
||||
itemsPayload = JSON.parse(trimmed) as NonNullable<
|
||||
Parameters<typeof postAdminReconcileJob>[0]["items"]
|
||||
>;
|
||||
} catch {
|
||||
toast.error(t("advancedJsonInvalid"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (itemsPayload === undefined) {
|
||||
itemsPayload = scopeLinesToItems(scopeLines);
|
||||
}
|
||||
const itemsPayload = scopeLinesToItems(scopeLines);
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
@@ -225,9 +205,6 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
toast.success(t("createSuccess"));
|
||||
setPage(1);
|
||||
setScopeLines("");
|
||||
if (showAdvanced) {
|
||||
setItemsJson("[]");
|
||||
}
|
||||
await loadJobs();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("createFailed"));
|
||||
@@ -240,17 +217,16 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
const im = items?.meta;
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-none flex-col gap-8">
|
||||
<div className="flex w-full max-w-none flex-col gap-6">
|
||||
{canCreate ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("createTitle")}</CardTitle>
|
||||
<CardDescription>
|
||||
{t("createDesc")}
|
||||
</CardDescription>
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header">
|
||||
<CardTitle className="admin-list-title">{t("createTitle")}</CardTitle>
|
||||
<CardDescription className="mt-1">{t("createDesc")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid max-w-3xl gap-4">
|
||||
<div className="grid gap-1.5">
|
||||
<CardContent className="admin-list-content pt-4">
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(220px,0.9fr)_minmax(180px,0.7fr)_minmax(180px,0.7fr)_auto] lg:items-end">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="rc-type">{t("reconcileType")}</Label>
|
||||
<Select
|
||||
modal={false}
|
||||
@@ -261,7 +237,7 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="rc-type" className="w-full max-w-md">
|
||||
<SelectTrigger id="rc-type" className="w-full">
|
||||
<SelectValue>{reconcileTypeLabel(reconcileType, t)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
@@ -272,8 +248,7 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="rc-start">{t("startTime")}</Label>
|
||||
<Input
|
||||
@@ -292,6 +267,9 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
onChange={(e) => setPeriodEndLocal(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="button" className="w-full lg:w-auto" onClick={() => void onCreate()} disabled={submitting}>
|
||||
{submitting ? t("submitting") : t("createTask")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="rc-scope">{t("scope")}</Label>
|
||||
@@ -299,66 +277,36 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
id="rc-scope"
|
||||
value={scopeLines}
|
||||
onChange={(e) => setScopeLines(e.target.value)}
|
||||
rows={5}
|
||||
rows={3}
|
||||
placeholder={t("scopePlaceholder")}
|
||||
className="min-h-[100px] text-sm"
|
||||
className="min-h-20 text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("scopeHint")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 border-t pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-fit px-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setShowAdvanced((x) => !x)}
|
||||
>
|
||||
{showAdvanced ? t("advancedToggleClose") : t("advancedToggleOpen")}
|
||||
</Button>
|
||||
{showAdvanced ? (
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="rc-items-adv">{t("advancedJson")}</Label>
|
||||
<Textarea
|
||||
id="rc-items-adv"
|
||||
value={itemsJson}
|
||||
onChange={(e) => setItemsJson(e.target.value)}
|
||||
rows={6}
|
||||
className="font-mono text-xs"
|
||||
placeholder='[{"side_a_ref":"TO-1","side_b_ref":"MAIN-1","difference_amount":100,"status":"mismatch"}]'
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<Button type="button" onClick={() => void onCreate()} disabled={submitting}>
|
||||
{submitting ? t("submitting") : t("createTask")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">{t("noCreatePermission")}</p>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row flex-wrap items-end justify-between gap-4">
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header flex flex-row flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>{t("jobsTitle")}</CardTitle>
|
||||
<CardTitle className="admin-list-title">{t("jobsTitle")}</CardTitle>
|
||||
<CardDescription className="mt-1.5">{t("jobsDesc")}</CardDescription>
|
||||
</div>
|
||||
<Button type="button" variant="secondary" size="sm" onClick={() => void loadJobs()}>
|
||||
{t("refresh")}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<CardContent className="admin-list-content pt-4">
|
||||
{jobsErr ? <p className="text-sm text-red-600 dark:text-red-400">{jobsErr}</p> : null}
|
||||
{jobsLoading && !jobs ? (
|
||||
<p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p>
|
||||
) : null}
|
||||
{jobs ? (
|
||||
<>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<div className="admin-table-shell">
|
||||
<Table id="reconcile-jobs-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-24">ID</TableHead>
|
||||
@@ -432,12 +380,12 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
</Card>
|
||||
|
||||
{selectedId != null ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("detailsTitle")}</CardTitle>
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header">
|
||||
<CardTitle className="admin-list-title">{t("detailsTitle")}</CardTitle>
|
||||
<CardDescription className="font-mono text-xs">#{selectedId}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<CardContent className="admin-list-content pt-4">
|
||||
{itemsLoading && !items ? (
|
||||
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
||||
) : null}
|
||||
@@ -446,8 +394,8 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
{items.job_no ? (
|
||||
<p className="font-mono text-sm text-muted-foreground">{t("jobNo")} {items.job_no}</p>
|
||||
) : null}
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<div className="admin-table-shell">
|
||||
<Table id={`reconcile-items-table-${selectedId ?? "none"}`}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-20">ID</TableHead>
|
||||
|
||||
@@ -215,7 +215,7 @@ export function ReportsConsole(): React.ReactElement {
|
||||
{data ? (
|
||||
<>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<Table id="reports-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-24">{t("id")}</TableHead>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getAdminDraws } from "@/api/admin-draws";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -101,17 +102,21 @@ export function RiskIndexConsole() {
|
||||
const lastPage = Math.max(1, data?.meta.last_page ?? 1);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row flex-wrap items-end justify-between gap-4">
|
||||
<CardTitle className="text-lg">{t("center")}</CardTitle>
|
||||
<div className="flex w-full max-w-4xl flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-end">
|
||||
<div className="grid flex-1 gap-2 sm:min-w-[12rem]">
|
||||
<Label htmlFor="risk-index-draw-no" className="text-xs text-muted-foreground">
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<CardTitle className="admin-list-title">{t("center")}</CardTitle>
|
||||
<div className="admin-list-toolbar lg:w-auto">
|
||||
<div className="admin-list-field lg:min-w-0">
|
||||
<Label
|
||||
htmlFor="risk-index-draw-no"
|
||||
className="text-xs text-muted-foreground sm:w-10 sm:shrink-0"
|
||||
>
|
||||
{t("drawNo")}
|
||||
</Label>
|
||||
<Input
|
||||
id="risk-index-draw-no"
|
||||
placeholder={t("fuzzyDrawNo")}
|
||||
className="w-full sm:w-[18rem] lg:w-[24rem]"
|
||||
value={drawNoInput}
|
||||
onChange={(e) => setDrawNoInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
@@ -121,8 +126,11 @@ export function RiskIndexConsole() {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:w-44">
|
||||
<Label htmlFor="risk-index-status" className="text-xs text-muted-foreground">
|
||||
<div className="admin-list-field">
|
||||
<Label
|
||||
htmlFor="risk-index-status"
|
||||
className="text-xs text-muted-foreground sm:w-10 sm:shrink-0"
|
||||
>
|
||||
{t("status")}
|
||||
</Label>
|
||||
<Select
|
||||
@@ -134,7 +142,7 @@ export function RiskIndexConsole() {
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="risk-index-status" size="sm" className="w-full">
|
||||
<SelectTrigger id="risk-index-status" size="sm" className="w-full sm:w-40">
|
||||
<SelectValue>{riskStatusTriggerLabel}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
@@ -147,7 +155,12 @@ export function RiskIndexConsole() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="admin-list-actions">
|
||||
<AdminTableExportButton
|
||||
tableId="risk-index-table"
|
||||
filename="风控中心期号列表"
|
||||
sheetName="风控中心"
|
||||
/>
|
||||
<Button type="button" size="sm" onClick={() => applySearch()}>
|
||||
{t("search")}
|
||||
</Button>
|
||||
@@ -157,13 +170,13 @@ export function RiskIndexConsole() {
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<CardContent className="admin-list-content">
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
{loading && (data?.items.length ?? 0) === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<Table>
|
||||
<div className="admin-table-shell">
|
||||
<Table id="risk-index-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("drawNo")}</TableHead>
|
||||
@@ -191,7 +204,7 @@ export function RiskIndexConsole() {
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Link
|
||||
href={`/admin/risk/draws/${row.id}/occupancy`}
|
||||
href={`/admin/draws/${row.id}/risk/occupancy`}
|
||||
className={cn(buttonVariants({ variant: "secondary", size: "sm" }))}
|
||||
>
|
||||
{t("enterRisk")}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getAdminRiskPoolLockLogs } from "@/api/admin-risk";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -31,6 +32,22 @@ import type { AdminRiskLockLogListData, AdminRiskLockLogRow } from "@/types/api/
|
||||
|
||||
const ACTION_ALL = "__all__";
|
||||
|
||||
function riskActionLabel(
|
||||
value: string,
|
||||
t: (key: string) => string,
|
||||
): string {
|
||||
if (value === ACTION_ALL) {
|
||||
return t("noLimit");
|
||||
}
|
||||
if (value === "lock") {
|
||||
return t("lock");
|
||||
}
|
||||
if (value === "release") {
|
||||
return t("release");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
|
||||
const { t } = useTranslation(["risk", "common"]);
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
@@ -76,25 +93,30 @@ export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
|
||||
}, [load]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">{t("lockLogsTitle")}</CardTitle>
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header">
|
||||
<CardTitle className="admin-list-title">{t("lockLogsTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid max-w-full gap-3 sm:grid-cols-[minmax(0,8rem)_minmax(0,10rem)_auto] sm:items-end">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="risk-log-number">{t("number4d")}</Label>
|
||||
<CardContent className="admin-list-content">
|
||||
<div className="admin-list-toolbar">
|
||||
<div className="admin-list-field">
|
||||
<Label htmlFor="risk-log-number" className="sm:w-20 sm:shrink-0">
|
||||
{t("number4d")}
|
||||
</Label>
|
||||
<Input
|
||||
id="risk-log-number"
|
||||
inputMode="numeric"
|
||||
maxLength={4}
|
||||
value={draftNumber}
|
||||
className="w-full sm:w-32"
|
||||
onChange={(e) => setDraftNumber(e.target.value.replace(/\D/g, "").slice(0, 4))}
|
||||
placeholder={t("optional")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="risk-log-action">{t("actionFilter")}</Label>
|
||||
<div className="admin-list-field">
|
||||
<Label htmlFor="risk-log-action" className="sm:w-20 sm:shrink-0">
|
||||
{t("actionFilter")}
|
||||
</Label>
|
||||
<Select
|
||||
modal={false}
|
||||
value={draftAction}
|
||||
@@ -102,8 +124,8 @@ export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
|
||||
if (v) setDraftAction(v);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="risk-log-action" size="sm" className="w-full">
|
||||
<SelectValue />
|
||||
<SelectTrigger id="risk-log-action" size="sm" className="w-full sm:w-40">
|
||||
<SelectValue>{riskActionLabel(draftAction, t)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ACTION_ALL}>{t("noLimit")}</SelectItem>
|
||||
@@ -112,7 +134,12 @@ export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex gap-2 sm:justify-end">
|
||||
<div className="admin-list-actions">
|
||||
<AdminTableExportButton
|
||||
tableId={`risk-lock-logs-table-${drawId}`}
|
||||
filename="风险占用流水"
|
||||
sheetName="风险占用流水"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
@@ -133,8 +160,8 @@ export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
|
||||
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<Table>
|
||||
<div className="admin-table-shell">
|
||||
<Table id={`risk-lock-logs-table-${drawId}`}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("time")}</TableHead>
|
||||
@@ -153,7 +180,9 @@ export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
|
||||
{row.created_at ? formatDt(row.created_at) : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">{row.normalized_number}</TableCell>
|
||||
<TableCell className="text-sm">{row.action_type}</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{riskActionLabel(row.action_type, t)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm tabular-nums">
|
||||
{formatAdminMinorUnits(row.amount)}
|
||||
</TableCell>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getAdminRiskPoolDetail } from "@/api/admin-risk";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
@@ -68,7 +69,7 @@ export function RiskPoolDetailConsole({
|
||||
<CardContent className="space-y-2">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
<Link
|
||||
href={`/admin/risk/draws/${drawId}/pools`}
|
||||
href={`/admin/draws/${drawId}/risk/pools`}
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
|
||||
>
|
||||
{t("backToList")}
|
||||
@@ -92,7 +93,7 @@ export function RiskPoolDetailConsole({
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Link
|
||||
href={`/admin/risk/draws/${drawId}/pools`}
|
||||
href={`/admin/draws/${drawId}/risk/pools`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}
|
||||
>
|
||||
← {t("backToAllPools")}
|
||||
@@ -142,7 +143,14 @@ export function RiskPoolDetailConsole({
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<Table>
|
||||
<div className="admin-table-toolbar">
|
||||
<AdminTableExportButton
|
||||
tableId={`risk-pool-detail-table-${drawId}-${number4d}`}
|
||||
filename={`风险池详情-${number4d}`}
|
||||
sheetName="风险池详情"
|
||||
/>
|
||||
</div>
|
||||
<Table id={`risk-pool-detail-table-${drawId}-${number4d}`}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("time")}</TableHead>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
postAdminRiskPoolRecover,
|
||||
} from "@/api/admin-risk";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -44,6 +45,14 @@ const SORT_OPTIONS: { value: "usage_desc" | "locked_desc" | "remaining_asc" | "n
|
||||
{ value: "number_asc", label: "sortNumberAsc" },
|
||||
];
|
||||
|
||||
function riskSortLabel(
|
||||
value: "usage_desc" | "locked_desc" | "remaining_asc" | "number_asc",
|
||||
t: (key: string) => string,
|
||||
): string {
|
||||
const option = SORT_OPTIONS.find((item) => item.value === value);
|
||||
return option ? t(option.label) : value;
|
||||
}
|
||||
|
||||
type RiskFilter = "all" | "sold_out" | "high_risk";
|
||||
|
||||
type RiskPoolsConsoleProps = {
|
||||
@@ -130,26 +139,28 @@ export function RiskPoolsConsole({
|
||||
);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="space-y-2">
|
||||
<CardTitle className="text-lg">{title}</CardTitle>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="risk-pool-number">{t("searchNumber")}</Label>
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header space-y-3">
|
||||
<CardTitle className="admin-list-title">{title}</CardTitle>
|
||||
<div className="admin-list-toolbar">
|
||||
<div className="admin-list-field">
|
||||
<Label htmlFor="risk-pool-number" className="sm:w-16 sm:shrink-0">
|
||||
{t("searchNumber")}
|
||||
</Label>
|
||||
<Input
|
||||
id="risk-pool-number"
|
||||
value={number}
|
||||
maxLength={4}
|
||||
placeholder={t("searchNumberPlaceholder")}
|
||||
className="h-9 w-32 font-mono"
|
||||
className="h-8 w-full font-mono sm:w-52"
|
||||
onChange={(event) => {
|
||||
setNumber(event.target.value.replace(/\D/g, "").slice(0, 4));
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("riskFilter")}</Label>
|
||||
<div className="admin-list-field">
|
||||
<Label className="sm:w-16 sm:shrink-0">{t("riskFilter")}</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
["all", t("filterAll")],
|
||||
@@ -172,8 +183,10 @@ export function RiskPoolsConsole({
|
||||
</div>
|
||||
</div>
|
||||
{allowSortChange ? (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="risk-pool-sort">{t("sort")}</Label>
|
||||
<div className="admin-list-field">
|
||||
<Label htmlFor="risk-pool-sort" className="sm:w-10 sm:shrink-0">
|
||||
{t("sort")}
|
||||
</Label>
|
||||
<Select
|
||||
modal={false}
|
||||
value={sort}
|
||||
@@ -184,7 +197,7 @@ export function RiskPoolsConsole({
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="risk-pool-sort" size="sm" className="w-full sm:w-52">
|
||||
<SelectValue />
|
||||
<SelectValue>{riskSortLabel(sort, t)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SORT_OPTIONS.map((o) => (
|
||||
@@ -196,16 +209,23 @@ export function RiskPoolsConsole({
|
||||
</Select>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="admin-list-actions">
|
||||
<AdminTableExportButton
|
||||
tableId={`risk-pools-table-${drawId}`}
|
||||
filename={title}
|
||||
sheetName="风险池"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<CardContent className="admin-list-content">
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
{loading && !data ? (
|
||||
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<Table>
|
||||
<div className="admin-table-shell">
|
||||
<Table id={`risk-pools-table-${drawId}`}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("searchNumber")}</TableHead>
|
||||
@@ -272,7 +292,7 @@ export function RiskPoolsConsole({
|
||||
{row.is_sold_out ? t("recover") : t("close")}
|
||||
</Button>
|
||||
<Link
|
||||
href={`/admin/risk/draws/${drawId}/pools/${row.normalized_number}`}
|
||||
href={`/admin/draws/${drawId}/risk/pools/${row.normalized_number}`}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "link", size: "sm" }),
|
||||
"h-8 px-0",
|
||||
|
||||
@@ -17,7 +17,7 @@ const segments = [
|
||||
export function RiskSubnav({ drawId }: { drawId: string }) {
|
||||
const { t } = useTranslation("risk");
|
||||
const pathname = usePathname();
|
||||
const base = `/admin/risk/draws/${drawId}`;
|
||||
const base = `/admin/draws/${drawId}/risk`;
|
||||
|
||||
return (
|
||||
<nav className="mb-6 flex flex-wrap gap-2 border-b border-border pb-3">
|
||||
@@ -38,7 +38,7 @@ export function RiskSubnav({ drawId }: { drawId: string }) {
|
||||
);
|
||||
})}
|
||||
<Link
|
||||
href="/admin/risk"
|
||||
href="/admin/draws"
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "ml-auto")}
|
||||
>
|
||||
{t("changeDraw")}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const settlementModuleMeta = {
|
||||
title: "Settlement",
|
||||
title: "结算",
|
||||
description: "",
|
||||
} as const;
|
||||
|
||||
@@ -17,6 +17,15 @@ import { AdminListPaginationFooter } from "@/components/admin/admin-list-paginat
|
||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -25,9 +34,13 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { formatAdminMinorUnits } from "@/lib/money";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PRD_PAYOUT_MANAGE, PRD_PAYOUT_REVIEW } from "@/modules/draws/draw-prd";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type {
|
||||
AdminSettlementBatchDetailsData,
|
||||
@@ -38,8 +51,13 @@ type Props = {
|
||||
batchId: number;
|
||||
};
|
||||
|
||||
type SettlementAction = "approve" | "reject" | "payout";
|
||||
|
||||
export function SettlementBatchDetailsConsole({ batchId }: Props) {
|
||||
const { t } = useTranslation(["settlement", "common"]);
|
||||
const profile = useAdminProfile();
|
||||
const canReviewSettlement = adminHasAnyPermission(profile?.permissions, [PRD_PAYOUT_REVIEW]);
|
||||
const canManagePayout = adminHasAnyPermission(profile?.permissions, [PRD_PAYOUT_MANAGE]);
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
const [summary, setSummary] = useState<AdminSettlementBatchShowData | null>(null);
|
||||
const [details, setDetails] = useState<AdminSettlementBatchDetailsData | null>(null);
|
||||
@@ -48,6 +66,8 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(25);
|
||||
const [acting, setActing] = useState<string | null>(null);
|
||||
const [pendingAction, setPendingAction] = useState<SettlementAction | null>(null);
|
||||
const [reviewRemark, setReviewRemark] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -73,6 +93,8 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
|
||||
try {
|
||||
await action();
|
||||
toast.success(t("actionSuccess", { name: label }));
|
||||
setPendingAction(null);
|
||||
setReviewRemark("");
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("actionFailed", { name: label }));
|
||||
@@ -81,6 +103,37 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
const actionLabel = (action: SettlementAction): string => {
|
||||
if (action === "approve") {
|
||||
return t("approve");
|
||||
}
|
||||
if (action === "reject") {
|
||||
return t("reject");
|
||||
}
|
||||
return t("runPayout");
|
||||
};
|
||||
|
||||
const confirmPendingAction = (): void => {
|
||||
if (!summary || pendingAction === null) {
|
||||
return;
|
||||
}
|
||||
const remark = reviewRemark.trim() || undefined;
|
||||
if (pendingAction === "approve") {
|
||||
void runAction(actionLabel(pendingAction), () => postAdminApproveSettlementBatch(batchId, remark));
|
||||
return;
|
||||
}
|
||||
if (pendingAction === "reject") {
|
||||
void runAction(actionLabel(pendingAction), () => postAdminRejectSettlementBatch(batchId, remark));
|
||||
return;
|
||||
}
|
||||
void runAction(actionLabel(pendingAction), () => postAdminPayoutSettlementBatch(batchId));
|
||||
};
|
||||
|
||||
const openActionDialog = (action: SettlementAction): void => {
|
||||
setReviewRemark("");
|
||||
setPendingAction(action);
|
||||
};
|
||||
|
||||
async function exportCsv(): Promise<void> {
|
||||
setActing(t("export"));
|
||||
try {
|
||||
@@ -156,6 +209,14 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
|
||||
<span className="text-muted-foreground">{t("winTotal")}</span>{" "}
|
||||
<span className="tabular-nums">{summary.total_win_count}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">{t("totalBet")}</span>{" "}
|
||||
<span className="font-mono tabular-nums">{formatAdminMinorUnits(summary.total_bet_amount)}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">{t("actualDeduct")}</span>{" "}
|
||||
<span className="font-mono tabular-nums">{formatAdminMinorUnits(summary.total_actual_deduct)}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">{t("payoutAmount")}</span>{" "}
|
||||
<span className="font-mono tabular-nums">{formatAdminMinorUnits(summary.total_payout_amount)}</span>
|
||||
@@ -166,6 +227,17 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
|
||||
{formatAdminMinorUnits(summary.total_jackpot_payout_amount)}
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">{t("platformProfit")}</span>{" "}
|
||||
<span
|
||||
className={cn(
|
||||
"font-mono tabular-nums",
|
||||
summary.platform_profit < 0 ? "text-destructive" : "text-emerald-700",
|
||||
)}
|
||||
>
|
||||
{formatAdminMinorUnits(summary.platform_profit)}
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">{t("startedAt")}</span> {formatDt(summary.started_at)}
|
||||
</p>
|
||||
@@ -177,8 +249,8 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={acting !== null || summary.status !== "pending_review"}
|
||||
onClick={() => void runAction(t("approve"), () => postAdminApproveSettlementBatch(batchId))}
|
||||
disabled={!canReviewSettlement || acting !== null || summary.status !== "pending_review"}
|
||||
onClick={() => openActionDialog("approve")}
|
||||
>
|
||||
{t("approve")}
|
||||
</Button>
|
||||
@@ -186,16 +258,16 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={acting !== null || summary.status !== "pending_review"}
|
||||
onClick={() => void runAction(t("reject"), () => postAdminRejectSettlementBatch(batchId))}
|
||||
disabled={!canReviewSettlement || acting !== null || summary.status !== "pending_review"}
|
||||
onClick={() => openActionDialog("reject")}
|
||||
>
|
||||
{t("reject")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={acting !== null || summary.status !== "approved"}
|
||||
onClick={() => void runAction(t("runPayout"), () => postAdminPayoutSettlementBatch(batchId))}
|
||||
disabled={!canManagePayout || acting !== null || summary.status !== "approved"}
|
||||
onClick={() => openActionDialog("payout")}
|
||||
>
|
||||
{t("runPayout")}
|
||||
</Button>
|
||||
@@ -216,7 +288,7 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
|
||||
<CardContent>
|
||||
{details ? (
|
||||
<>
|
||||
<Table>
|
||||
<Table id={`settlement-details-table-${batchId}`}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("ticketNo")}</TableHead>
|
||||
@@ -267,6 +339,71 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Dialog
|
||||
open={pendingAction !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && acting === null) {
|
||||
setPendingAction(null);
|
||||
setReviewRemark("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
{summary && pendingAction ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("confirmAction", { name: actionLabel(pendingAction) })}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{pendingAction === "payout"
|
||||
? t("confirmPayoutDesc")
|
||||
: t("confirmActionDesc", { drawNo: summary.draw_no ?? "—" })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<p className="rounded-md border bg-muted/30 p-3 text-sm">
|
||||
{t("confirmAmountLine", {
|
||||
actual: formatAdminMinorUnits(summary.total_actual_deduct),
|
||||
payout: formatAdminMinorUnits(summary.total_payout_amount),
|
||||
profit: formatAdminMinorUnits(summary.platform_profit),
|
||||
})}
|
||||
</p>
|
||||
{pendingAction !== "payout" ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="settlement-detail-review-remark">{t("reviewRemark")}</Label>
|
||||
<Textarea
|
||||
id="settlement-detail-review-remark"
|
||||
value={reviewRemark}
|
||||
placeholder={t("reviewRemarkPlaceholder")}
|
||||
onChange={(event) => setReviewRemark(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={acting !== null}
|
||||
onClick={() => {
|
||||
setPendingAction(null);
|
||||
setReviewRemark("");
|
||||
}}
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={pendingAction === "reject" ? "destructive" : "default"}
|
||||
disabled={acting !== null}
|
||||
onClick={confirmPendingAction}
|
||||
>
|
||||
{t("confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</ModuleScaffold>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,16 +6,24 @@ import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
downloadAdminSettlementBatchExport,
|
||||
getAdminSettlementBatches,
|
||||
postAdminApproveSettlementBatch,
|
||||
postAdminPayoutSettlementBatch,
|
||||
postAdminRejectSettlementBatch,
|
||||
} from "@/api/admin-settlement";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
import { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
@@ -33,25 +41,44 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { formatAdminMinorUnits } from "@/lib/money";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PRD_PAYOUT_MANAGE, PRD_PAYOUT_REVIEW } from "@/modules/draws/draw-prd";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { AdminSettlementBatchListData, AdminSettlementBatchRow } from "@/types/api/admin-settlement";
|
||||
|
||||
import { settlementModuleMeta } from "@/modules/settlement/meta";
|
||||
|
||||
const STATUS_ALL = "__all__";
|
||||
const STATUS_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: STATUS_ALL, label: "statusOptions.all" },
|
||||
{ value: "running", label: "statusOptions.running" },
|
||||
{ value: "pending_review", label: "statusOptions.pending_review" },
|
||||
{ value: "approved", label: "statusOptions.approved" },
|
||||
{ value: "rejected", label: "statusOptions.rejected" },
|
||||
{ value: "paid", label: "statusOptions.paid" },
|
||||
{ value: "completed", label: "statusOptions.completed" },
|
||||
{ value: "failed", label: "statusOptions.failed" },
|
||||
];
|
||||
|
||||
type SettlementAction = "approve" | "reject" | "payout";
|
||||
|
||||
type PendingAction = {
|
||||
row: AdminSettlementBatchRow;
|
||||
action: SettlementAction;
|
||||
};
|
||||
|
||||
function settlementStatusText(value: string, t: (key: string) => string): string {
|
||||
const option = STATUS_OPTIONS.find((item) => item.value === value);
|
||||
return option ? t(option.label) : value;
|
||||
}
|
||||
|
||||
export function SettlementBatchesConsole() {
|
||||
const { t } = useTranslation(["settlement", "common"]);
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
const profile = useAdminProfile();
|
||||
const canReviewSettlement = adminHasAnyPermission(profile?.permissions, [PRD_PAYOUT_REVIEW]);
|
||||
const canManagePayout = adminHasAnyPermission(profile?.permissions, [PRD_PAYOUT_MANAGE]);
|
||||
const [data, setData] = useState<AdminSettlementBatchListData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -62,6 +89,8 @@ export function SettlementBatchesConsole() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(20);
|
||||
const [actingId, setActingId] = useState<number | null>(null);
|
||||
const [pendingAction, setPendingAction] = useState<PendingAction | null>(null);
|
||||
const [reviewRemark, setReviewRemark] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -101,6 +130,8 @@ export function SettlementBatchesConsole() {
|
||||
try {
|
||||
await action();
|
||||
toast.success(t("actionSuccess", { name: label }));
|
||||
setPendingAction(null);
|
||||
setReviewRemark("");
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("actionFailed", { name: label }));
|
||||
@@ -109,172 +140,184 @@ export function SettlementBatchesConsole() {
|
||||
}
|
||||
}
|
||||
|
||||
async function exportBatch(batchId: number): Promise<void> {
|
||||
setActingId(batchId);
|
||||
try {
|
||||
const blob = await downloadAdminSettlementBatchExport(batchId);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `settlement-${batchId}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("exportFailed"));
|
||||
} finally {
|
||||
setActingId(null);
|
||||
const actionLabel = (action: SettlementAction): string => {
|
||||
if (action === "approve") {
|
||||
return t("approve");
|
||||
}
|
||||
}
|
||||
if (action === "reject") {
|
||||
return t("reject");
|
||||
}
|
||||
return t("runPayout");
|
||||
};
|
||||
|
||||
const confirmPendingAction = (): void => {
|
||||
if (!pendingAction) {
|
||||
return;
|
||||
}
|
||||
const { row, action } = pendingAction;
|
||||
const remark = reviewRemark.trim() || undefined;
|
||||
if (action === "approve") {
|
||||
void runBatchAction(row.id, actionLabel(action), () => postAdminApproveSettlementBatch(row.id, remark));
|
||||
return;
|
||||
}
|
||||
if (action === "reject") {
|
||||
void runBatchAction(row.id, actionLabel(action), () => postAdminRejectSettlementBatch(row.id, remark));
|
||||
return;
|
||||
}
|
||||
void runBatchAction(row.id, actionLabel(action), () => postAdminPayoutSettlementBatch(row.id));
|
||||
};
|
||||
|
||||
const openActionDialog = (row: AdminSettlementBatchRow, action: SettlementAction): void => {
|
||||
setReviewRemark("");
|
||||
setPendingAction({ row, action });
|
||||
};
|
||||
|
||||
return (
|
||||
<ModuleScaffold>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-lg font-semibold tracking-tight">{settlementModuleMeta.title}</h1>
|
||||
</div>
|
||||
<Card className="mb-6">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">{t("filter")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:items-end">
|
||||
<div className="flex min-w-[12rem] flex-1 flex-col gap-1.5">
|
||||
<Label htmlFor="sb-draw-no">{t("drawNo")}</Label>
|
||||
<Input
|
||||
id="sb-draw-no"
|
||||
value={draftDrawNo}
|
||||
onChange={(e) => setDraftDrawNo(e.target.value)}
|
||||
placeholder={t("placeholderDrawNo")}
|
||||
className="font-mono"
|
||||
/>
|
||||
<Card className="admin-list-card">
|
||||
<CardHeader className="admin-list-header space-y-6 pb-6">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<CardTitle className="admin-list-title">{t("batchList")}</CardTitle>
|
||||
</div>
|
||||
<div className="flex min-w-[10rem] flex-col gap-1.5">
|
||||
<Label>{t("status")}</Label>
|
||||
<Select value={draftStatus} onValueChange={(v) => setDraftStatus(v ?? STATUS_ALL)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{t(o.label)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="admin-list-toolbar">
|
||||
<div className="admin-list-field">
|
||||
<Label htmlFor="sb-draw-no" className="sm:w-10 sm:shrink-0">
|
||||
{t("drawNo")}
|
||||
</Label>
|
||||
<Input
|
||||
id="sb-draw-no"
|
||||
value={draftDrawNo}
|
||||
onChange={(e) => setDraftDrawNo(e.target.value)}
|
||||
placeholder={t("placeholderDrawNo")}
|
||||
className="w-full font-mono sm:w-[18rem] xl:w-[20rem]"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-list-field">
|
||||
<Label className="sm:w-10 sm:shrink-0">{t("status")}</Label>
|
||||
<Select value={draftStatus} onValueChange={(v) => setDraftStatus(v ?? STATUS_ALL)}>
|
||||
<SelectTrigger className="w-full sm:w-40">
|
||||
<SelectValue>{settlementStatusText(draftStatus, t)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{t(o.label)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="admin-list-actions">
|
||||
<AdminTableExportButton
|
||||
tableId="settlement-batches-table"
|
||||
filename="结算批次"
|
||||
sheetName="结算批次"
|
||||
/>
|
||||
<Button type="button" className="xl:shrink-0" onClick={applyFilters}>
|
||||
{t("apply")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="button" onClick={applyFilters}>
|
||||
{t("apply")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("batchList")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="admin-list-content pt-0">
|
||||
{error ? <p className="text-destructive text-sm">{error}</p> : null}
|
||||
{loading && !data ? (
|
||||
<p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>{t("drawNo")}</TableHead>
|
||||
<TableHead>{t("version", { ns: "draws", version: "" }).replace(" v", "").trim()}</TableHead>
|
||||
<TableHead>{t("reviewStatus")}</TableHead>
|
||||
<TableHead>{t("status")}</TableHead>
|
||||
<TableHead className="text-right">{t("ticketCount")}</TableHead>
|
||||
<TableHead className="text-right">{t("winCount")}</TableHead>
|
||||
<TableHead className="text-right">{t("payoutTotal")}</TableHead>
|
||||
<TableHead className="text-right">{t("jackpot")}</TableHead>
|
||||
<TableHead>{t("finishedAt")}</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(data?.items ?? []).map((row: AdminSettlementBatchRow) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="font-mono text-xs">{row.id}</TableCell>
|
||||
<TableCell className="font-mono text-sm">{row.draw_no ?? "—"}</TableCell>
|
||||
<TableCell className="font-mono text-xs">v{row.settle_version}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{row.review_status ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
<div className="admin-table-shell">
|
||||
<Table id="settlement-batches-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>{t("drawNo")}</TableHead>
|
||||
<TableHead className="text-right">{t("totalBet")}</TableHead>
|
||||
<TableHead className="text-right">{t("actualDeduct")}</TableHead>
|
||||
<TableHead className="text-right">{t("payoutTotal")}</TableHead>
|
||||
<TableHead className="text-right">{t("platformProfit")}</TableHead>
|
||||
<TableHead>{t("reviewStatus")}</TableHead>
|
||||
<TableHead>{t("status")}</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(data?.items ?? []).map((row: AdminSettlementBatchRow) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="font-mono text-xs">{row.id}</TableCell>
|
||||
<TableCell className="font-mono text-sm">{row.draw_no ?? "—"}</TableCell>
|
||||
<TableCell className="text-right font-mono text-xs tabular-nums">
|
||||
{formatAdminMinorUnits(row.total_bet_amount)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-xs tabular-nums">
|
||||
{formatAdminMinorUnits(row.total_actual_deduct)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-xs tabular-nums">
|
||||
{formatAdminMinorUnits(row.total_payout_amount)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={cn(
|
||||
"rounded px-1.5 py-0.5 text-xs font-medium",
|
||||
row.status === "completed" && "bg-emerald-500/15 text-emerald-800",
|
||||
row.status === "running" && "bg-amber-500/15 text-amber-900",
|
||||
row.status === "failed" && "bg-destructive/15 text-destructive",
|
||||
"text-right font-mono text-xs tabular-nums",
|
||||
row.platform_profit < 0 ? "text-destructive" : "text-emerald-700",
|
||||
)}
|
||||
>
|
||||
{row.status}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{row.total_ticket_count}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{row.total_win_count}</TableCell>
|
||||
<TableCell className="text-right font-mono text-xs tabular-nums">
|
||||
{formatAdminMinorUnits(row.total_payout_amount)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-xs tabular-nums">
|
||||
{formatAdminMinorUnits(row.total_jackpot_payout_amount)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{formatDt(row.finished_at ?? row.started_at)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap justify-end gap-1.5">
|
||||
<Link
|
||||
href={`/admin/settlement-batches/${row.id}/details`}
|
||||
className={cn(buttonVariants({ variant: "link", size: "sm" }), "px-0")}
|
||||
{formatAdminMinorUnits(row.platform_profit)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{row.review_status ?? "—"}</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded px-1.5 py-0.5 text-xs font-medium",
|
||||
["completed", "paid"].includes(row.status) && "bg-emerald-500/15 text-emerald-800",
|
||||
row.status === "running" && "bg-amber-500/15 text-amber-900",
|
||||
row.status === "pending_review" && "bg-blue-500/15 text-blue-800",
|
||||
row.status === "approved" && "bg-indigo-500/15 text-indigo-800",
|
||||
row.status === "rejected" && "bg-muted text-muted-foreground",
|
||||
row.status === "failed" && "bg-destructive/15 text-destructive",
|
||||
)}
|
||||
>
|
||||
{t("details")}
|
||||
</Link>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={actingId !== null || row.status !== "pending_review"}
|
||||
onClick={() => void runBatchAction(row.id, t("approve"), () => postAdminApproveSettlementBatch(row.id))}
|
||||
>
|
||||
{t("pass")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={actingId !== null || row.status !== "pending_review"}
|
||||
onClick={() => void runBatchAction(row.id, t("reject"), () => postAdminRejectSettlementBatch(row.id))}
|
||||
>
|
||||
{t("reject")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={actingId !== null || row.status !== "approved"}
|
||||
onClick={() => void runBatchAction(row.id, t("runPayout"), () => postAdminPayoutSettlementBatch(row.id))}
|
||||
>
|
||||
{t("payout")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={actingId !== null}
|
||||
onClick={() => void exportBatch(row.id)}
|
||||
>
|
||||
{t("export")}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{settlementStatusText(row.status, t)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap justify-end gap-1.5">
|
||||
<Link
|
||||
href={`/admin/settlement-batches/${row.id}/details`}
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "!border-border")}
|
||||
>
|
||||
{t("details")}
|
||||
</Link>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!canReviewSettlement || actingId !== null || row.status !== "pending_review"}
|
||||
onClick={() => openActionDialog(row, "approve")}
|
||||
>
|
||||
{t("pass")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!canReviewSettlement || actingId !== null || row.status !== "pending_review"}
|
||||
onClick={() => openActionDialog(row, "reject")}
|
||||
>
|
||||
{t("reject")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={!canManagePayout || actingId !== null || row.status !== "approved"}
|
||||
onClick={() => openActionDialog(row, "payout")}
|
||||
>
|
||||
{t("payout")}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
{data ? (
|
||||
<AdminListPaginationFooter
|
||||
@@ -293,6 +336,71 @@ export function SettlementBatchesConsole() {
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Dialog
|
||||
open={pendingAction !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && actingId === null) {
|
||||
setPendingAction(null);
|
||||
setReviewRemark("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
{pendingAction ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("confirmAction", { name: actionLabel(pendingAction.action) })}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{pendingAction.action === "payout"
|
||||
? t("confirmPayoutDesc")
|
||||
: t("confirmActionDesc", { drawNo: pendingAction.row.draw_no ?? "—" })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<p className="rounded-md border bg-muted/30 p-3 text-sm">
|
||||
{t("confirmAmountLine", {
|
||||
actual: formatAdminMinorUnits(pendingAction.row.total_actual_deduct),
|
||||
payout: formatAdminMinorUnits(pendingAction.row.total_payout_amount),
|
||||
profit: formatAdminMinorUnits(pendingAction.row.platform_profit),
|
||||
})}
|
||||
</p>
|
||||
{pendingAction.action !== "payout" ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="settlement-review-remark">{t("reviewRemark")}</Label>
|
||||
<Textarea
|
||||
id="settlement-review-remark"
|
||||
value={reviewRemark}
|
||||
placeholder={t("reviewRemarkPlaceholder")}
|
||||
onChange={(event) => setReviewRemark(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={actingId !== null}
|
||||
onClick={() => {
|
||||
setPendingAction(null);
|
||||
setReviewRemark("");
|
||||
}}
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={pendingAction.action === "reject" ? "destructive" : "default"}
|
||||
disabled={actingId !== null}
|
||||
onClick={confirmPendingAction}
|
||||
>
|
||||
{t("confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</ModuleScaffold>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getAdminPlayerTicketItems } from "@/api/admin-player-tickets";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -75,36 +76,43 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-none">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("playerTicketQuery")}</CardTitle>
|
||||
<Card className="admin-list-card w-full max-w-none">
|
||||
<CardHeader className="admin-list-header">
|
||||
<CardTitle className="admin-list-title">{t("playerTicketQuery")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="pt-player">{t("playerId")}</Label>
|
||||
<CardContent className="admin-list-content">
|
||||
<div className="admin-list-toolbar">
|
||||
<div className="admin-list-field">
|
||||
<Label htmlFor="pt-player" className="sm:w-20 sm:shrink-0">{t("playerId")}</Label>
|
||||
<Input
|
||||
id="pt-player"
|
||||
inputMode="numeric"
|
||||
className="w-40 font-mono"
|
||||
className="w-full font-mono sm:w-36"
|
||||
placeholder="players.id"
|
||||
value={playerIdDraft}
|
||||
onChange={(e) => setPlayerIdDraft(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid min-w-[10rem] flex-1 gap-1.5">
|
||||
<Label htmlFor="pt-draw">{t("drawNoOptional")}</Label>
|
||||
<div className="admin-list-field xl:min-w-0">
|
||||
<Label htmlFor="pt-draw" className="sm:w-20 sm:shrink-0">{t("drawNoOptional")}</Label>
|
||||
<Input
|
||||
id="pt-draw"
|
||||
className="font-mono text-sm"
|
||||
className="w-full font-mono text-sm sm:w-[16rem] xl:w-[20rem]"
|
||||
placeholder={t("drawNoPlaceholder")}
|
||||
value={drawNoDraft}
|
||||
onChange={(e) => setDrawNoDraft(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="button" onClick={() => runSearch()}>
|
||||
{t("query")}
|
||||
</Button>
|
||||
<div className="admin-list-actions">
|
||||
<AdminTableExportButton
|
||||
tableId="player-tickets-table"
|
||||
filename="玩家注单"
|
||||
sheetName="玩家注单"
|
||||
/>
|
||||
<Button type="button" onClick={() => runSearch()}>
|
||||
{t("query")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{err ? <p className="text-sm text-destructive">{err}</p> : null}
|
||||
@@ -114,8 +122,8 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
|
||||
{data ? (
|
||||
<>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<div className="admin-table-shell">
|
||||
<Table id="player-tickets-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("ticketNo")}</TableHead>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "@/api/admin-wallet";
|
||||
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -410,6 +411,11 @@ export function TransferOrdersPanel(): React.ReactElement {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<AdminTableExportButton
|
||||
tableId="wallet-transfer-orders-table"
|
||||
filename="钱包转账订单"
|
||||
sheetName="转账订单"
|
||||
/>
|
||||
<Button type="button" size="sm" onClick={() => runSearch()}>
|
||||
{t("search")}
|
||||
</Button>
|
||||
@@ -429,7 +435,7 @@ export function TransferOrdersPanel(): React.ReactElement {
|
||||
{data ? (
|
||||
<>
|
||||
<div className="rounded-md border">
|
||||
<Table className="table-fixed">
|
||||
<Table id="wallet-transfer-orders-table" className="table-fixed">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-0 max-w-[14rem]">{t("localTransferNo")}</TableHead>
|
||||
@@ -729,6 +735,11 @@ export function WalletTxnsPanel(): React.ReactElement {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<AdminTableExportButton
|
||||
tableId="wallet-transactions-table"
|
||||
filename="钱包流水"
|
||||
sheetName="钱包流水"
|
||||
/>
|
||||
<Button type="button" size="sm" onClick={() => runSearch()}>
|
||||
{t("search")}
|
||||
</Button>
|
||||
@@ -748,7 +759,7 @@ export function WalletTxnsPanel(): React.ReactElement {
|
||||
{data ? (
|
||||
<>
|
||||
<div className="rounded-md border">
|
||||
<Table className="table-fixed">
|
||||
<Table id="wallet-transactions-table" className="table-fixed">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-0 max-w-[14rem]">{t("txnNo")}</TableHead>
|
||||
@@ -868,6 +879,11 @@ export function PlayerWalletPanel(): React.ReactElement {
|
||||
className="w-40"
|
||||
/>
|
||||
</div>
|
||||
<AdminTableExportButton
|
||||
tableId="player-wallet-table"
|
||||
filename="玩家钱包"
|
||||
sheetName="玩家钱包"
|
||||
/>
|
||||
<Button type="button" onClick={() => void query()} disabled={loading}>
|
||||
{loading ? t("querying") : t("query")}
|
||||
</Button>
|
||||
@@ -879,7 +895,7 @@ export function PlayerWalletPanel(): React.ReactElement {
|
||||
<span className="text-muted-foreground">{t("sitePlayer")}</span>{" "}
|
||||
{result.player.site_code}:{result.player.site_player_id}
|
||||
</p>
|
||||
<Table>
|
||||
<Table id="player-wallet-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("walletType")}</TableHead>
|
||||
|
||||
Reference in New Issue
Block a user