feat: 统一管理端多语言、配置与票据/结算页面重构
This commit is contained in:
@@ -38,6 +38,16 @@ import { cn } from "@/lib/utils";
|
||||
import type { AdminPermissionCatalogData, AdminRoleRow } from "@/types/api/index";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
|
||||
function permissionGroupLabel(key: string, fallback: string, t: (key: string) => string): string {
|
||||
const translated = t(`permissionGroups.${key}`);
|
||||
return translated === `permissionGroups.${key}` ? fallback : translated;
|
||||
}
|
||||
|
||||
function permissionLabel(slug: string, fallback: string, t: (key: string) => string): string {
|
||||
const translated = t(`permissionNames.${slug}`);
|
||||
return translated === `permissionNames.${slug}` ? fallback : translated;
|
||||
}
|
||||
|
||||
export function AdminRolesConsole(): React.ReactElement {
|
||||
const { t } = useTranslation(["adminUsers", "common"]);
|
||||
const [catalog, setCatalog] = useState<AdminPermissionCatalogData | null>(null);
|
||||
@@ -289,7 +299,7 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
<Table id="admin-roles-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-16">ID</TableHead>
|
||||
<TableHead className="w-16">{t("table.id", { ns: "common" })}</TableHead>
|
||||
<TableHead>{t("roleTable.name")}</TableHead>
|
||||
<TableHead>{t("roleTable.slug")}</TableHead>
|
||||
<TableHead>{t("roleTable.type")}</TableHead>
|
||||
@@ -376,7 +386,7 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
<DialogHeader className="shrink-0 space-y-1 border-b bg-background px-5 py-4 pr-12">
|
||||
<DialogTitle className="text-base">{t("rolePermissionDialog.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{selectedRole ? `${selectedRole.name} · ${selectedRole.slug}` : null}
|
||||
{selectedRole ? selectedRole.name : null}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain bg-muted/25 px-5 py-4">
|
||||
@@ -401,7 +411,9 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
isOpen && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 text-base font-semibold leading-none">{group.label}</span>
|
||||
<span className="min-w-0 flex-1 text-base font-semibold leading-none">
|
||||
{permissionGroupLabel(group.key, group.label, t)}
|
||||
</span>
|
||||
<span className="shrink-0 rounded-full bg-muted px-2.5 py-1 tabular-nums text-xs font-medium text-muted-foreground">
|
||||
{selectedCount}/{group.permissions.length}
|
||||
</span>
|
||||
@@ -424,7 +436,7 @@ export function AdminRolesConsole(): React.ReactElement {
|
||||
}
|
||||
/>
|
||||
<span className="min-w-0 whitespace-normal break-words font-medium leading-6 text-foreground">
|
||||
{permission.name}
|
||||
{permissionLabel(permission.slug, permission.name, t)}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const adminRolesModuleMeta = {
|
||||
segment: "admin_roles",
|
||||
title: "Roles",
|
||||
title: "角色管理",
|
||||
description: "",
|
||||
} as const;
|
||||
|
||||
@@ -78,6 +78,10 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
() => items.find((u) => u.id === selectedId) ?? null,
|
||||
[items, selectedId],
|
||||
);
|
||||
const roleNameBySlug = useMemo(
|
||||
() => new Map((catalog?.roles ?? []).map((role) => [role.slug, role.name])),
|
||||
[catalog],
|
||||
);
|
||||
|
||||
const selectClassName = cn(
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base outline-none transition-colors",
|
||||
@@ -355,7 +359,7 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
<Table id="admin-users-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-16">ID</TableHead>
|
||||
<TableHead className="w-16">{t("table.id", { ns: "common" })}</TableHead>
|
||||
<TableHead>{t("table.account")}</TableHead>
|
||||
<TableHead>{t("table.nickname")}</TableHead>
|
||||
<TableHead className="w-20 whitespace-nowrap">{t("table.status")}</TableHead>
|
||||
@@ -403,7 +407,7 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
) : (
|
||||
row.roles.map((slug) => (
|
||||
<Badge key={slug} variant="secondary">
|
||||
{slug}
|
||||
{roleNameBySlug.get(slug) ?? slug}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
@@ -497,7 +501,7 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
/>
|
||||
<span className="space-y-0.5">
|
||||
<span className="block leading-none font-medium">{role.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{role.slug}</span>
|
||||
<span className="text-xs text-muted-foreground">{role.description ?? ""}</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
@@ -605,7 +609,7 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
/>
|
||||
<span className="space-y-0.5">
|
||||
<span className="block leading-none font-medium">{role.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{role.slug}</span>
|
||||
<span className="text-xs text-muted-foreground">{role.description ?? ""}</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const adminUsersModuleMeta = {
|
||||
segment: "admin_users",
|
||||
title: "Admins",
|
||||
title: "管理员列表",
|
||||
description: "",
|
||||
} as const;
|
||||
|
||||
@@ -66,66 +66,82 @@ export function AuditLogsConsole(): React.ReactElement {
|
||||
const meta = data?.meta;
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-none">
|
||||
<CardHeader className="flex flex-col gap-4">
|
||||
<div>
|
||||
<CardTitle>{t("title")}</CardTitle>
|
||||
</div>
|
||||
<div className="admin-list-toolbar">
|
||||
<div className="admin-list-field">
|
||||
<Label htmlFor="aud-mod" className="sm:w-20 sm:shrink-0">{t("moduleCode")}</Label>
|
||||
<Card className="admin-list-card w-full max-w-none">
|
||||
<CardHeader className="admin-list-header flex flex-col gap-5">
|
||||
<CardTitle className="admin-list-title">{t("title")}</CardTitle>
|
||||
<div className="grid gap-3 lg:grid-cols-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Label htmlFor="aud-mod" className="shrink-0 whitespace-nowrap">
|
||||
{t("moduleCode")}
|
||||
</Label>
|
||||
<Input
|
||||
id="aud-mod"
|
||||
value={moduleCode}
|
||||
onChange={(e) => setModuleCode(e.target.value)}
|
||||
placeholder={t("exactMatch")}
|
||||
className="w-full sm:w-40"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-list-field">
|
||||
<Label htmlFor="aud-act" className="sm:w-20 sm:shrink-0">{t("actionCode")}</Label>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Label htmlFor="aud-act" className="shrink-0 whitespace-nowrap">
|
||||
{t("actionCode")}
|
||||
</Label>
|
||||
<Input
|
||||
id="aud-act"
|
||||
value={actionCode}
|
||||
onChange={(e) => setActionCode(e.target.value)}
|
||||
placeholder={t("exactMatch")}
|
||||
className="w-full sm:w-40"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-list-field">
|
||||
<Label htmlFor="aud-op" className="sm:w-20 sm:shrink-0">{t("operatorType")}</Label>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Label htmlFor="aud-op" className="shrink-0 whitespace-nowrap">
|
||||
{t("operatorType")}
|
||||
</Label>
|
||||
<Input
|
||||
id="aud-op"
|
||||
value={operatorType}
|
||||
onChange={(e) => setOperatorType(e.target.value)}
|
||||
placeholder={t("operatorTypePlaceholder")}
|
||||
className="w-full sm:w-40"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-list-actions">
|
||||
<AdminTableExportButton
|
||||
tableId="audit-logs-table"
|
||||
filename="审计日志"
|
||||
sheetName="审计日志"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAppliedModule(moduleCode);
|
||||
setAppliedAction(actionCode);
|
||||
setAppliedOpType(operatorType);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
{t("actions.search", { ns: "common" })}
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" size="sm" onClick={() => void load()}>
|
||||
{t("actions.refresh", { ns: "common" })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<AdminTableExportButton
|
||||
tableId="audit-logs-table"
|
||||
filename="审计日志"
|
||||
sheetName="审计日志"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAppliedModule(moduleCode);
|
||||
setAppliedAction(actionCode);
|
||||
setAppliedOpType(operatorType);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
{t("actions.search", { ns: "common" })}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setModuleCode("");
|
||||
setActionCode("");
|
||||
setOperatorType("");
|
||||
setAppliedModule("");
|
||||
setAppliedAction("");
|
||||
setAppliedOpType("");
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
{t("actions.reset", { ns: "common", defaultValue: "重置" })}
|
||||
</Button>
|
||||
</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 && !data ? (
|
||||
<p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p>
|
||||
@@ -137,7 +153,7 @@ export function AuditLogsConsole(): React.ReactElement {
|
||||
<Table id="audit-logs-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-20">ID</TableHead>
|
||||
<TableHead className="w-20">{t("table.id", { ns: "common" })}</TableHead>
|
||||
<TableHead>{t("operator")}</TableHead>
|
||||
<TableHead>{t("module")}</TableHead>
|
||||
<TableHead>{t("action")}</TableHead>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const auditLogsModuleMeta = {
|
||||
segment: "audit-logs",
|
||||
title: "Audit Logs",
|
||||
title: "审计日志",
|
||||
description: "",
|
||||
} as const;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const authModuleMeta = {
|
||||
segment: "login",
|
||||
title: "Login",
|
||||
title: "登录",
|
||||
description: "",
|
||||
} as const;
|
||||
|
||||
@@ -34,16 +34,12 @@ export const CONFIG_NAV_GROUPS: readonly ConfigNavGroup[] = [
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "risk_wallet",
|
||||
id: "risk",
|
||||
items: [
|
||||
{
|
||||
href: "/admin/config/risk-cap",
|
||||
key: "risk-cap",
|
||||
},
|
||||
{
|
||||
href: "/admin/config/wallet",
|
||||
key: "wallet",
|
||||
},
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -11,7 +11,6 @@ const LINKS: { href: string; key: string; match?: "exact" | "prefix" }[] = [
|
||||
{ href: "/admin/config/odds", key: "odds" },
|
||||
{ href: "/admin/config/rebate", key: "rebate" },
|
||||
{ href: "/admin/config/risk-cap", key: "risk-cap" },
|
||||
{ href: "/admin/config/wallet", key: "wallet" },
|
||||
];
|
||||
|
||||
function linkActive(pathname: string, href: string, match: "exact" | "prefix"): boolean {
|
||||
|
||||
@@ -276,7 +276,7 @@ export function OddsConfigDocScreen() {
|
||||
void refreshList();
|
||||
setSelectedId(String(d.id));
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : "Publish failed");
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("odds.publishFailed", { ns: "config" }));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -308,13 +308,13 @@ export function OddsConfigDocScreen() {
|
||||
reason: `draft ${new Date().toISOString()}`,
|
||||
clone_from_version_id: active?.id ?? null,
|
||||
});
|
||||
toast.success(`Created draft v${d.version_no}`);
|
||||
toast.success(t("odds.createDraftSuccess", { ns: "config", version: d.version_no }));
|
||||
await refreshList();
|
||||
setSelectedId(String(d.id));
|
||||
setDetail(d);
|
||||
setDraftRows(d.items.map((it) => ({ ...it })));
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : "Create draft failed");
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("odds.createDraftFailed", { ns: "config" }));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -330,7 +330,13 @@ export function OddsConfigDocScreen() {
|
||||
reason: `rollback from v${rollbackTarget.version_no}`,
|
||||
clone_from_version_id: rollbackTarget.id,
|
||||
});
|
||||
toast.success(`Cloned v${rollbackTarget.version_no} into new draft v${d.version_no}`);
|
||||
toast.success(
|
||||
t("odds.rollbackSuccess", {
|
||||
ns: "config",
|
||||
fromVersion: rollbackTarget.version_no,
|
||||
version: d.version_no,
|
||||
}),
|
||||
);
|
||||
await refreshList();
|
||||
setSelectedId(String(d.id));
|
||||
setDetail(d);
|
||||
@@ -338,7 +344,7 @@ export function OddsConfigDocScreen() {
|
||||
setRollbackOpen(false);
|
||||
setRollbackTarget(null);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : "Rollback failed");
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("odds.rollbackFailed", { ns: "config" }));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -352,7 +358,7 @@ export function OddsConfigDocScreen() {
|
||||
toast.success(t("versionSwitcher.delete", { ns: "config" }));
|
||||
await refreshList();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : "Delete failed");
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("odds.deleteFailed", { ns: "config" }));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -382,7 +388,7 @@ export function OddsConfigDocScreen() {
|
||||
}, [activeCompareRows, detail, draftRows, resolvedPlayCode]);
|
||||
|
||||
const catTabs: { id: CatTab; label: string }[] = [
|
||||
{ id: "all", label: "All" },
|
||||
{ id: "all", label: t("odds.tabs.all", { ns: "config" }) },
|
||||
{ id: "d4", label: "4D" },
|
||||
{ id: "d3", label: "3D" },
|
||||
{ id: "d2", label: "2D" },
|
||||
@@ -395,7 +401,7 @@ export function OddsConfigDocScreen() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="text-base text-muted-foreground self-center mr-2">Category</span>
|
||||
<span className="text-base text-muted-foreground self-center mr-2">{t("odds.category", { ns: "config" })}</span>
|
||||
{catTabs.map((t) => (
|
||||
<Button
|
||||
key={t.id}
|
||||
@@ -410,10 +416,10 @@ export function OddsConfigDocScreen() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 min-h-[96px]">
|
||||
<p className="text-base text-muted-foreground">Play Type</p>
|
||||
<p className="text-base text-muted-foreground">{t("odds.playType", { ns: "config" })}</p>
|
||||
<div className="flex flex-wrap gap-2 min-h-[44px]">
|
||||
{filteredTypes.length === 0 ? (
|
||||
<span className="text-base text-muted-foreground">No play types in this category.</span>
|
||||
<span className="text-base text-muted-foreground">{t("odds.noPlayTypes", { ns: "config" })}</span>
|
||||
) : (
|
||||
filteredTypes.map((t) => (
|
||||
<Button
|
||||
@@ -443,7 +449,7 @@ export function OddsConfigDocScreen() {
|
||||
onSelectedIdChange={setSelectedId}
|
||||
loading={loadingList}
|
||||
sheetTitle={`${t("nav.items.odds", { ns: "config" })} ${t("versionSwitcher.sheetTitle", { ns: "config" })}`}
|
||||
sheetDescription="Choose a version to view here. Non-draft versions can be rolled back into a new draft."
|
||||
sheetDescription={t("odds.sheetDescription", { ns: "config" })}
|
||||
onDeleteVersion={handleDeleteVersion}
|
||||
onRollbackVersion={requestRollback}
|
||||
rollbackBusy={saving}
|
||||
@@ -465,7 +471,7 @@ export function OddsConfigDocScreen() {
|
||||
|
||||
{detail ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Active version:
|
||||
{t("odds.activeVersionPrefix", { ns: "config" })}
|
||||
{activeHead ? (
|
||||
<>
|
||||
v{activeHead.version_no}
|
||||
@@ -475,7 +481,7 @@ export function OddsConfigDocScreen() {
|
||||
"—"
|
||||
)}
|
||||
{!isDraft ? (
|
||||
<span className="text-amber-600 dark:text-amber-400"> - This version is read-only. Create a draft before editing odds.</span>
|
||||
<span className="text-amber-600 dark:text-amber-400"> - {t("odds.readOnlyHint", { ns: "config" })}</span>
|
||||
) : null}
|
||||
</p>
|
||||
) : null}
|
||||
@@ -484,7 +490,7 @@ export function OddsConfigDocScreen() {
|
||||
|
||||
{loadingDetail || loadingTypes ? (
|
||||
<div className="flex min-h-[420px] items-center">
|
||||
<p className="text-base text-muted-foreground">Loading details…</p>
|
||||
<p className="text-base text-muted-foreground">{t("odds.loadingDetails", { ns: "config" })}</p>
|
||||
</div>
|
||||
) : resolvedPlayCode ? (
|
||||
<div className="grid min-h-[420px] gap-4 max-w-md">
|
||||
@@ -519,17 +525,21 @@ export function OddsConfigDocScreen() {
|
||||
</ConfigReadonlyValue>
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground tabular-nums">
|
||||
Multiplier x{oddsMultiplierLabel(row.odds_value)} · {row.currency_code}
|
||||
{t("odds.multiplier", {
|
||||
ns: "config",
|
||||
value: oddsMultiplierLabel(row.odds_value),
|
||||
currency: row.currency_code,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-destructive">Missing {scope} row. Check seed or version data.</p>
|
||||
<p className="text-sm text-destructive">{t("odds.missingScopeRow", { ns: "config", scope })}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="grid gap-1 pt-2 border-t">
|
||||
<Label>Rebate Rate (%)</Label>
|
||||
<Label>{t("odds.rebateRate", { ns: "config" })}</Label>
|
||||
{isDraft ? (
|
||||
<Input
|
||||
type="text"
|
||||
@@ -544,7 +554,7 @@ export function OddsConfigDocScreen() {
|
||||
{rebatePercentUi}
|
||||
</ConfigReadonlyValue>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground">Writes rebate_rate to all prize scopes under this play type.</p>
|
||||
<p className="text-sm text-muted-foreground">{t("odds.rebateRateHint", { ns: "config" })}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -554,9 +564,9 @@ export function OddsConfigDocScreen() {
|
||||
<Dialog open={rollbackOpen} onOpenChange={setRollbackOpen}>
|
||||
<DialogContent showCloseButton className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirm rollback</DialogTitle>
|
||||
<DialogTitle>{t("odds.rollbackDialog.title", { ns: "config" })}</DialogTitle>
|
||||
<DialogDescription>
|
||||
A new draft will be cloned from version v{rollbackTarget?.version_no}. The active version will not be overwritten directly.
|
||||
{t("odds.rollbackDialog.description", { ns: "config", version: rollbackTarget?.version_no ?? "—" })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
@@ -564,7 +574,7 @@ export function OddsConfigDocScreen() {
|
||||
{t("actions.cancel", { ns: "adminUsers" })}
|
||||
</Button>
|
||||
<Button type="button" onClick={() => void handleRollback()} disabled={!rollbackTarget || saving}>
|
||||
Confirm rollback
|
||||
{t("odds.rollbackDialog.confirm", { ns: "config" })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -573,16 +583,16 @@ export function OddsConfigDocScreen() {
|
||||
<Dialog open={publishConfirmOpen} onOpenChange={setPublishConfirmOpen}>
|
||||
<DialogContent showCloseButton className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Publish odds version?</DialogTitle>
|
||||
<DialogTitle>{t("odds.publishDialog.title", { ns: "config" })}</DialogTitle>
|
||||
<DialogDescription>
|
||||
New odds affect new tickets immediately. Existing successful tickets still settle by their saved odds snapshot.
|
||||
{t("odds.publishDialog.description", { ns: "config" })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="rounded-lg border">
|
||||
<div className="grid grid-cols-3 border-b bg-muted/40 px-3 py-2 text-sm font-medium">
|
||||
<span>Prize Scope</span>
|
||||
<span className="text-right">Current Active</span>
|
||||
<span className="text-right">After Publish</span>
|
||||
<span>{t("odds.publishDialog.columns.prizeScope", { ns: "config" })}</span>
|
||||
<span className="text-right">{t("odds.publishDialog.columns.currentActive", { ns: "config" })}</span>
|
||||
<span className="text-right">{t("odds.publishDialog.columns.afterPublish", { ns: "config" })}</span>
|
||||
</div>
|
||||
{publishDiffRows.map((row) => (
|
||||
<div key={row.scope} className="grid grid-cols-3 px-3 py-2 text-sm">
|
||||
@@ -606,7 +616,7 @@ export function OddsConfigDocScreen() {
|
||||
void handlePublish();
|
||||
}}
|
||||
>
|
||||
Confirm publish
|
||||
{t("odds.publishDialog.confirm", { ns: "config" })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -284,7 +284,7 @@ export function PlayConfigDocScreen() {
|
||||
const payload = buildPlayConfigSavePayload(draftRows);
|
||||
for (const r of payload) {
|
||||
if (r.min_bet_amount > r.max_bet_amount) {
|
||||
toast.error(`${r.play_code}: min_bet_amount cannot exceed max_bet_amount`);
|
||||
toast.error(t("play.validation.minMaxInvalid", { ns: "config", playCode: r.play_code }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -315,7 +315,7 @@ export function PlayConfigDocScreen() {
|
||||
void refreshList();
|
||||
setSelectedId(String(d.id));
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : "Publish failed");
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("play.publishFailed", { ns: "config" }));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -329,14 +329,14 @@ export function PlayConfigDocScreen() {
|
||||
reason: `draft ${new Date().toISOString()}`,
|
||||
clone_from_version_id: active?.id ?? null,
|
||||
});
|
||||
toast.success(`Created draft v${d.version_no}`);
|
||||
toast.success(t("play.createDraftSuccess", { ns: "config", version: d.version_no }));
|
||||
setCreatingDraftId(String(d.id));
|
||||
setSelectedId(String(d.id));
|
||||
setDetail(d);
|
||||
setDraftRows(d.items.map((it) => ({ ...it })));
|
||||
void refreshList();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : "Create draft failed");
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("play.createDraftFailed", { ns: "config" }));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -356,7 +356,7 @@ export function PlayConfigDocScreen() {
|
||||
updateConfigRow(rulePlayCode, { rule_text_zh: ruleDraftZh.trim() || null });
|
||||
setRuleDialogOpen(false);
|
||||
setRulePlayCode(null);
|
||||
toast.message("Rule text saved into the local draft. Save the draft to persist it.");
|
||||
toast.message(t("play.ruleSavedLocal", { ns: "config" }));
|
||||
}
|
||||
|
||||
const activeHead = list.find((x) => x.status === "active");
|
||||
@@ -367,7 +367,7 @@ export function PlayConfigDocScreen() {
|
||||
toast.success(t("versionSwitcher.delete", { ns: "config" }));
|
||||
await refreshList();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : "Delete failed");
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("play.deleteFailed", { ns: "config" }));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -407,14 +407,14 @@ export function PlayConfigDocScreen() {
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{activeHead ? (
|
||||
<>
|
||||
Active version v{activeHead.version_no}
|
||||
{t("play.activeVersion", { ns: "config", version: activeHead.version_no })}
|
||||
{activeHead.effective_at ? ` · ${activeHead.effective_at}` : ""}
|
||||
</>
|
||||
) : null}
|
||||
{!isDraft ? (
|
||||
<span className="text-amber-600 dark:text-amber-400">
|
||||
{activeHead ? " — " : ""}
|
||||
Limits and rules are read-only. Create a draft first.
|
||||
{t("play.readOnlyHint", { ns: "config" })}
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
@@ -424,14 +424,14 @@ export function PlayConfigDocScreen() {
|
||||
<div className="rounded-xl border bg-muted/20 p-3">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Batch switches</p>
|
||||
<p className="text-sm font-medium">{t("play.batchSwitchesTitle", { ns: "config" })}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Only updates the current draft. The player betting table refreshes after save and publish.
|
||||
{t("play.batchSwitchesDesc", { ns: "config" })}
|
||||
</p>
|
||||
</div>
|
||||
{!isDraft ? (
|
||||
<span className="text-xs text-amber-600 dark:text-amber-400">
|
||||
Current version is read-only. Create a draft first.
|
||||
{t("play.readOnlyDraftHint", { ns: "config" })}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -444,7 +444,13 @@ export function PlayConfigDocScreen() {
|
||||
<div className="min-w-[92px]">
|
||||
<p className="text-sm font-medium">{group.label}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{group.total > 0 ? `${group.enabledCount}/${group.total} enabled` : "No play types"}
|
||||
{group.total > 0
|
||||
? t("play.batchEnabledCount", {
|
||||
ns: "config",
|
||||
enabledCount: group.enabledCount,
|
||||
total: group.total,
|
||||
})
|
||||
: t("play.noPlayTypes", { ns: "config" })}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -454,7 +460,9 @@ export function PlayConfigDocScreen() {
|
||||
disabled={!isDraft || saving || group.total === 0}
|
||||
onClick={() => applyBatchSwitch(group, !group.allEnabled)}
|
||||
>
|
||||
{group.allEnabled ? "Disable" : "Enable"}
|
||||
{group.allEnabled
|
||||
? t("play.actions.disable", { ns: "config" })
|
||||
: t("play.actions.enable", { ns: "config" })}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
@@ -471,14 +479,14 @@ export function PlayConfigDocScreen() {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="text-center">Play Code</TableHead>
|
||||
<TableHead className="w-[100px] text-center">Category</TableHead>
|
||||
<TableHead className="w-[88px] text-center">Status</TableHead>
|
||||
<TableHead className="min-w-[120px] text-center">Display Name</TableHead>
|
||||
<TableHead className="w-[120px] text-center">Order</TableHead>
|
||||
<TableHead className="w-[110px] text-center">Min Bet</TableHead>
|
||||
<TableHead className="w-[110px] text-center">Max Bet</TableHead>
|
||||
<TableHead className="w-[140px] text-center">Actions</TableHead>
|
||||
<TableHead className="text-center">{t("play.table.playCode", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-[100px] text-center">{t("play.table.category", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-[88px] text-center">{t("play.table.status", { ns: "config" })}</TableHead>
|
||||
<TableHead className="min-w-[120px] text-center">{t("play.table.displayName", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-[120px] text-center">{t("play.table.order", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-[110px] text-center">{t("play.table.minBet", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-[110px] text-center">{t("play.table.maxBet", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-[140px] text-center">{t("play.table.actions", { ns: "config" })}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -494,11 +502,13 @@ export function PlayConfigDocScreen() {
|
||||
onCheckedChange={(v) => {
|
||||
updateConfigRow(row.play_code, { is_enabled: v === true });
|
||||
}}
|
||||
aria-label={`Enable ${row.play_code}`}
|
||||
aria-label={t("play.aria.enablePlay", { ns: "config", playCode: row.play_code })}
|
||||
/>
|
||||
) : (
|
||||
<ConfigReadonlyValue className="justify-center">
|
||||
{row.is_enabled ? "Enabled" : "Disabled"}
|
||||
{row.is_enabled
|
||||
? t("play.states.enabled", { ns: "config" })
|
||||
: t("play.states.disabled", { ns: "config" })}
|
||||
</ConfigReadonlyValue>
|
||||
)}
|
||||
</TableCell>
|
||||
@@ -588,10 +598,10 @@ export function PlayConfigDocScreen() {
|
||||
disabled={saving}
|
||||
onClick={() => openRuleEditor(row.play_code)}
|
||||
>
|
||||
Rule Text
|
||||
{t("play.actions.ruleText", { ns: "config" })}
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">Read only</span>
|
||||
<span className="text-sm text-muted-foreground">{t("play.states.readOnly", { ns: "config" })}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -605,13 +615,13 @@ export function PlayConfigDocScreen() {
|
||||
<Dialog open={ruleDialogOpen} onOpenChange={setRuleDialogOpen}>
|
||||
<DialogContent showCloseButton className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rule Text (Chinese)</DialogTitle>
|
||||
<DialogTitle>{t("play.ruleDialog.title", { ns: "config" })}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Play {rulePlayCode ?? "—"}; changes are only stored in the draft until you save and publish it.
|
||||
{t("play.ruleDialog.description", { ns: "config", playCode: rulePlayCode ?? "—" })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="rule-zh">rule_text_zh</Label>
|
||||
<Label htmlFor="rule-zh">{t("play.ruleDialog.fieldLabel", { ns: "config" })}</Label>
|
||||
<textarea
|
||||
id="rule-zh"
|
||||
className="border-input bg-background ring-ring/24 focus-visible:ring-[3px] min-h-[140px] w-full rounded-lg border px-3 py-2 text-sm outline-none"
|
||||
@@ -624,7 +634,7 @@ export function PlayConfigDocScreen() {
|
||||
{t("actions.cancel", { ns: "adminUsers" })}
|
||||
</Button>
|
||||
<Button type="button" onClick={saveRuleZh}>
|
||||
Apply to Draft
|
||||
{t("play.ruleDialog.apply", { ns: "config" })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -218,11 +218,11 @@ export function RebateConfigDocScreen() {
|
||||
setP2(inferPercentFrom(2, rows, types));
|
||||
setP3(inferPercentFrom(3, rows, types));
|
||||
setP4(inferPercentFrom(4, rows, types));
|
||||
toast.success("Published odds version with rebate");
|
||||
toast.success(t("rebate.publishSuccess", { ns: "config" }));
|
||||
void refreshList();
|
||||
setSelectedId(String(d.id));
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : "Publish failed");
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("rebate.publishFailed", { ns: "config" }));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -236,7 +236,7 @@ export function RebateConfigDocScreen() {
|
||||
reason: `rebate draft ${new Date().toISOString()}`,
|
||||
clone_from_version_id: active?.id ?? null,
|
||||
});
|
||||
toast.success(`Created draft v${d.version_no}`);
|
||||
toast.success(t("rebate.createDraftSuccess", { ns: "config", version: d.version_no }));
|
||||
await refreshList();
|
||||
setSelectedId(String(d.id));
|
||||
const rows = d.items.map((it) => ({ ...it }));
|
||||
@@ -246,7 +246,7 @@ export function RebateConfigDocScreen() {
|
||||
setP3(inferPercentFrom(3, rows, types));
|
||||
setP4(inferPercentFrom(4, rows, types));
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : "Create draft failed");
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("rebate.createDraftFailed", { ns: "config" }));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -260,7 +260,7 @@ export function RebateConfigDocScreen() {
|
||||
toast.success(t("versionSwitcher.delete", { ns: "config" }));
|
||||
await refreshList();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : "Delete failed");
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("rebate.deleteFailed", { ns: "config" }));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -278,7 +278,7 @@ export function RebateConfigDocScreen() {
|
||||
onSelectedIdChange={setSelectedId}
|
||||
loading={loading}
|
||||
sheetTitle={`${t("nav.items.rebate", { ns: "config" })} ${t("versionSwitcher.sheetTitle", { ns: "config" })}`}
|
||||
sheetDescription="Rebate is stored in the odds draft version and shares the same version set as odds."
|
||||
sheetDescription={t("rebate.sheetDescription", { ns: "config" })}
|
||||
onDeleteVersion={handleDeleteVersion}
|
||||
className="w-auto min-w-0"
|
||||
/>
|
||||
@@ -288,7 +288,7 @@ export function RebateConfigDocScreen() {
|
||||
loadingList={loading}
|
||||
loadingDetail={loadingDetail}
|
||||
saving={saving}
|
||||
publishLabel="Publish"
|
||||
publishLabel={t("rebate.publishLabel", { ns: "config" })}
|
||||
onRefresh={() => void refreshList()}
|
||||
onNewDraft={() => void handleNewDraft()}
|
||||
onSaveDraft={() => void handleSave()}
|
||||
@@ -297,9 +297,18 @@ export function RebateConfigDocScreen() {
|
||||
|
||||
{detail ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Editing version v{detail.version_no} · {detail.status === "draft" ? "Draft" : detail.status === "active" ? "Active" : "Archived"}
|
||||
{t("rebate.editingVersion", {
|
||||
ns: "config",
|
||||
version: detail.version_no,
|
||||
status:
|
||||
detail.status === "draft"
|
||||
? t("versionStatus.draft", { ns: "config" })
|
||||
: detail.status === "active"
|
||||
? t("versionStatus.active", { ns: "config" })
|
||||
: t("versionStatus.archived", { ns: "config" }),
|
||||
})}
|
||||
{!isDraft ? (
|
||||
<span className="text-amber-600 dark:text-amber-400"> - Create a draft before editing rebate.</span>
|
||||
<span className="text-amber-600 dark:text-amber-400"> - {t("rebate.readOnlyHint", { ns: "config" })}</span>
|
||||
) : null}
|
||||
</p>
|
||||
) : null}
|
||||
@@ -307,7 +316,7 @@ export function RebateConfigDocScreen() {
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="grid gap-2">
|
||||
<Label>2D Rebate Rate (%)</Label>
|
||||
<Label>{t("rebate.fields.d2", { ns: "config" })}</Label>
|
||||
{isDraft ? (
|
||||
<Input
|
||||
type="number"
|
||||
@@ -323,7 +332,7 @@ export function RebateConfigDocScreen() {
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>3D Rebate Rate (%)</Label>
|
||||
<Label>{t("rebate.fields.d3", { ns: "config" })}</Label>
|
||||
{isDraft ? (
|
||||
<Input
|
||||
type="number"
|
||||
@@ -339,7 +348,7 @@ export function RebateConfigDocScreen() {
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>4D Rebate Rate (%)</Label>
|
||||
<Label>{t("rebate.fields.d4", { ns: "config" })}</Label>
|
||||
{isDraft ? (
|
||||
<Input
|
||||
type="number"
|
||||
@@ -357,19 +366,25 @@ export function RebateConfigDocScreen() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 rounded-lg border bg-muted/30 p-4">
|
||||
<Checkbox id="win-enjoy" checked aria-disabled disabled aria-label="Apply rebate on winning tickets" />
|
||||
<Checkbox
|
||||
id="win-enjoy"
|
||||
checked
|
||||
aria-disabled
|
||||
disabled
|
||||
aria-label={t("rebate.winEnjoy.label", { ns: "config" })}
|
||||
/>
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="win-enjoy" className="font-medium leading-snug">
|
||||
Apply rebate on winning tickets
|
||||
{t("rebate.winEnjoy.label", { ns: "config" })}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Placeholder field. It can later be aligned with risk and settlement rules and persisted.
|
||||
{t("rebate.winEnjoy.description", { ns: "config" })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1 text-sm">
|
||||
<span className="text-muted-foreground">Effective Time (current active odds version)</span>
|
||||
<span className="text-muted-foreground">{t("rebate.effectiveTime", { ns: "config" })}</span>
|
||||
<span className="font-mono text-sm">
|
||||
{activeHead?.effective_at ? formatDt(activeHead.effective_at) : "—"}
|
||||
</span>
|
||||
|
||||
@@ -189,19 +189,19 @@ export function RiskCapDocScreen() {
|
||||
return;
|
||||
}
|
||||
if (draftRows.length === 0) {
|
||||
toast.error("At least one cap row is required");
|
||||
toast.error(t("riskCap.validation.requireAtLeastOne", { ns: "config" }));
|
||||
return;
|
||||
}
|
||||
for (const r of draftRows) {
|
||||
if (isDefaultRiskRow(r)) {
|
||||
if (r.cap_amount <= 0) {
|
||||
toast.error("Default cap amount must be greater than 0");
|
||||
toast.error(t("riskCap.validation.defaultGreaterThanZero", { ns: "config" }));
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!/^[0-9]{4}$/.test(r.normalized_number)) {
|
||||
toast.error(`Number must be 4 digits: ${r.normalized_number}`);
|
||||
toast.error(t("riskCap.validation.numberMustBe4Digits", { ns: "config", number: r.normalized_number }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -254,7 +254,7 @@ export function RiskCapDocScreen() {
|
||||
void refreshList();
|
||||
setSelectedId(String(d.id));
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : "Publish failed");
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("riskCap.publishFailed", { ns: "config" }));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -268,7 +268,7 @@ export function RiskCapDocScreen() {
|
||||
reason: `draft ${new Date().toISOString()}`,
|
||||
clone_from_version_id: active?.id ?? null,
|
||||
});
|
||||
toast.success(`Created draft v${d.version_no}`);
|
||||
toast.success(t("riskCap.createDraftSuccess", { ns: "config", version: d.version_no }));
|
||||
await refreshList();
|
||||
setSelectedId(String(d.id));
|
||||
setDetail(d);
|
||||
@@ -282,7 +282,7 @@ export function RiskCapDocScreen() {
|
||||
setDraftRows(nd);
|
||||
syncDefaultCapFromRows(nd);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : "Create draft failed");
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("riskCap.createDraftFailed", { ns: "config" }));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -291,7 +291,7 @@ export function RiskCapDocScreen() {
|
||||
function applyDefaultCap() {
|
||||
const n = Number.parseInt(defaultCapStr, 10);
|
||||
if (!Number.isFinite(n) || n <= 0) {
|
||||
toast.error("Enter a valid cap amount");
|
||||
toast.error(t("riskCap.validation.enterValidCapAmount", { ns: "config" }));
|
||||
return;
|
||||
}
|
||||
setDraftRows((prev) => {
|
||||
@@ -299,7 +299,7 @@ export function RiskCapDocScreen() {
|
||||
return [defaultRiskRowFromAmount(n), ...next];
|
||||
});
|
||||
setSyncOpen(false);
|
||||
toast.message("Saved into local draft. Save the draft to persist it.");
|
||||
toast.message(t("riskCap.savedLocalDraft", { ns: "config" }));
|
||||
}
|
||||
|
||||
const occFiltered = useMemo(() => {
|
||||
@@ -321,7 +321,7 @@ export function RiskCapDocScreen() {
|
||||
toast.success(t("versionSwitcher.delete", { ns: "config" }));
|
||||
await refreshList();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : "Delete failed");
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("riskCap.deleteFailed", { ns: "config" }));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -364,9 +364,9 @@ export function RiskCapDocScreen() {
|
||||
|
||||
{detail ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Effective at: {detail.effective_at ? formatDt(detail.effective_at) : "—"} · Note: {detail.reason ?? "—"}
|
||||
{t("riskCap.effectiveAt", { ns: "config", value: detail.effective_at ? formatDt(detail.effective_at) : "—" })} · {t("riskCap.note", { ns: "config", value: detail.reason ?? "—" })}
|
||||
{!isDraft ? (
|
||||
<span className="text-amber-600 dark:text-amber-400"> - Read only. Create a draft first.</span>
|
||||
<span className="text-amber-600 dark:text-amber-400"> - {t("riskCap.readOnlyHint", { ns: "config" })}</span>
|
||||
) : null}
|
||||
</p>
|
||||
) : null}
|
||||
@@ -375,13 +375,13 @@ export function RiskCapDocScreen() {
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
|
||||
<section className="space-y-3 rounded-lg border bg-muted/20 p-4">
|
||||
<h3 className="text-sm font-medium">Default Cap</h3>
|
||||
<h3 className="text-sm font-medium">{t("riskCap.defaultCap.title", { ns: "config" })}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Numbers without a special cap use this default cap template.
|
||||
{t("riskCap.defaultCap.description", { ns: "config" })}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="default-cap">Cap Amount (minor unit)</Label>
|
||||
<Label htmlFor="default-cap">{t("riskCap.defaultCap.fieldLabel", { ns: "config" })}</Label>
|
||||
{isDraft ? (
|
||||
<Input
|
||||
id="default-cap"
|
||||
@@ -400,7 +400,7 @@ export function RiskCapDocScreen() {
|
||||
</div>
|
||||
{isDraft ? (
|
||||
<Button type="button" variant="secondary" disabled={saving} onClick={() => setSyncOpen(true)}>
|
||||
Update
|
||||
{t("riskCap.actions.update", { ns: "config" })}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -408,7 +408,7 @@ export function RiskCapDocScreen() {
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-medium">Special Caps</h3>
|
||||
<h3 className="text-sm font-medium">{t("riskCap.specialCaps.title", { ns: "config" })}</h3>
|
||||
{isDraft ? (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -416,25 +416,25 @@ export function RiskCapDocScreen() {
|
||||
disabled={saving}
|
||||
onClick={() => setDraftRows((prev) => [...prev, newRow()])}
|
||||
>
|
||||
+ Add Special Cap
|
||||
{t("riskCap.actions.addSpecialCap", { ns: "config" })}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{loadingDetail ? (
|
||||
<p className="text-sm text-muted-foreground">Loading details…</p>
|
||||
<p className="text-sm text-muted-foreground">{t("riskCap.loadingDetails", { ns: "config" })}</p>
|
||||
) : specialRows.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No detail rows.</p>
|
||||
<p className="text-sm text-muted-foreground">{t("riskCap.noDetailRows", { ns: "config" })}</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[110px]">Number</TableHead>
|
||||
<TableHead className="w-[140px]">Cap Amount</TableHead>
|
||||
<TableHead className="w-[90px] text-right">Used</TableHead>
|
||||
<TableHead className="w-[90px] text-right">Remaining</TableHead>
|
||||
<TableHead className="w-[72px] text-center">Sold Out</TableHead>
|
||||
<TableHead className="w-[160px]">Actions</TableHead>
|
||||
<TableHead className="w-[110px]">{t("riskCap.table.number", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-[140px]">{t("riskCap.table.capAmount", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-[90px] text-right">{t("riskCap.table.used", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-[90px] text-right">{t("riskCap.table.remaining", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-[72px] text-center">{t("riskCap.table.soldOut", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-[160px]">{t("riskCap.table.actions", { ns: "config" })}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -490,7 +490,7 @@ export function RiskCapDocScreen() {
|
||||
{t("actions.delete", { ns: "adminUsers" })}
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">Read only</span>
|
||||
<span className="text-sm text-muted-foreground">{t("riskCap.readOnly", { ns: "config" })}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -502,42 +502,42 @@ export function RiskCapDocScreen() {
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-sm font-medium">All Number Occupancy</h3>
|
||||
<h3 className="text-sm font-medium">{t("riskCap.occupancy.title", { ns: "config" })}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Placeholder view: filters and exports still need ticket-summary integration. Data below still comes from the current draft list.
|
||||
{t("riskCap.occupancy.description", { ns: "config" })}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3 items-end">
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="occ-search">Search Number</Label>
|
||||
<Label htmlFor="occ-search">{t("riskCap.occupancy.searchLabel", { ns: "config" })}</Label>
|
||||
<Input
|
||||
id="occ-search"
|
||||
className="w-[140px] font-mono"
|
||||
placeholder="e.g. 8888"
|
||||
placeholder={t("riskCap.occupancy.searchPlaceholder", { ns: "config" })}
|
||||
value={occSearch}
|
||||
onChange={(e) => setOccSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="button" variant="outline" onClick={() => toast.message("Sold-out / high-risk preset filter is pending integration")}>
|
||||
Filter Presets…
|
||||
<Button type="button" variant="outline" onClick={() => toast.message(t("riskCap.occupancy.filterPending", { ns: "config" }))}>
|
||||
{t("riskCap.actions.filterPresets", { ns: "config" })}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => toast.message("CSV export is pending integration")}
|
||||
onClick={() => toast.message(t("riskCap.occupancy.exportPending", { ns: "config" }))}
|
||||
>
|
||||
Export CSV
|
||||
{t("riskCap.actions.exportCsv", { ns: "config" })}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Number</TableHead>
|
||||
<TableHead className="text-right">Used</TableHead>
|
||||
<TableHead className="text-right">Remaining</TableHead>
|
||||
<TableHead className="text-right">Ratio</TableHead>
|
||||
<TableHead className="text-center">Sold Out</TableHead>
|
||||
<TableHead className="w-[140px]">Actions</TableHead>
|
||||
<TableHead>{t("riskCap.table.number", { ns: "config" })}</TableHead>
|
||||
<TableHead className="text-right">{t("riskCap.table.used", { ns: "config" })}</TableHead>
|
||||
<TableHead className="text-right">{t("riskCap.table.remaining", { ns: "config" })}</TableHead>
|
||||
<TableHead className="text-right">{t("riskCap.table.ratio", { ns: "config" })}</TableHead>
|
||||
<TableHead className="text-center">{t("riskCap.table.soldOut", { ns: "config" })}</TableHead>
|
||||
<TableHead className="w-[140px]">{t("riskCap.table.actions", { ns: "config" })}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -550,7 +550,7 @@ export function RiskCapDocScreen() {
|
||||
<TableCell className="text-center text-muted-foreground">—</TableCell>
|
||||
<TableCell>
|
||||
<Button type="button" variant="ghost" disabled>
|
||||
Close
|
||||
{t("riskCap.actions.close", { ns: "config" })}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -564,9 +564,9 @@ export function RiskCapDocScreen() {
|
||||
<Dialog open={syncOpen} onOpenChange={setSyncOpen}>
|
||||
<DialogContent showCloseButton className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Sync Default Cap</DialogTitle>
|
||||
<DialogTitle>{t("riskCap.syncDialog.title", { ns: "config" })}</DialogTitle>
|
||||
<DialogDescription>
|
||||
The default cap template will be set to {defaultCapStr || "(empty)"}. This only changes the draft. Save and publish after confirming.
|
||||
{t("riskCap.syncDialog.description", { ns: "config", value: defaultCapStr || "(empty)" })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
@@ -574,7 +574,7 @@ export function RiskCapDocScreen() {
|
||||
{t("actions.cancel", { ns: "adminUsers" })}
|
||||
</Button>
|
||||
<Button type="button" onClick={applyDefaultCap}>
|
||||
Confirm
|
||||
{t("riskCap.syncDialog.confirm", { ns: "config" })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
export const configHubMeta = {
|
||||
title: "Configuration Center",
|
||||
description: "Manage play catalogs, odds, rebates, and risk caps with draft, publish, and activation stages.",
|
||||
title: "运营配置",
|
||||
description: "管理玩法目录、赔率、返水与风控限额,支持草稿、发布与生效流程。",
|
||||
} as const;
|
||||
|
||||
export const configPlayConfigMeta = {
|
||||
title: "Play Configuration",
|
||||
description: "Manage play switches, limits, and rule text. Catalog changes directly affect betting entry points.",
|
||||
title: "玩法配置",
|
||||
description: "管理玩法开关、限额与规则说明,目录变更会直接影响投注入口。",
|
||||
} as const;
|
||||
|
||||
export const configOddsMeta = {
|
||||
title: "Odds Configuration",
|
||||
description: "Manage odds, rebates, and commissions. Verify ranges and currency before publishing.",
|
||||
title: "赔率配置",
|
||||
description: "管理赔率、返水与佣金,发布前需确认区间与币种配置。",
|
||||
} as const;
|
||||
|
||||
export const configRebateMeta = {
|
||||
title: "Commission / Rebate",
|
||||
description: "Batch-adjust rebate rates from the odds draft, suitable for dimension-wide updates.",
|
||||
title: "返水与佣金",
|
||||
description: "基于赔率草稿批量调整返水比例,适合按维度统一更新。",
|
||||
} as const;
|
||||
|
||||
export const configRiskCapMeta = {
|
||||
title: "Risk Caps",
|
||||
description: "Manage number cap versions and risk pool thresholds. Confirm number scope and draw before publishing.",
|
||||
title: "风控限额",
|
||||
description: "管理号码限额版本与风控池阈值,发布前需确认号码范围与期号。",
|
||||
} as const;
|
||||
|
||||
export const configWalletMeta = {
|
||||
title: "Wallet Configuration",
|
||||
description: "Manage wallet thresholds and transfer policies.",
|
||||
title: "钱包配置",
|
||||
description: "管理钱包阈值与划转策略参数。",
|
||||
} as const;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const dashboardModuleMeta = {
|
||||
segment: "dashboard",
|
||||
title: "Dashboard",
|
||||
title: "仪表盘",
|
||||
description: "",
|
||||
} as const;
|
||||
|
||||
@@ -26,6 +26,12 @@ import { toast } from "sonner";
|
||||
|
||||
import { PRD_PAYOUT_MANAGE, 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;
|
||||
}
|
||||
|
||||
export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactElement {
|
||||
const { t } = useTranslation(["draws", "common"]);
|
||||
const idNum = Number(drawId);
|
||||
@@ -98,7 +104,7 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t("status")}</span>
|
||||
<p>{data.draw_status}</p>
|
||||
<p>{drawStatusText(data.draw_status, t)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t("orderAndItemCount")}</span>
|
||||
@@ -167,7 +173,7 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
|
||||
<Table id={`draw-finance-table-${drawId}`}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-20">ID</TableHead>
|
||||
<TableHead className="w-20">{t("table.id", { ns: "common" })}</TableHead>
|
||||
<TableHead>{t("status")}</TableHead>
|
||||
<TableHead className="text-right">{t("ticketCount")}</TableHead>
|
||||
<TableHead className="text-right">{t("winCount")}</TableHead>
|
||||
@@ -180,7 +186,7 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
|
||||
{data.settlement_batches.map((b) => (
|
||||
<TableRow key={b.id}>
|
||||
<TableCell className="font-mono text-xs">{b.id}</TableCell>
|
||||
<TableCell className="text-xs">{b.status}</TableCell>
|
||||
<TableCell className="text-xs">{drawStatusText(b.status, t)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-xs">
|
||||
{b.total_ticket_count}
|
||||
</TableCell>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const drawsModuleMeta = {
|
||||
segment: "draws",
|
||||
title: "Draws",
|
||||
title: "期号管理",
|
||||
description: "",
|
||||
} as const;
|
||||
|
||||
@@ -95,8 +95,19 @@ export function JackpotRecordsConsole() {
|
||||
setCPage(1);
|
||||
};
|
||||
|
||||
const triggerTypeText = (value: string) => {
|
||||
const key = `triggerTypes.${value}`;
|
||||
const translated = t(key);
|
||||
return translated === key ? value : translated;
|
||||
};
|
||||
|
||||
return (
|
||||
<ModuleScaffold>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-lg font-semibold tracking-tight">{t("recordsPage.title")}</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t("recordsPage.description")}</p>
|
||||
</div>
|
||||
|
||||
<Card className="mb-6">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">{t("filter")}</CardTitle>
|
||||
@@ -132,14 +143,14 @@ export function JackpotRecordsConsole() {
|
||||
<div className="admin-table-toolbar">
|
||||
<AdminTableExportButton
|
||||
tableId="jackpot-payout-table"
|
||||
filename="Jackpot派彩记录"
|
||||
sheetName="Jackpot派彩"
|
||||
filename="奖池派彩记录"
|
||||
sheetName="奖池派彩"
|
||||
/>
|
||||
</div>
|
||||
<Table id="jackpot-payout-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>{t("table.id", { ns: "common" })}</TableHead>
|
||||
<TableHead>{t("drawNo")}</TableHead>
|
||||
<TableHead>{t("trigger")}</TableHead>
|
||||
<TableHead className="text-right">{t("payoutAmount")}</TableHead>
|
||||
@@ -152,7 +163,7 @@ export function JackpotRecordsConsole() {
|
||||
<TableRow key={r.id}>
|
||||
<TableCell className="font-mono text-xs">{r.id}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{r.draw_no ?? "—"}</TableCell>
|
||||
<TableCell className="text-xs">{r.trigger_type}</TableCell>
|
||||
<TableCell className="text-xs">{triggerTypeText(r.trigger_type)}</TableCell>
|
||||
<TableCell className="text-right font-mono text-xs tabular-nums">
|
||||
{formatAdminMinorUnits(r.total_payout_amount, r.currency_code ?? "NPR")}
|
||||
</TableCell>
|
||||
@@ -196,14 +207,14 @@ export function JackpotRecordsConsole() {
|
||||
<div className="admin-table-toolbar">
|
||||
<AdminTableExportButton
|
||||
tableId="jackpot-contribution-table"
|
||||
filename="Jackpot注入记录"
|
||||
sheetName="Jackpot注入"
|
||||
filename="奖池注入记录"
|
||||
sheetName="奖池注入"
|
||||
/>
|
||||
</div>
|
||||
<Table id="jackpot-contribution-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>{t("table.id", { ns: "common" })}</TableHead>
|
||||
<TableHead>{t("drawNo")}</TableHead>
|
||||
<TableHead>{t("ticketNo")}</TableHead>
|
||||
<TableHead>{t("player")}</TableHead>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const jackpotModuleMeta = {
|
||||
title: "Jackpot",
|
||||
title: "奖池记录",
|
||||
description: "",
|
||||
} as const;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const playersModuleMeta = {
|
||||
segment: "players",
|
||||
title: "Players",
|
||||
title: "玩家列表",
|
||||
description: "",
|
||||
} as const;
|
||||
|
||||
@@ -309,7 +309,7 @@ export function PlayersConsole(): React.ReactElement {
|
||||
<Table id="players-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-16">ID</TableHead>
|
||||
<TableHead className="w-16">{t("table.id", { ns: "common" })}</TableHead>
|
||||
<TableHead>{t("site")}</TableHead>
|
||||
<TableHead>{t("sitePlayerId")}</TableHead>
|
||||
<TableHead>{t("username")}</TableHead>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const reconcileModuleMeta = {
|
||||
segment: "reconcile",
|
||||
title: "Reconcile",
|
||||
title: "对账中心",
|
||||
description: "",
|
||||
} as const;
|
||||
|
||||
@@ -309,7 +309,7 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
<Table id="reconcile-jobs-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-24">ID</TableHead>
|
||||
<TableHead className="w-24">{t("table.id", { ns: "common" })}</TableHead>
|
||||
<TableHead>{t("jobNo")}</TableHead>
|
||||
<TableHead>{t("type")}</TableHead>
|
||||
<TableHead>{t("status")}</TableHead>
|
||||
@@ -398,7 +398,7 @@ export function ReconcileConsole(): React.ReactElement {
|
||||
<Table id={`reconcile-items-table-${selectedId ?? "none"}`}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-20">ID</TableHead>
|
||||
<TableHead className="w-20">{t("table.id", { ns: "common" })}</TableHead>
|
||||
<TableHead>{t("sideARef")}</TableHead>
|
||||
<TableHead>{t("sideBRef")}</TableHead>
|
||||
<TableHead>{t("differenceAmount")}</TableHead>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const reportsModuleMeta = {
|
||||
segment: "reports",
|
||||
title: "Reports",
|
||||
title: "报表导出",
|
||||
description: "",
|
||||
} as const;
|
||||
|
||||
@@ -129,6 +129,9 @@ export function ReportsConsole(): React.ReactElement {
|
||||
const lastPage = meta
|
||||
? Math.max(1, meta.last_page)
|
||||
: 1;
|
||||
const reportFormatLabel = (value: string) =>
|
||||
t(`formatOptions.${value}`, { defaultValue: value.toUpperCase() });
|
||||
const reportStatusLabel = (value: string) => t(`statusOptions.${value}`, { defaultValue: value });
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-none flex-col gap-8">
|
||||
@@ -175,8 +178,8 @@ export function ReportsConsole(): React.ReactElement {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="csv">CSV</SelectItem>
|
||||
<SelectItem value="xlsx">XLSX</SelectItem>
|
||||
<SelectItem value="csv">{t("formatOptions.csv")}</SelectItem>
|
||||
<SelectItem value="xlsx">{t("formatOptions.xlsx")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -245,9 +248,9 @@ export function ReportsConsole(): React.ReactElement {
|
||||
defaultValue: row.report_type,
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell>{row.export_format}</TableCell>
|
||||
<TableCell>{reportFormatLabel(row.export_format)}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{row.status}</Badge>
|
||||
<Badge variant="secondary">{reportStatusLabel(row.status)}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[12rem] truncate text-xs text-muted-foreground">
|
||||
{row.output_path ?? "—"}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const riskModuleMeta = {
|
||||
segment: "risk",
|
||||
title: "Risk",
|
||||
title: "风控中心",
|
||||
description: "",
|
||||
} as const;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const settingsModuleMeta = {
|
||||
segment: "settings",
|
||||
title: "Settings",
|
||||
description: "",
|
||||
title: "系统设置",
|
||||
description: "管理影响钱包划转与跨模块行为的全局运行参数。",
|
||||
} as const;
|
||||
|
||||
226
src/modules/settings/system-settings-screen.tsx
Normal file
226
src/modules/settings/system-settings-screen.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
getAdminSettings,
|
||||
updateAdminSetting,
|
||||
} from "@/api/admin-settings";
|
||||
import { WalletConfigDocScreen } from "@/modules/config/doc/wallet-config-doc-screen";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
|
||||
const DRAW_GROUP = "draw";
|
||||
const SETTLEMENT_GROUP = "settlement";
|
||||
|
||||
const DRAW_KEYS = {
|
||||
REQUIRE_MANUAL_REVIEW: "draw.require_manual_review",
|
||||
COOLDOWN_MINUTES: "draw.cooldown_minutes",
|
||||
AUTO_SETTLEMENT: "settlement.auto_run_on_tick",
|
||||
} as const;
|
||||
|
||||
interface RuntimeDraft {
|
||||
requireManualReview: boolean;
|
||||
cooldownMinutes: string;
|
||||
autoSettlement: boolean;
|
||||
}
|
||||
|
||||
export function SystemSettingsScreen() {
|
||||
const { t } = useTranslation(["common", "config", "adminUsers"]);
|
||||
const [draft, setDraft] = useState<RuntimeDraft>({
|
||||
requireManualReview: false,
|
||||
cooldownMinutes: "15",
|
||||
autoSettlement: true,
|
||||
});
|
||||
const [saved, setSaved] = useState<RuntimeDraft>({
|
||||
requireManualReview: false,
|
||||
cooldownMinutes: "15",
|
||||
autoSettlement: true,
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [drawRes, settlementRes] = await Promise.all([
|
||||
getAdminSettings(DRAW_GROUP),
|
||||
getAdminSettings(SETTLEMENT_GROUP),
|
||||
]);
|
||||
|
||||
const kv: Record<string, unknown> = {};
|
||||
for (const item of [...drawRes.items, ...settlementRes.items]) {
|
||||
kv[item.key] = item.value;
|
||||
}
|
||||
|
||||
const nextDraft: RuntimeDraft = {
|
||||
requireManualReview: Boolean(kv[DRAW_KEYS.REQUIRE_MANUAL_REVIEW] ?? false),
|
||||
cooldownMinutes: String(kv[DRAW_KEYS.COOLDOWN_MINUTES] ?? 15),
|
||||
autoSettlement: Boolean(kv[DRAW_KEYS.AUTO_SETTLEMENT] ?? true),
|
||||
};
|
||||
setDraft(nextDraft);
|
||||
setSaved(nextDraft);
|
||||
setDirty(false);
|
||||
} catch {
|
||||
toast.error(t("system.loadFailed", { ns: "config" }));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
void load();
|
||||
});
|
||||
}, [load]);
|
||||
|
||||
const updateDraft = <K extends keyof RuntimeDraft>(field: K, value: RuntimeDraft[K]) => {
|
||||
setDraft((prev) => ({ ...prev, [field]: value }));
|
||||
setDirty(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateAdminSetting(DRAW_KEYS.REQUIRE_MANUAL_REVIEW, draft.requireManualReview);
|
||||
await updateAdminSetting(
|
||||
DRAW_KEYS.COOLDOWN_MINUTES,
|
||||
Math.max(0, Number.parseInt(draft.cooldownMinutes || "0", 10) || 0),
|
||||
);
|
||||
await updateAdminSetting(DRAW_KEYS.AUTO_SETTLEMENT, draft.autoSettlement);
|
||||
toast.success(t("system.saveSuccess", { ns: "config" }));
|
||||
setSaved(draft);
|
||||
setDirty(false);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof LotteryApiBizError ? error.message : t("system.saveFailed", { ns: "config" }),
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6">
|
||||
<Card>
|
||||
<CardHeader className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
{t("nav.settings", { ns: "common", defaultValue: "System Settings" })}
|
||||
</p>
|
||||
<CardTitle className="text-2xl">
|
||||
{t("system.runtimeTitle", { ns: "config" })}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm text-muted-foreground">
|
||||
<p>
|
||||
{t("system.runtimeIntro1", { ns: "config" })}
|
||||
</p>
|
||||
<p>
|
||||
{t("system.runtimeIntro2", { ns: "config" })}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("system.title", { ns: "config" })}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("system.description", { ns: "config" })}
|
||||
</p>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-3 rounded-xl border border-border/70 p-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="manual-review">{t("system.fields.manualReview", { ns: "config" })}</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("system.hints.manualReview", { ns: "config" })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
id="manual-review"
|
||||
checked={draft.requireManualReview}
|
||||
onCheckedChange={(checked) => updateDraft("requireManualReview", checked === true)}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
<Label htmlFor="manual-review" className="text-sm font-medium">
|
||||
{draft.requireManualReview
|
||||
? t("system.states.enabled", { ns: "config" })
|
||||
: t("system.states.disabled", { ns: "config" })}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-xl border border-border/70 p-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="auto-settlement">{t("system.fields.autoSettlement", { ns: "config" })}</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("system.hints.autoSettlement", { ns: "config" })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
id="auto-settlement"
|
||||
checked={draft.autoSettlement}
|
||||
onCheckedChange={(checked) => updateDraft("autoSettlement", checked === true)}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
<Label htmlFor="auto-settlement" className="text-sm font-medium">
|
||||
{draft.autoSettlement
|
||||
? t("system.states.enabled", { ns: "config" })
|
||||
: t("system.states.disabled", { ns: "config" })}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cooldown-minutes">{t("system.fields.cooldownMinutes", { ns: "config" })}</Label>
|
||||
<Input
|
||||
id="cooldown-minutes"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={draft.cooldownMinutes}
|
||||
onChange={(e) => updateDraft("cooldownMinutes", e.target.value)}
|
||||
disabled={loading || saving}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("system.hints.cooldownMinutes", { ns: "config" })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Button onClick={() => void handleSave()} disabled={!dirty || loading || saving}>
|
||||
{saving ? t("saving", { ns: "adminUsers" }) : t("actions.save", { ns: "adminUsers" })}
|
||||
</Button>
|
||||
{dirty && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setDraft(saved);
|
||||
setDirty(false);
|
||||
}}
|
||||
>
|
||||
{t("system.discard", { ns: "config" })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<WalletConfigDocScreen />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
9
src/modules/settlement/invalid-settlement-batch-id.tsx
Normal file
9
src/modules/settlement/invalid-settlement-batch-id.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function InvalidSettlementBatchId(): React.ReactElement {
|
||||
const { t } = useTranslation("settlement");
|
||||
|
||||
return <p className="text-destructive text-sm">{t("invalidBatchId")}</p>;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
export const settlementModuleMeta = {
|
||||
title: "结算",
|
||||
title: "结算批次",
|
||||
description: "",
|
||||
} as const;
|
||||
|
||||
@@ -53,6 +53,19 @@ type Props = {
|
||||
|
||||
type SettlementAction = "approve" | "reject" | "payout";
|
||||
|
||||
function settlementStatusText(value: string, t: (key: string) => string): string {
|
||||
const key = `statusOptions.${value}`;
|
||||
const translated = t(key);
|
||||
return translated === key ? value : translated;
|
||||
}
|
||||
|
||||
function settlementReviewStatusText(value: string | null, t: (key: string) => string): string {
|
||||
if (!value) return "—";
|
||||
const key = `reviewStatusOptions.${value}`;
|
||||
const translated = t(key);
|
||||
return translated === key ? value : translated;
|
||||
}
|
||||
|
||||
export function SettlementBatchDetailsConsole({ batchId }: Props) {
|
||||
const { t } = useTranslation(["settlement", "common"]);
|
||||
const profile = useAdminProfile();
|
||||
@@ -195,11 +208,11 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
|
||||
<CardContent className="grid gap-2 text-sm sm:grid-cols-2">
|
||||
<p>
|
||||
<span className="text-muted-foreground">{t("settlementStatus")}</span>{" "}
|
||||
<span className="font-mono">{summary.status}</span>
|
||||
<span className="font-mono">{settlementStatusText(summary.status, t)}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">{t("reviewState")}</span>{" "}
|
||||
<span className="font-mono">{summary.review_status ?? "—"}</span>
|
||||
<span className="font-mono">{settlementReviewStatusText(summary.review_status, t)}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">{t("ticketTotal")}</span>{" "}
|
||||
|
||||
@@ -74,6 +74,13 @@ function settlementStatusText(value: string, t: (key: string) => string): string
|
||||
return option ? t(option.label) : value;
|
||||
}
|
||||
|
||||
function settlementReviewStatusText(value: string | null, t: (key: string) => string): string {
|
||||
if (!value) return "—";
|
||||
const key = `reviewStatusOptions.${value}`;
|
||||
const translated = t(key);
|
||||
return translated === key ? value : translated;
|
||||
}
|
||||
|
||||
export function SettlementBatchesConsole() {
|
||||
const { t } = useTranslation(["settlement", "common"]);
|
||||
const profile = useAdminProfile();
|
||||
@@ -228,7 +235,7 @@ export function SettlementBatchesConsole() {
|
||||
<Table id="settlement-batches-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>{t("table.id", { ns: "common" })}</TableHead>
|
||||
<TableHead>{t("drawNo")}</TableHead>
|
||||
<TableHead className="text-right">{t("totalBet")}</TableHead>
|
||||
<TableHead className="text-right">{t("actualDeduct")}</TableHead>
|
||||
@@ -261,7 +268,9 @@ export function SettlementBatchesConsole() {
|
||||
>
|
||||
{formatAdminMinorUnits(row.platform_profit)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{row.review_status ?? "—"}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{settlementReviewStatusText(row.review_status, t)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
className={cn(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const ticketsModuleMeta = {
|
||||
segment: "tickets",
|
||||
title: "Tickets",
|
||||
title: "注单列表",
|
||||
description: "",
|
||||
} as const;
|
||||
|
||||
@@ -3,11 +3,19 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getAdminPlayerTicketItems } from "@/api/admin-player-tickets";
|
||||
import { getAdminTicketItems } from "@/api/admin-tickets";
|
||||
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";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
@@ -18,33 +26,99 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { AdminPlayerTicketItemsData } from "@/types/api/admin-player-tickets";
|
||||
import type { AdminTicketItemsData } from "@/types/api/admin-tickets";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
const TICKET_STATUS_OPTIONS = [
|
||||
"pending_confirm",
|
||||
"partial_pending_confirm",
|
||||
"success",
|
||||
"failed",
|
||||
"pending_payout",
|
||||
"settled_win",
|
||||
"settled_lose",
|
||||
] as const;
|
||||
|
||||
type TicketFilters = {
|
||||
playerQuery: string;
|
||||
drawNo: string;
|
||||
numberKeyword: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
statuses: string[];
|
||||
};
|
||||
|
||||
const emptyTicketFilters: TicketFilters = {
|
||||
playerQuery: "",
|
||||
drawNo: "",
|
||||
numberKeyword: "",
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
statuses: [],
|
||||
};
|
||||
|
||||
function ticketStatusText(value: string, t: (key: string) => string): string {
|
||||
const key = `statusOptions.${value}`;
|
||||
const translated = t(key);
|
||||
return translated === key ? value : translated;
|
||||
}
|
||||
|
||||
function ticketStatusSummary(statuses: string[], t: (key: string) => string): string {
|
||||
if (statuses.length === 0) {
|
||||
return t("statusOptions.all");
|
||||
}
|
||||
|
||||
if (statuses.length === 1) {
|
||||
return ticketStatusText(statuses[0], t);
|
||||
}
|
||||
|
||||
return t("statusSelectedCount", { count: statuses.length, defaultValue: `已选 ${statuses.length} 项` });
|
||||
}
|
||||
|
||||
function ticketStatusVariant(
|
||||
value: string,
|
||||
): "default" | "secondary" | "destructive" | "outline" {
|
||||
if (value === "settled_win") return "secondary";
|
||||
if (value === "failed") return "destructive";
|
||||
if (value === "pending_payout") return "default";
|
||||
return "outline";
|
||||
}
|
||||
|
||||
export function PlayerTicketsConsole(): React.ReactElement {
|
||||
const { t } = useTranslation(["tickets", "common"]);
|
||||
const [playerIdDraft, setPlayerIdDraft] = useState("");
|
||||
const [drawNoDraft, setDrawNoDraft] = useState("");
|
||||
const [playerId, setPlayerId] = useState<number | null>(null);
|
||||
const [drawNo, setDrawNo] = useState("");
|
||||
const [data, setData] = useState<AdminPlayerTicketItemsData | null>(null);
|
||||
const formatTs = useAdminDateTimeFormatter();
|
||||
const [draft, setDraft] = useState<TicketFilters>(emptyTicketFilters);
|
||||
const [applied, setApplied] = useState<TicketFilters>(emptyTicketFilters);
|
||||
const [data, setData] = useState<AdminTicketItemsData | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(20);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (playerId == null || playerId < 1) {
|
||||
setData(null);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const d = await getAdminPlayerTicketItems(playerId, {
|
||||
const playerQuery = applied.playerQuery.trim();
|
||||
const playerId = Number(playerQuery);
|
||||
const query =
|
||||
playerQuery === ""
|
||||
? {}
|
||||
: Number.isInteger(playerId) && playerId > 0 && String(playerId) === playerQuery
|
||||
? { player_id: playerId }
|
||||
: { player_account: playerQuery };
|
||||
|
||||
const d = await getAdminTicketItems({
|
||||
page,
|
||||
per_page: perPage,
|
||||
draw_no: drawNo.trim() || undefined,
|
||||
...query,
|
||||
draw_no: applied.drawNo.trim() || undefined,
|
||||
status: applied.statuses.length > 0 ? applied.statuses : undefined,
|
||||
number: applied.numberKeyword.trim() || undefined,
|
||||
start_date: applied.startDate || undefined,
|
||||
end_date: applied.endDate || undefined,
|
||||
});
|
||||
setData(d);
|
||||
} catch (e) {
|
||||
@@ -53,7 +127,7 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [playerId, page, perPage, drawNo, t]);
|
||||
}, [applied, page, perPage, t]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
@@ -62,108 +136,227 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
||||
}, [load]);
|
||||
|
||||
const runSearch = () => {
|
||||
const id = Number(playerIdDraft.trim());
|
||||
if (Number.isNaN(id) || id < 1) {
|
||||
setErr(t("invalidPlayerId"));
|
||||
setPlayerId(null);
|
||||
setData(null);
|
||||
return;
|
||||
}
|
||||
setErr(null);
|
||||
setPlayerId(id);
|
||||
setDrawNo(drawNoDraft.trim());
|
||||
setApplied({
|
||||
...draft,
|
||||
playerQuery: draft.playerQuery.trim(),
|
||||
drawNo: draft.drawNo.trim(),
|
||||
numberKeyword: draft.numberKeyword.trim(),
|
||||
});
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setDraft(emptyTicketFilters);
|
||||
setApplied(emptyTicketFilters);
|
||||
setErr(null);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const toggleStatus = (status: string, checked: boolean) => {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
statuses: checked
|
||||
? [...current.statuses, status]
|
||||
: current.statuses.filter((item) => item !== status),
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<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="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>
|
||||
<div className="grid gap-3 lg:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="pt-player">{t("playerId")}</Label>
|
||||
<Input
|
||||
id="pt-player"
|
||||
inputMode="numeric"
|
||||
className="w-full font-mono sm:w-36"
|
||||
placeholder="players.id"
|
||||
value={playerIdDraft}
|
||||
onChange={(e) => setPlayerIdDraft(e.target.value)}
|
||||
className="font-mono"
|
||||
placeholder={t("playerIdPlaceholder")}
|
||||
value={draft.playerQuery}
|
||||
onChange={(e) =>
|
||||
setDraft((current) => ({ ...current, playerQuery: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-list-field xl:min-w-0">
|
||||
<Label htmlFor="pt-draw" className="sm:w-20 sm:shrink-0">{t("drawNoOptional")}</Label>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="pt-draw">{t("drawNoOptional")}</Label>
|
||||
<Input
|
||||
id="pt-draw"
|
||||
className="w-full font-mono text-sm sm:w-[16rem] xl:w-[20rem]"
|
||||
className="font-mono text-sm"
|
||||
placeholder={t("drawNoPlaceholder")}
|
||||
value={drawNoDraft}
|
||||
onChange={(e) => setDrawNoDraft(e.target.value)}
|
||||
value={draft.drawNo}
|
||||
onChange={(e) => setDraft((current) => ({ ...current, drawNo: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-list-actions">
|
||||
<AdminTableExportButton
|
||||
tableId="player-tickets-table"
|
||||
filename="玩家注单"
|
||||
sheetName="玩家注单"
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="pt-number">{t("numberKeyword")}</Label>
|
||||
<Input
|
||||
id="pt-number"
|
||||
className="font-mono text-sm"
|
||||
placeholder={t("numberKeywordPlaceholder")}
|
||||
value={draft.numberKeyword}
|
||||
onChange={(e) =>
|
||||
setDraft((current) => ({ ...current, numberKeyword: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<AdminDateRangeField
|
||||
id="pt-date-range"
|
||||
label={t("placedDateRange")}
|
||||
from={draft.startDate}
|
||||
to={draft.endDate}
|
||||
onRangeChange={(range) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
startDate: range.from,
|
||||
endDate: range.to,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<Button type="button" onClick={() => runSearch()}>
|
||||
{t("query")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-sm font-medium leading-none">{t("statusFilterLabel")}</span>
|
||||
<span className="text-muted-foreground text-xs">{t("statusHint")}</span>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="inline-flex h-11 w-full items-center justify-between rounded-md border border-border bg-card px-4 text-left text-sm font-normal text-primary shadow-sm outline-none transition-all hover:bg-accent hover:text-primary focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50">
|
||||
<span className="truncate">{ticketStatusSummary(draft.statuses, t)}</span>
|
||||
<ChevronDown className="size-4 shrink-0 opacity-60" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-[min(28rem,calc(100vw-2rem))]">
|
||||
{TICKET_STATUS_OPTIONS.map((status) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={status}
|
||||
checked={draft.statuses.includes(status)}
|
||||
onCheckedChange={(checked) => toggleStatus(status, checked === true)}
|
||||
>
|
||||
{ticketStatusText(status, t)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<AdminTableExportButton
|
||||
tableId="tickets-table"
|
||||
filename="注单列表"
|
||||
sheetName="注单列表"
|
||||
/>
|
||||
<Button type="button" size="sm" onClick={() => runSearch()}>
|
||||
{t("query")}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => resetFilters()}>
|
||||
{t("resetFilters")}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => void load()}>
|
||||
{t("refreshCurrentPage")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{applied.playerQuery || applied.drawNo || applied.numberKeyword || applied.startDate || applied.endDate || applied.statuses.length > 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{applied.playerQuery ? (
|
||||
<>
|
||||
{t("playerId")}:<span className="font-mono">{applied.playerQuery}</span>
|
||||
</>
|
||||
) : (
|
||||
<span>{t("allTickets", { defaultValue: "全部注单" })}</span>
|
||||
)}
|
||||
{applied.drawNo ? (
|
||||
<>
|
||||
{" · "}
|
||||
{t("drawNo")}:<span className="font-mono">{applied.drawNo}</span>
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{err ? <p className="text-sm text-destructive">{err}</p> : null}
|
||||
{loading && playerId != null ? (
|
||||
{loading ? (
|
||||
<p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p>
|
||||
) : null}
|
||||
|
||||
{data ? (
|
||||
<>
|
||||
<div className="admin-table-shell">
|
||||
<Table id="player-tickets-table">
|
||||
<Table id="tickets-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("ticketNo")}</TableHead>
|
||||
<TableHead>{t("player")}</TableHead>
|
||||
<TableHead>{t("orderNo")}</TableHead>
|
||||
<TableHead>{t("drawNo")}</TableHead>
|
||||
<TableHead>{t("playCode")}</TableHead>
|
||||
<TableHead>{t("number")}</TableHead>
|
||||
<TableHead className="text-right">{t("betAmount")}</TableHead>
|
||||
<TableHead className="text-right">{t("actualDeduct")}</TableHead>
|
||||
<TableHead>{t("status")}</TableHead>
|
||||
<TableHead>{t("failReason")}</TableHead>
|
||||
<TableHead className="text-right">{t("winAmount")}</TableHead>
|
||||
<TableHead>{t("placedAt")}</TableHead>
|
||||
<TableHead>{t("updatedAt")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} className="text-muted-foreground">
|
||||
<TableCell colSpan={13} className="text-muted-foreground">
|
||||
{t("states.noData", { ns: "common" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
data.items.map((row) => (
|
||||
<TableRow key={row.ticket_no}>
|
||||
<TableCell className="font-mono text-xs">{row.ticket_no}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{row.order_no ?? "—"}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{row.draw_no ?? "—"}</TableCell>
|
||||
<TableCell className="text-xs">{row.play_code}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{row.original_number ?? "—"}</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-xs">
|
||||
{row.actual_deduct_amount}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">{row.status}</TableCell>
|
||||
<TableCell className="max-w-[14rem] text-xs text-muted-foreground">
|
||||
{row.fail_reason_text ?? row.fail_reason_code ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-xs">
|
||||
{row.win_amount + row.jackpot_win_amount}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
data.items.map((row) => {
|
||||
const winLabel = row.jackpot_win_amount > 0
|
||||
? `${row.win_amount_formatted} + ${row.jackpot_win_amount_formatted}`
|
||||
: row.win_amount_formatted;
|
||||
|
||||
return (
|
||||
<TableRow key={row.ticket_no}>
|
||||
<TableCell className="font-mono text-xs">{row.ticket_no}</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
<div className="flex flex-col leading-tight">
|
||||
<span className="font-medium">
|
||||
{row.nickname ?? row.username ?? "—"}
|
||||
</span>
|
||||
<span className="font-mono text-[11px] text-muted-foreground">
|
||||
{row.site_code && row.site_player_id
|
||||
? `${row.site_code} / ${row.site_player_id}`
|
||||
: row.site_player_id ?? `#${row.player_id}`}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">{row.order_no ?? "—"}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{row.draw_no ?? "—"}</TableCell>
|
||||
<TableCell className="text-xs">{row.play_code}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{row.original_number ?? "—"}</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-xs">
|
||||
{row.total_bet_amount_formatted}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-xs">
|
||||
{row.actual_deduct_amount_formatted}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
<Badge variant={ticketStatusVariant(row.status)}>
|
||||
{ticketStatusText(row.status, t)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[14rem] text-xs text-muted-foreground">
|
||||
{row.fail_reason_text ?? row.fail_reason_code ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-xs">{winLabel}</TableCell>
|
||||
<TableCell className="text-xs">{formatTs(row.placed_at)}</TableCell>
|
||||
<TableCell className="text-xs">{formatTs(row.updated_at)}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const walletModuleMeta = {
|
||||
segment: "wallet",
|
||||
title: "Wallet",
|
||||
title: "钱包管理",
|
||||
description: "",
|
||||
} as const;
|
||||
|
||||
Reference in New Issue
Block a user