feat(ui): enhance table and admin components with improved layout and status display

- Updated global CSS to center-align table headers and cells, ensuring a consistent layout.
- Modified admin table components to replace switches with status badges for better clarity.
- Enhanced internationalization support by adding new strings for version actions and validation messages in multiple locales.
- Refactored configuration document screens to include version selection and improved user feedback on status changes.
This commit is contained in:
2026-05-26 11:13:16 +08:00
parent 05fa0cbeec
commit 4080f0b601
38 changed files with 788 additions and 608 deletions

View File

@@ -175,13 +175,36 @@
@apply text-sm text-muted-foreground; @apply text-sm text-muted-foreground;
} }
[data-slot="table-cell"]:has(> [data-slot="button"]), [data-slot="table-head"],
[data-slot="table-cell"]:has(> a) { [data-slot="table-cell"] {
text-align: center; text-align: center;
} }
[data-slot="table-cell"] > .flex:has([data-slot="button"]), [data-slot="table-cell"] > .flex,
[data-slot="table-cell"] > .flex:has(a) { [data-slot="table-head"] > .flex {
justify-content: center; justify-content: center !important;
}
[data-slot="table-cell"] > .flex.flex-col {
align-items: center !important;
}
[data-slot="table-cell"] > .flex.flex-wrap {
justify-content: center !important;
}
[data-slot="table-cell"] [data-slot="badge"],
[data-slot="table-cell"] [data-slot="switch"],
[data-slot="table-cell"] [role="checkbox"],
[data-slot="table-cell"] [data-slot="button"],
[data-slot="table-cell"] > a {
margin-inline: auto;
}
[data-slot="table-cell"]:has(> [data-slot="button"]),
[data-slot="table-cell"]:has(> a),
[data-slot="table-cell"]:has(> [role="checkbox"]),
[data-slot="table-cell"]:has(> [data-slot="switch"]) {
text-align: center;
} }
} }

View File

@@ -15,7 +15,10 @@ const OMIT_HEADER_TOKENS = [
"download", "download",
] as const; ] as const;
function shouldOmitColumn(headerText: string): boolean { function shouldOmitColumn(cell: Element, headerText: string): boolean {
if (cell.hasAttribute("data-export-ignore")) {
return true;
}
const normalized = headerText.trim().toLowerCase(); const normalized = headerText.trim().toLowerCase();
if (normalized === "") { if (normalized === "") {
return true; return true;
@@ -33,9 +36,10 @@ function stripOmittedColumns(table: HTMLTableElement): HTMLTableElement {
const omitIndexes = Array.from(headerRow.children) const omitIndexes = Array.from(headerRow.children)
.map((cell, index) => ({ .map((cell, index) => ({
index, index,
cell,
text: cell.textContent ?? "", text: cell.textContent ?? "",
})) }))
.filter((item) => shouldOmitColumn(item.text)) .filter((item) => shouldOmitColumn(item.cell, item.text))
.map((item) => item.index) .map((item) => item.index)
.sort((a, b) => b - a); .sort((a, b) => b - a);

View File

@@ -0,0 +1,32 @@
"use client";
import { Switch } from "@/components/ui/switch";
export type ConfirmableSwitchProps = {
checked: boolean;
disabled?: boolean;
confirmBusy?: boolean;
"aria-label": string;
onCheckedChange: (checked: boolean) => void;
};
/**
* Controlled switch for flows that confirm before applying state.
* Disables interaction while a confirm dialog is in progress.
*/
export function ConfirmableSwitch({
checked,
disabled,
confirmBusy,
"aria-label": ariaLabel,
onCheckedChange,
}: ConfirmableSwitchProps) {
return (
<Switch
checked={checked}
disabled={disabled || confirmBusy}
aria-label={ariaLabel}
onCheckedChange={onCheckedChange}
/>
);
}

View File

@@ -70,7 +70,7 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
<th <th
data-slot="table-head" data-slot="table-head"
className={cn( className={cn(
"h-11 px-3 text-left align-middle font-semibold tracking-wide whitespace-nowrap text-[#17305f] [&:has([role=checkbox])]:pr-0", "h-11 px-3 text-center align-middle font-semibold tracking-wide whitespace-nowrap text-[#17305f] [&:has([role=checkbox])]:pr-0",
className className
)} )}
{...props} {...props}
@@ -83,7 +83,7 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
<td <td
data-slot="table-cell" data-slot="table-cell"
className={cn( className={cn(
"px-3 py-2.5 text-left align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0", "px-3 py-2.5 text-center align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className className
)} )}
{...props} {...props}

View File

@@ -47,6 +47,7 @@
"effectiveAt": "Effective at: {{value}}", "effectiveAt": "Effective at: {{value}}",
"note": "Note: {{value}}", "note": "Note: {{value}}",
"current": "Current", "current": "Current",
"moreActions": "More actions for v{{version}}",
"selected": "Selected", "selected": "Selected",
"view": "View", "view": "View",
"rollback": "Rollback", "rollback": "Rollback",
@@ -54,8 +55,11 @@
"deleteConfirmTitle": "Delete this version?", "deleteConfirmTitle": "Delete this version?",
"deleteConfirmDescription": "Version ID {{id}} (version_no {{version}}) will be permanently deleted. Active versions cannot be deleted." "deleteConfirmDescription": "Version ID {{id}} (version_no {{version}}) will be permanently deleted. Active versions cannot be deleted."
}, },
"versionToolbar": {
"draftEditing": "Editing a draft — save and publish to go live"
},
"versionActions": { "versionActions": {
"publishCurrent": "Set as current version", "publishCurrent": "Publish",
"refreshing": "Refreshing", "refreshing": "Refreshing",
"refresh": "Refresh versions", "refresh": "Refresh versions",
"newDraft": "New draft", "newDraft": "New draft",
@@ -194,7 +198,8 @@
"batchSwitchEnable": "Enable", "batchSwitchEnable": "Enable",
"batchSwitchDisable": "Disable", "batchSwitchDisable": "Disable",
"toggleConfirmTitle": "{{action}} play {{playCode}}?", "toggleConfirmTitle": "{{action}} play {{playCode}}?",
"toggleConfirmDescription": "This calls the API immediately (not draft-only).", "toggleConfirmDescription": "This updates the current draft only. Players see changes after save and publish.",
"batchPartialEnabled": "{{enabledCount}}/{{total}} enabled (not all on — turn on to enable all)",
"toggleEnable": "Enable", "toggleEnable": "Enable",
"toggleDisable": "Disable", "toggleDisable": "Disable",
"toggleInstantFailed": "Failed to apply play switch. Try again later.", "toggleInstantFailed": "Failed to apply play switch. Try again later.",

View File

@@ -14,6 +14,7 @@
"exportHint": "Once export APIs are connected, the current filters will generate the selected file format.", "exportHint": "Once export APIs are connected, the current filters will generate the selected file format.",
"validation": { "validation": {
"drawNoRequired": "Please enter a draw number", "drawNoRequired": "Please enter a draw number",
"drawNoNotFound": "Draw number «{{drawNo}}» was not found",
"drawNoNumberRequired": "Please enter both draw number and number" "drawNoNumberRequired": "Please enter both draw number and number"
}, },
"formats": { "formats": {

View File

@@ -47,6 +47,7 @@
"effectiveAt": "लागू समय: {{value}}", "effectiveAt": "लागू समय: {{value}}",
"note": "टिप्पणी: {{value}}", "note": "टिप्पणी: {{value}}",
"current": "हाल हेर्दै", "current": "हाल हेर्दै",
"moreActions": "v{{version}} थप कार्य",
"selected": "छानिएको", "selected": "छानिएको",
"view": "हेर्नुहोस्", "view": "हेर्नुहोस्",
"rollback": "रोलब्याक", "rollback": "रोलब्याक",
@@ -54,8 +55,11 @@
"deleteConfirmTitle": "यो संस्करण मेटाउने?", "deleteConfirmTitle": "यो संस्करण मेटाउने?",
"deleteConfirmDescription": "संस्करण ID {{id}} (version_no {{version}}) स्थायी रूपमा मेटाइनेछ। सक्रिय संस्करण मेटाउन मिल्दैन।" "deleteConfirmDescription": "संस्करण ID {{id}} (version_no {{version}}) स्थायी रूपमा मेटाइनेछ। सक्रिय संस्करण मेटाउन मिल्दैन।"
}, },
"versionToolbar": {
"draftEditing": "ड्राफ्ट सम्पादन — सेभ र प्रकाशित गरेपछि लाइभ हुन्छ"
},
"versionActions": { "versionActions": {
"publishCurrent": "हालको संस्करण बनाउनुहोस्", "publishCurrent": "प्रकाशित गर्नुहोस्",
"refreshing": "रिफ्रेस हुँदैछ", "refreshing": "रिफ्रेस हुँदैछ",
"refresh": "संस्करण रिफ्रेस", "refresh": "संस्करण रिफ्रेस",
"newDraft": "नयाँ ड्राफ्ट", "newDraft": "नयाँ ड्राफ्ट",
@@ -194,7 +198,8 @@
"batchSwitchEnable": "सक्रिय", "batchSwitchEnable": "सक्रिय",
"batchSwitchDisable": "निष्क्रिय", "batchSwitchDisable": "निष्क्रिय",
"toggleConfirmTitle": "खेल {{playCode}} {{action}} गर्ने?", "toggleConfirmTitle": "खेल {{playCode}} {{action}} गर्ने?",
"toggleConfirmDescription": "यो तुरुन्त API मार्फत लागू हुन्छ (केवल ड्राफ्ट मात्र होइन)।", "toggleConfirmDescription": "यो हालको ड्राफ्ट मात्र अपडेट गर्छ। सेभ र प्रकाशित गरेपछि खेलाडीलाई देखिन्छ।",
"batchPartialEnabled": "{{enabledCount}}/{{total}} सक्रिय (सबै खुला छैन — अन गर्दा सबै सक्रिय हुन्छ)",
"toggleEnable": "सक्रिय", "toggleEnable": "सक्रिय",
"toggleDisable": "निष्क्रिय", "toggleDisable": "निष्क्रिय",
"toggleInstantFailed": "खेल स्विच तुरुन्त लागू गर्न असफल। पछि पुनः प्रयास गर्नुहोस्।", "toggleInstantFailed": "खेल स्विच तुरुन्त लागू गर्न असफल। पछि पुनः प्रयास गर्नुहोस्।",

View File

@@ -14,6 +14,7 @@
"exportHint": "निर्यात API जोडिएपछि हालका फिल्टरअनुसार छानिएको फाइल ढाँचा बनाइनेछ।", "exportHint": "निर्यात API जोडिएपछि हालका फिल्टरअनुसार छानिएको फाइल ढाँचा बनाइनेछ।",
"validation": { "validation": {
"drawNoRequired": "कृपया ड्र नं. प्रविष्ट गर्नुहोस्", "drawNoRequired": "कृपया ड्र नं. प्रविष्ट गर्नुहोस्",
"drawNoNotFound": "ड्र नं. «{{drawNo}}» फेला परेन",
"drawNoNumberRequired": "कृपया ड्र नं. र नम्बर दुवै प्रविष्ट गर्नुहोस्" "drawNoNumberRequired": "कृपया ड्र नं. र नम्बर दुवै प्रविष्ट गर्नुहोस्"
}, },
"formats": { "formats": {

View File

@@ -47,6 +47,7 @@
"effectiveAt": "生效时间:{{value}}", "effectiveAt": "生效时间:{{value}}",
"note": "备注:{{value}}", "note": "备注:{{value}}",
"current": "当前查看", "current": "当前查看",
"moreActions": "版本 v{{version}} 更多操作",
"selected": "已选中", "selected": "已选中",
"view": "查看", "view": "查看",
"rollback": "回滚", "rollback": "回滚",
@@ -54,8 +55,11 @@
"deleteConfirmTitle": "确认删除版本?", "deleteConfirmTitle": "确认删除版本?",
"deleteConfirmDescription": "将永久删除版本 ID {{id}}version_no {{version}})。生效中的版本不可删除。" "deleteConfirmDescription": "将永久删除版本 ID {{id}}version_no {{version}})。生效中的版本不可删除。"
}, },
"versionToolbar": {
"draftEditing": "正在编辑草稿,保存并发布后生效"
},
"versionActions": { "versionActions": {
"publishCurrent": "启用为当前版本", "publishCurrent": "发布生效",
"refreshing": "刷新中", "refreshing": "刷新中",
"refresh": "刷新版本", "refresh": "刷新版本",
"newDraft": "新建草稿", "newDraft": "新建草稿",
@@ -194,7 +198,8 @@
"batchSwitchEnable": "开启", "batchSwitchEnable": "开启",
"batchSwitchDisable": "关闭", "batchSwitchDisable": "关闭",
"toggleConfirmTitle": "确认{{action}}玩法 {{playCode}}", "toggleConfirmTitle": "确认{{action}}玩法 {{playCode}}",
"toggleConfirmDescription": "将立即调用接口生效(不仅限于草稿)。", "toggleConfirmDescription": "将写入当前草稿;保存并发布后才会影响玩家端。",
"batchPartialEnabled": "{{enabledCount}}/{{total}} 已开启(未全开,打开开关将全部开启)",
"toggleEnable": "开启", "toggleEnable": "开启",
"toggleDisable": "关闭", "toggleDisable": "关闭",
"toggleInstantFailed": "玩法开关即时生效失败,请稍后重试", "toggleInstantFailed": "玩法开关即时生效失败,请稍后重试",

View File

@@ -14,6 +14,7 @@
"exportHint": "接入导出接口后,会按当前条件生成对应格式的文件。", "exportHint": "接入导出接口后,会按当前条件生成对应格式的文件。",
"validation": { "validation": {
"drawNoRequired": "请输入期号", "drawNoRequired": "请输入期号",
"drawNoNotFound": "未找到期号「{{drawNo}}」",
"drawNoNumberRequired": "请输入期号和号码" "drawNoNumberRequired": "请输入期号和号码"
}, },
"formats": { "formats": {

View File

@@ -15,7 +15,9 @@ import {
putAdminRole, putAdminRole,
putAdminRolePermissions, putAdminRolePermissions,
} from "@/api/admin-users"; } from "@/api/admin-users";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { resolveRoleStatusTone } from "@/lib/admin-status-tone";
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button"; import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
@@ -369,13 +371,9 @@ export function AdminRolesConsole(): React.ReactElement {
)} )}
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex justify-center"> <AdminStatusBadge status={role.status} tone={resolveRoleStatusTone(role.status)}>
<Switch {role.status === 1 ? t("status.enabled") : t("status.disabled")}
checked={role.status === 1} </AdminStatusBadge>
disabled
aria-label={t("roleDialog.status")}
/>
</div>
</TableCell> </TableCell>
<TableCell className="tabular-nums">{role.user_count}</TableCell> <TableCell className="tabular-nums">{role.user_count}</TableCell>
<TableCell className="tabular-nums">{role.permission_slugs.length}</TableCell> <TableCell className="tabular-nums">{role.permission_slugs.length}</TableCell>

View File

@@ -16,7 +16,9 @@ import {
} from "@/api/admin-users"; } from "@/api/admin-users";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer"; import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button"; import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { resolveAdminUserStatusTone } from "@/lib/admin-status-tone";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -391,13 +393,9 @@ export function AdminUsersConsole(): React.ReactElement {
</TableCell> </TableCell>
<TableCell>{row.nickname ?? ""}</TableCell> <TableCell>{row.nickname ?? ""}</TableCell>
<TableCell> <TableCell>
<div className="flex justify-center"> <AdminStatusBadge status={row.status} tone={resolveAdminUserStatusTone(row.status)}>
<Switch {row.status === 0 ? t("status.enabled") : t("status.disabled")}
checked={row.status === 0} </AdminStatusBadge>
disabled
aria-label={t("table.status")}
/>
</div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">

View File

@@ -25,16 +25,17 @@ type ConfigChipProps = {
onClick: () => void; onClick: () => void;
children: ReactNode; children: ReactNode;
disabled?: boolean; disabled?: boolean;
className?: string;
}; };
export function ConfigChip({ active, onClick, children, disabled }: ConfigChipProps) { export function ConfigChip({ active, onClick, children, disabled, className }: ConfigChipProps) {
return ( return (
<Button <Button
type="button" type="button"
variant={active ? "default" : "outline"} variant={active ? "default" : "outline"}
size="sm" size="sm"
disabled={disabled} disabled={disabled}
className={cn("h-9 rounded-full px-4 text-sm font-medium", active && "shadow-sm")} className={cn("h-8 rounded-full px-3 text-sm font-medium", active && "shadow-sm", className)}
onClick={onClick} onClick={onClick}
> >
{children} {children}

View File

@@ -12,6 +12,7 @@ type ConfigDocPageProps = {
/** Category / play-type chips etc., rendered above the version toolbar. */ /** Category / play-type chips etc., rendered above the version toolbar. */
filters?: ReactNode; filters?: ReactNode;
toolbar?: ReactNode; toolbar?: ReactNode;
/** @deprecated Pass `footer` on `ConfigDocToolbar` instead. */
context?: ReactNode; context?: ReactNode;
children: ReactNode; children: ReactNode;
className?: string; className?: string;
@@ -21,23 +22,29 @@ type ConfigDocPageProps = {
export function ConfigDocToolbar({ export function ConfigDocToolbar({
switcher, switcher,
actions, actions,
footer,
className, className,
}: { }: {
switcher: ReactNode; switcher: ReactNode;
actions: ReactNode; actions: ReactNode;
/** Live version / read-only hint — rendered as a subtle line under the main row. */
footer?: ReactNode;
className?: string; className?: string;
}) { }) {
return ( return (
<div <div
className={cn( className={cn(
"rounded-xl border border-border/60 bg-secondary/50 p-4 shadow-sm", "overflow-hidden rounded-xl border border-border/60 bg-card",
className, className,
)} )}
> >
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"> <div className="flex flex-col gap-3 p-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4 sm:p-4">
<div className="min-w-0 flex-1">{switcher}</div> <div className="min-w-0 flex-1">{switcher}</div>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">{actions}</div> <div className="flex shrink-0 flex-wrap items-center gap-1.5 sm:justify-end">{actions}</div>
</div> </div>
{footer ? (
<div className="border-t border-border/50 bg-muted/20 px-3 py-2.5 sm:px-4">{footer}</div>
) : null}
</div> </div>
); );
} }
@@ -65,8 +72,8 @@ export function ConfigDocPage({
{description ? <CardDescription className="text-base">{description}</CardDescription> : null} {description ? <CardDescription className="text-base">{description}</CardDescription> : null}
</CardHeader> </CardHeader>
<CardContent className={cn("space-y-6 pt-6", contentClassName)}> <CardContent className={cn("space-y-6 pt-6", contentClassName)}>
{filters}
{toolbar} {toolbar}
{filters}
{context} {context}
{children} {children}
</CardContent> </CardContent>

View File

@@ -39,32 +39,44 @@ export function ConfigVersionActions({
const resolvedPublishLabel = publishLabel ?? t("versionActions.publishCurrent"); const resolvedPublishLabel = publishLabel ?? t("versionActions.publishCurrent");
return ( return (
<div className={cn("flex flex-wrap items-center gap-2", className)}> <div className={cn("flex flex-wrap items-center gap-1", className)}>
<Button type="button" variant="outline" disabled={loadingList} onClick={onRefresh}> <Button
<RefreshCw className={loadingList ? "size-4 animate-spin" : "size-4"} aria-hidden /> type="button"
{loadingList ? t("versionActions.refreshing") : t("versionActions.refresh")} variant="ghost"
size="icon-sm"
disabled={loadingList}
onClick={onRefresh}
aria-label={loadingList ? t("versionActions.refreshing") : t("versionActions.refresh")}
title={loadingList ? t("versionActions.refreshing") : t("versionActions.refresh")}
>
<RefreshCw className={cn("size-4", loadingList && "animate-spin")} aria-hidden />
</Button> </Button>
{canManage ? ( {canManage ? (
<Button type="button" disabled={saving} onClick={onNewDraft}>
<Plus className="size-4" aria-hidden />
{t("versionActions.newDraft")}
</Button>
) : null}
{canManage && isDraft ? (
<> <>
<Button <span className="mx-0.5 hidden h-5 w-px bg-border/60 sm:block" aria-hidden />
type="button" <Button type="button" variant="outline" size="sm" disabled={saving} onClick={onNewDraft}>
variant="secondary" <Plus className="size-3.5" aria-hidden />
disabled={draftActionBusy} {t("versionActions.newDraft")}
onClick={onSaveDraft}
>
<Save className="size-4" aria-hidden />
{t("versionActions.saveDraft")}
</Button>
<Button type="button" disabled={draftActionBusy} onClick={onPublish}>
<Rocket className="size-4" aria-hidden />
{resolvedPublishLabel}
</Button> </Button>
{isDraft ? (
<>
<Button
type="button"
variant="outline"
size="sm"
disabled={draftActionBusy}
onClick={onSaveDraft}
>
<Save className="size-3.5" aria-hidden />
{t("versionActions.saveDraft")}
</Button>
<Button type="button" size="sm" disabled={draftActionBusy} onClick={onPublish}>
<Rocket className="size-3.5" aria-hidden />
{resolvedPublishLabel}
</Button>
</>
) : null}
</> </>
) : null} ) : null}
</div> </div>

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Layers } from "lucide-react"; import { Check, ChevronRight, Layers, MoreHorizontal } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -13,6 +13,12 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
@@ -41,6 +47,27 @@ export type ConfigVersionSwitcherProps = {
rollbackBusy?: boolean; rollbackBusy?: boolean;
}; };
function formatVersionNote(
reason: string | null | undefined,
formatDt: (iso: string | null | undefined) => string,
): string | null {
if (!reason?.trim()) {
return null;
}
const trimmed = reason.trim();
const isoMatch = trimmed.match(/\d{4}-\d{2}-\d{2}T[\d:.]+Z?/);
if (trimmed.startsWith("draft") && isoMatch) {
return formatDt(isoMatch[0]);
}
if (trimmed.startsWith("seed:")) {
return trimmed.slice(5);
}
if (trimmed.length > 56) {
return `${trimmed.slice(0, 56)}`;
}
return trimmed;
}
export function ConfigVersionSwitcher({ export function ConfigVersionSwitcher({
versions, versions,
selectedId, selectedId,
@@ -85,14 +112,13 @@ export function ConfigVersionSwitcher({
[selectedId, sortedVersions], [selectedId, sortedVersions],
); );
const statusCounts = useMemo( const visibleSections = useMemo(
() => () =>
STATUS_ORDER.map((status) => ({ STATUS_ORDER.map((status) => ({
status, status,
label: t(`versionStatus.${status}`, { ns: "config" }), rows: groupedVersions.get(status) ?? [],
count: groupedVersions.get(status)?.length ?? 0, })).filter((section) => section.rows.length > 0),
})), [groupedVersions],
[groupedVersions, t],
); );
function switchTo(id: number) { function switchTo(id: number) {
@@ -118,15 +144,14 @@ export function ConfigVersionSwitcher({
return ( return (
<> <>
<div className={cn("flex min-w-0 items-center gap-2", className)}> <div className={cn("flex min-w-0 items-center gap-3", className)}>
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2"> <div className="flex min-w-0 flex-1 items-center gap-2">
{selectedVersion ? ( {selectedVersion ? (
<> <>
<span className="font-mono text-lg font-semibold leading-none tabular-nums text-foreground"> <span className="font-mono text-base font-semibold tabular-nums text-foreground">
v{selectedVersion.version_no} v{selectedVersion.version_no}
</span> </span>
<ConfigStatusBadge status={selectedVersion.status} /> <ConfigStatusBadge status={selectedVersion.status} className="h-5 px-1.5 text-[11px]" />
<span className="text-sm text-muted-foreground">#{selectedVersion.id}</span>
</> </>
) : ( ) : (
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
@@ -138,12 +163,13 @@ export function ConfigVersionSwitcher({
</div> </div>
<Button <Button
type="button" type="button"
variant="outline" variant="ghost"
size="sm"
disabled={loading || sortedVersions.length === 0} disabled={loading || sortedVersions.length === 0}
onClick={() => setSheetOpen(true)} onClick={() => setSheetOpen(true)}
className="shrink-0" className="h-8 shrink-0 gap-1.5 text-muted-foreground hover:text-foreground"
> >
<Layers className="size-4" aria-hidden /> <Layers className="size-3.5" aria-hidden />
{t("versionSwitcher.switch", { ns: "config" })} {t("versionSwitcher.switch", { ns: "config" })}
</Button> </Button>
</div> </div>
@@ -151,172 +177,142 @@ export function ConfigVersionSwitcher({
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}> <Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetContent <SheetContent
side="right" side="right"
className="flex flex-col overflow-hidden border-l bg-background p-0 shadow-xl sm:max-w-[430px]" className="flex w-full flex-col gap-0 overflow-hidden border-l bg-background p-0 sm:max-w-md"
> >
<div className="border-b border-border/60 bg-card px-5 pb-4 pt-5"> <SheetHeader className="space-y-1 border-b border-border/60 px-5 py-4 text-left">
<SheetHeader className="space-y-2 text-left"> <SheetTitle className="text-[15px] font-semibold tracking-tight">
<SheetTitle className="text-base font-semibold tracking-tight text-foreground"> {resolvedSheetTitle}
{resolvedSheetTitle} </SheetTitle>
</SheetTitle> {resolvedSheetDescription ? (
{resolvedSheetDescription ? ( <SheetDescription className="text-[13px] leading-relaxed">
<SheetDescription className="max-w-[320px] text-sm leading-relaxed text-muted-foreground"> {resolvedSheetDescription}
{resolvedSheetDescription} </SheetDescription>
</SheetDescription> ) : null}
) : null} </SheetHeader>
</SheetHeader>
</div> <div className="flex-1 overflow-y-auto px-3 py-3">
<div className="border-b border-border/60 bg-card px-4 py-3">
<div className="flex flex-wrap gap-2">
{statusCounts.map((s) => (
<div
key={s.status}
className="inline-flex items-center gap-2 rounded-full border border-border/60 bg-muted/50 px-3 py-1.5 text-sm text-muted-foreground"
>
<span className="font-medium text-foreground/80">{s.label}</span>
<span className="font-mono tabular-nums">{s.count}</span>
</div>
))}
</div>
</div>
<div className="flex-1 overflow-auto px-4 py-4">
{sortedVersions.length === 0 ? ( {sortedVersions.length === 0 ? (
<div className="rounded-2xl border border-dashed border-border/60 bg-muted/30 p-5 text-center text-sm text-muted-foreground"> <p className="px-2 py-8 text-center text-sm text-muted-foreground">
{t("versionSwitcher.empty", { ns: "config" })} {t("versionSwitcher.empty", { ns: "config" })}
</div> </p>
) : ( ) : (
STATUS_ORDER.map((status) => { <div className="space-y-5">
const rows = groupedVersions.get(status) ?? []; {visibleSections.map((section) => (
if (rows.length === 0) { <section key={section.status}>
return null; <p className="mb-2 px-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
} {t(`versionStatus.${section.status}`, { ns: "config" })}
return ( </p>
<section key={status} className="border-b border-border/60 pb-4 last:border-b-0 last:pb-0"> <ul className="space-y-1">
<div className="mb-2 flex items-center justify-between px-1"> {section.rows.map((v) => {
<div className="flex items-center gap-2.5">
<div
className={cn(
"size-2.5 rounded-full",
status === "draft" && "bg-muted-foreground",
status === "active" && "bg-primary",
status === "archived" && "bg-border",
)}
/>
<p className="text-base font-semibold text-foreground">
{t(`versionStatus.${status}`, { ns: "config" })}
</p>
</div>
<p className="rounded-full bg-muted px-2 py-0.5 text-sm font-medium tabular-nums text-muted-foreground">
{t("versionSwitcher.count", { ns: "config", count: rows.length })}
</p>
</div>
<div className="space-y-1.5">
{rows.map((v) => {
const isCurrent = selectedId === String(v.id); const isCurrent = selectedId === String(v.id);
const note = formatVersionNote(v.reason, formatDt);
const effectiveLabel = v.effective_at
? formatDt(v.effective_at)
: null;
const meta = [effectiveLabel, note].filter(Boolean).join(" · ");
const showMenu =
(onDeleteVersion && v.status !== "active") ||
(onRollbackVersion && v.status !== "draft");
return ( return (
<div <li key={v.id}>
key={v.id}
className={cn(
"group flex gap-3 rounded-xl border border-transparent px-2 py-3 transition-colors hover:bg-muted/30",
isCurrent && "border-border/60 bg-muted/20",
)}
>
<div <div
className={cn( className={cn(
"mt-1 h-auto w-1 shrink-0 rounded-full bg-border", "group flex items-stretch gap-0.5 rounded-lg border transition-colors",
v.status === "draft" && "bg-muted-foreground/60", isCurrent
v.status === "active" && "bg-primary", ? "border-primary/40 bg-primary/[0.04]"
v.status === "archived" && "bg-muted-foreground/30", : "border-transparent hover:border-border/60 hover:bg-muted/30",
)} )}
/> >
<div className="min-w-0 flex-1 space-y-2"> <button
<div className="flex items-start justify-between gap-3"> type="button"
<div className="min-w-0 space-y-1.5"> className="flex min-w-0 flex-1 items-center gap-3 px-3 py-2.5 text-left"
<div className="flex flex-wrap items-center gap-2"> onClick={() => switchTo(v.id)}
<span className="font-mono text-lg font-semibold leading-none tabular-nums text-foreground"> >
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-mono text-[15px] font-semibold tabular-nums text-foreground">
v{v.version_no} v{v.version_no}
</span> </span>
<ConfigStatusBadge status={v.status} /> <ConfigStatusBadge
<span className="rounded-full bg-muted px-2 py-0.5 text-sm font-medium tabular-nums text-muted-foreground"> status={v.status}
#{v.id} className="h-5 px-1.5 text-[11px]"
</span> />
</div> </div>
<p className="line-clamp-2 text-sm leading-relaxed text-muted-foreground"> {meta ? (
{t("versionSwitcher.effectiveAt", { <p className="mt-0.5 truncate text-xs text-muted-foreground">
ns: "config", {meta}
value: v.effective_at ? formatDt(v.effective_at) : "—", </p>
})} ) : null}
{v.reason
? ` · ${t("versionSwitcher.note", {
ns: "config",
value: v.reason,
})}`
: ""}
</p>
</div> </div>
{isCurrent ? ( {isCurrent ? (
<span className="shrink-0 rounded-full bg-primary px-2.5 py-1 text-sm font-medium text-primary-foreground"> <span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
{t("versionSwitcher.current", { ns: "config" })} <Check className="size-3.5" strokeWidth={2.5} aria-hidden />
<span className="sr-only">
{t("versionSwitcher.current", { ns: "config" })}
</span>
</span> </span>
) : null} ) : (
</div> <ChevronRight
<div className="flex flex-wrap items-center gap-2"> className="size-4 shrink-0 text-muted-foreground/50 group-hover:text-muted-foreground"
<Button aria-hidden
type="button" />
variant={isCurrent ? "secondary" : "outline"} )}
size="sm" </button>
className={cn(
"h-9 rounded-full px-3 text-sm", {showMenu ? (
isCurrent && "bg-muted text-muted-foreground hover:bg-muted", <div className="flex shrink-0 items-center pr-1">
)} <DropdownMenu>
onClick={() => switchTo(v.id)} <DropdownMenuTrigger
> className="inline-flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground opacity-0 outline-none transition-opacity hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring group-hover:opacity-100 data-popup-open:opacity-100"
{isCurrent aria-label={t("versionSwitcher.moreActions", {
? t("versionSwitcher.selected", { ns: "config" }) ns: "config",
: t("versionSwitcher.view", { ns: "config" })} version: v.version_no,
</Button> })}
{onRollbackVersion && v.status !== "draft" ? ( onClick={(e) => e.stopPropagation()}
<Button >
type="button" <MoreHorizontal className="size-4" />
variant="ghost" </DropdownMenuTrigger>
size="sm" <DropdownMenuContent align="end" className="min-w-36">
className="rounded-full text-sm text-muted-foreground hover:bg-muted hover:text-foreground" {onRollbackVersion && v.status !== "draft" ? (
disabled={rollbackBusy} <DropdownMenuItem
onClick={() => { disabled={rollbackBusy}
onRollbackVersion(v); onClick={() => {
setSheetOpen(false); onRollbackVersion(v);
}} setSheetOpen(false);
> }}
{t("versionSwitcher.rollback", { ns: "config" })} >
</Button> {t("versionSwitcher.rollback", { ns: "config" })}
) : null} </DropdownMenuItem>
{onDeleteVersion && v.status !== "active" ? ( ) : null}
<Button {onDeleteVersion && v.status !== "active" ? (
type="button" <DropdownMenuItem
variant="ghost" variant="destructive"
size="sm" disabled={deletingId === v.id}
className="rounded-full text-sm text-destructive hover:bg-destructive/10 hover:text-destructive" onClick={() => setDeleteTarget(v)}
disabled={deletingId === v.id} >
onClick={() => setDeleteTarget(v)} {t("versionSwitcher.delete", { ns: "config" })}
> </DropdownMenuItem>
{t("versionSwitcher.delete", { ns: "config" })} ) : null}
</Button> </DropdownMenuContent>
) : null} </DropdownMenu>
</div> </div>
) : null}
</div> </div>
</div> </li>
); );
})} })}
</div> </ul>
</section> </section>
); ))}
}) </div>
)} )}
</div> </div>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
<Dialog open={deleteTarget !== null} onOpenChange={(open) => !open && setDeleteTarget(null)}> <Dialog open={deleteTarget !== null} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<DialogContent> <DialogContent showCloseButton className="sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>{t("versionSwitcher.deleteConfirmTitle", { ns: "config" })}</DialogTitle> <DialogTitle>{t("versionSwitcher.deleteConfirmTitle", { ns: "config" })}</DialogTitle>
<DialogDescription> <DialogDescription>

View File

@@ -0,0 +1,40 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
type ConfigVersionToolbarMetaProps = {
children: ReactNode;
className?: string;
/** Highlights read-only or draft-only hints. */
emphasis?: boolean;
};
/** Single-line meta row under the version toolbar (live version, read-only hints). */
export function ConfigVersionToolbarMeta({
children,
className,
emphasis = false,
}: ConfigVersionToolbarMetaProps) {
return (
<div
className={cn(
"flex flex-wrap items-center gap-x-3 gap-y-1 text-xs leading-relaxed",
emphasis ? "text-foreground" : "text-muted-foreground",
className,
)}
role="status"
>
{children}
</div>
);
}
export function ConfigVersionToolbarMetaEmphasis({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return <span className={cn("font-medium text-primary", className)}>{children}</span>;
}

View File

@@ -16,8 +16,11 @@ import {
} from "@/api/admin-config"; } from "@/api/admin-config";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ConfigChip, ConfigChipGroup } from "@/modules/config/config-chip-group"; import { ConfigChip, ConfigChipGroup } from "@/modules/config/config-chip-group";
import { ConfigContextBanner, ConfigContextEmphasis } from "@/modules/config/config-context-banner";
import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page"; import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page";
import {
ConfigVersionToolbarMeta,
ConfigVersionToolbarMetaEmphasis,
} from "@/modules/config/config-version-toolbar-meta";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -34,6 +37,7 @@ import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher"
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"; import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { resolveAdminPlayTypeDisplayName } from "@/lib/admin-play-types"; import { resolveAdminPlayTypeDisplayName } from "@/lib/admin-play-types";
import { adminHasAnyPermission } from "@/lib/admin-permissions"; import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { cn } from "@/lib/utils";
import { PRD_ODDS_MANAGE, PRD_REBATE_MANAGE } from "@/lib/admin-prd"; import { PRD_ODDS_MANAGE, PRD_REBATE_MANAGE } from "@/lib/admin-prd";
import { useAdminProfile } from "@/stores/admin-session"; import { useAdminProfile } from "@/stores/admin-session";
import { LotteryApiBizError } from "@/types/api/errors"; import { LotteryApiBizError } from "@/types/api/errors";
@@ -77,16 +81,25 @@ function filterTypes(tab: CatTab, types: AdminPlayTypeRow[]): AdminPlayTypeRow[]
type OddsConfigDocScreenProps = { type OddsConfigDocScreenProps = {
/** 嵌入「赔率与回水」合并页时去掉外层 ConfigDocPage */ /** 嵌入「赔率与回水」合并页时去掉外层 ConfigDocPage */
embedded?: boolean; embedded?: boolean;
/** 与回水分区共用版本选择(合并页) */
versionId?: string;
onVersionIdChange?: (id: string) => void;
}; };
export function OddsConfigDocScreen({ embedded = false }: OddsConfigDocScreenProps) { export function OddsConfigDocScreen({
embedded = false,
versionId: controlledVersionId,
onVersionIdChange,
}: OddsConfigDocScreenProps) {
const { t, i18n } = useTranslation(["config", "adminUsers", "common"]); const { t, i18n } = useTranslation(["config", "adminUsers", "common"]);
const profile = useAdminProfile(); const profile = useAdminProfile();
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_ODDS_MANAGE, PRD_REBATE_MANAGE]); const canManage = adminHasAnyPermission(profile?.permissions, [PRD_ODDS_MANAGE, PRD_REBATE_MANAGE]);
const formatDt = useAdminDateTimeFormatter(); const formatDt = useAdminDateTimeFormatter();
const [types, setTypes] = useState<AdminPlayTypeRow[]>([]); const [types, setTypes] = useState<AdminPlayTypeRow[]>([]);
const [list, setList] = useState<ConfigVersionSummary[]>([]); const [list, setList] = useState<ConfigVersionSummary[]>([]);
const [selectedId, setSelectedId] = useState(""); const [internalSelectedId, setInternalSelectedId] = useState("");
const selectedId = controlledVersionId ?? internalSelectedId;
const setSelectedId = onVersionIdChange ?? setInternalSelectedId;
const [detail, setDetail] = useState<OddsVersionDetail | null>(null); const [detail, setDetail] = useState<OddsVersionDetail | null>(null);
const [draftRows, setDraftRows] = useState<OddsItemRow[]>([]); const [draftRows, setDraftRows] = useState<OddsItemRow[]>([]);
const [loadingTypes, setLoadingTypes] = useState(true); const [loadingTypes, setLoadingTypes] = useState(true);
@@ -417,38 +430,42 @@ export function OddsConfigDocScreen({ embedded = false }: OddsConfigDocScreenPro
]; ];
const filtersBlock = ( const filtersBlock = (
<div className="space-y-4 rounded-xl border border-border/60 bg-card p-4"> <div className={cn("space-y-3", embedded ? "border-t border-border/50 px-3 py-3 sm:px-4" : "rounded-xl border border-border/60 bg-card p-4")}>
<ConfigChipGroup label={t("odds.category", { ns: "config" })}> <ConfigChipGroup label={t("odds.category", { ns: "config" })}>
{catTabs.map((tab) => ( {catTabs.map((tab) => (
<ConfigChip
key={tab.id}
active={catTab === tab.id}
onClick={() => setCatTab(tab.id)}
>
{tab.label}
</ConfigChip>
))}
</ConfigChipGroup>
<ConfigChipGroup label={t("odds.playType", { ns: "config" })}>
{filteredTypes.length === 0 ? (
<span className="text-sm text-muted-foreground">{t("odds.noPlayTypes", { ns: "config" })}</span>
) : (
<div className="-mx-1 flex gap-1.5 overflow-x-auto px-1 pb-0.5">
{filteredTypes.map((type) => (
<ConfigChip <ConfigChip
key={tab.id} key={type.play_code}
active={catTab === tab.id} active={resolvedPlayCode === type.play_code}
onClick={() => setCatTab(tab.id)} onClick={() => setPlayCode(type.play_code)}
className="shrink-0"
> >
{tab.label} {resolveAdminPlayTypeDisplayName(type.play_code, i18n.language, type)}
</ConfigChip> </ConfigChip>
))} ))}
</ConfigChipGroup> </div>
<ConfigChipGroup label={t("odds.playType", { ns: "config" })}> )}
{filteredTypes.length === 0 ? ( </ConfigChipGroup>
<span className="text-sm text-muted-foreground">{t("odds.noPlayTypes", { ns: "config" })}</span> </div>
) : (
filteredTypes.map((type) => (
<ConfigChip
key={type.play_code}
active={resolvedPlayCode === type.play_code}
onClick={() => setPlayCode(type.play_code)}
>
{resolveAdminPlayTypeDisplayName(type.play_code, i18n.language, type)}
</ConfigChip>
))
)}
</ConfigChipGroup>
</div>
); );
const toolbarBlock = ( const toolbarBlock = (
<ConfigDocToolbar <ConfigDocToolbar
className={embedded ? "rounded-none border-0 shadow-none" : undefined}
switcher={ switcher={
<ConfigVersionSwitcher <ConfigVersionSwitcher
versions={list} versions={list}
@@ -475,57 +492,64 @@ export function OddsConfigDocScreen({ embedded = false }: OddsConfigDocScreenPro
onPublish={() => void requestPublishConfirm()} onPublish={() => void requestPublishConfirm()}
/> />
} }
footer={
!detail ? null : (
<ConfigVersionToolbarMeta emphasis={!isDraft}>
<span>
{t("odds.activeVersionPrefix", { ns: "config" })}
{activeHead ? (
<>
v{activeHead.version_no}
{activeHead.effective_at ? ` · ${formatDt(activeHead.effective_at)}` : ""}
</>
) : (
"—"
)}
</span>
{!isDraft ? (
<ConfigVersionToolbarMetaEmphasis>
{t("odds.readOnlyHint", { ns: "config" })}
</ConfigVersionToolbarMetaEmphasis>
) : activeHead ? (
<span>{t("versionToolbar.draftEditing", { ns: "config" })}</span>
) : null}
</ConfigVersionToolbarMeta>
)
}
/> />
); );
const contextBlock =
embedded || !detail ? null : (
<ConfigContextBanner emphasis={!isDraft}>
{t("odds.activeVersionPrefix", { ns: "config" })}
{activeHead ? (
<>
v{activeHead.version_no}
{activeHead.effective_at ? ` · ${formatDt(activeHead.effective_at)}` : ""}
</>
) : (
"—"
)}
{!isDraft ? (
<>
{" "}
<ConfigContextEmphasis>{t("odds.readOnlyHint", { ns: "config" })}</ConfigContextEmphasis>
</>
) : null}
</ConfigContextBanner>
);
const mainBlock = ( const mainBlock = (
<> <>
{error ? <p className="text-sm text-destructive">{error}</p> : null} {error ? <p className="text-sm text-destructive">{error}</p> : null}
{loadingDetail || loadingTypes ? ( {loadingDetail || loadingTypes ? (
<div className="flex min-h-[420px] items-center"> <p className="py-8 text-center text-sm text-muted-foreground">
<p className="text-base text-muted-foreground">{t("odds.loadingDetails", { ns: "config" })}</p> {t("odds.loadingDetails", { ns: "config" })}
</div> </p>
) : resolvedPlayCode ? ( ) : resolvedPlayCode ? (
<div className="grid min-h-[420px] gap-4 max-w-md"> <div
{PRIZE_SCOPE_ORDER.map((scope) => { className={cn(
const row = scopeRows[scope]; embedded ? "rounded-xl border border-border/60 bg-card p-4" : undefined,
const hint = embedded ? null : PRIZE_SCOPE_MULTIPLIER_HINT[scope]; )}
const idx = row ? rowIndex(resolvedPlayCode, scope) : -1; >
return ( <div className="grid grid-cols-2 gap-x-4 gap-y-4 sm:grid-cols-3 lg:grid-cols-6">
<div key={scope} className="grid gap-1"> {PRIZE_SCOPE_ORDER.map((scope) => {
<Label className="flex items-baseline gap-2"> const row = scopeRows[scope];
{prizeScopeLabel(scope, t)} const hint = embedded ? null : PRIZE_SCOPE_MULTIPLIER_HINT[scope];
{hint ? <span className="text-sm text-muted-foreground font-normal">{hint}</span> : null} const idx = row ? rowIndex(resolvedPlayCode, scope) : -1;
</Label> return (
{row && idx >= 0 ? ( <div key={scope} className="grid min-w-0 gap-1.5">
<div className="flex flex-wrap items-center gap-2"> <Label className="truncate text-xs font-medium text-muted-foreground">
{canEditDraft ? ( {prizeScopeLabel(scope, t)}
{hint ? <span className="ml-1 font-normal">{hint}</span> : null}
</Label>
{row && idx >= 0 ? (
canEditDraft ? (
<Input <Input
type="text" type="text"
inputMode="decimal" inputMode="decimal"
className="h-9 max-w-[200px] font-mono tabular-nums" className="h-9 w-full font-mono tabular-nums"
disabled={saving} disabled={saving}
value={oddsMultiplierLabel(row.odds_value)} value={oddsMultiplierLabel(row.odds_value)}
onChange={(e) => onChange={(e) =>
@@ -535,46 +559,39 @@ export function OddsConfigDocScreen({ embedded = false }: OddsConfigDocScreenPro
} }
/> />
) : ( ) : (
<ConfigReadonlyValue mono className="max-w-[200px]"> <ConfigReadonlyValue mono className="h-9 w-full justify-center">
{oddsMultiplierLabel(row.odds_value)} {oddsMultiplierLabel(row.odds_value)}
</ConfigReadonlyValue> </ConfigReadonlyValue>
)} )
{!embedded ? ( ) : (
<span className="text-sm text-muted-foreground tabular-nums"> <p className="text-xs text-destructive">{t("odds.missingScopeRow", { ns: "config", scope })}</p>
{t("odds.multiplier", { )}
ns: "config", </div>
value: oddsMultiplierLabel(row.odds_value), );
currency: row.currency_code, })}
})} <div className="grid min-w-0 gap-1.5">
</span> <Label className="truncate text-xs font-medium text-muted-foreground">
) : null} {t("odds.rebateRate", { ns: "config" })}
</div> </Label>
) : ( {canEditDraft ? (
<p className="text-sm text-destructive">{t("odds.missingScopeRow", { ns: "config", scope })}</p> <Input
)} type="text"
</div> inputMode="decimal"
); className="h-9 w-full font-mono tabular-nums"
})} disabled={saving}
<div className="grid gap-1 pt-2 border-t"> value={rebatePercentUi}
<Label>{t("odds.rebateRate", { ns: "config" })}</Label> onChange={(e) => setRebateForPlayPercent(e.target.value)}
{canEditDraft ? ( />
<Input ) : (
type="text" <ConfigReadonlyValue mono className="h-9 w-full justify-center">
inputMode="decimal" {rebatePercentUi}
className="h-9 max-w-[200px] font-mono tabular-nums" </ConfigReadonlyValue>
disabled={saving} )}
value={rebatePercentUi} </div>
onChange={(e) => setRebateForPlayPercent(e.target.value)}
/>
) : (
<ConfigReadonlyValue mono className="max-w-[200px]">
{rebatePercentUi}
</ConfigReadonlyValue>
)}
{!embedded ? (
<p className="text-sm text-muted-foreground">{t("odds.rebateRateHint", { ns: "config" })}</p>
) : null}
</div> </div>
{!embedded ? (
<p className="mt-3 text-xs text-muted-foreground">{t("odds.rebateRateHint", { ns: "config" })}</p>
) : null}
</div> </div>
) : null} ) : null}
</> </>
@@ -649,10 +666,11 @@ export function OddsConfigDocScreen({ embedded = false }: OddsConfigDocScreenPro
if (embedded) { if (embedded) {
return ( return (
<div className="space-y-6"> <div className="space-y-4">
{filtersBlock} <div className="overflow-hidden rounded-xl border border-border/60 bg-card">
{toolbarBlock} {toolbarBlock}
{contextBlock} {filtersBlock}
</div>
{mainBlock} {mainBlock}
{dialogs} {dialogs}
</div> </div>
@@ -664,7 +682,6 @@ export function OddsConfigDocScreen({ embedded = false }: OddsConfigDocScreenPro
title={t("nav.items.odds", { ns: "config" })} title={t("nav.items.odds", { ns: "config" })}
filters={filtersBlock} filters={filtersBlock}
toolbar={toolbarBlock} toolbar={toolbarBlock}
context={contextBlock}
> >
{mainBlock} {mainBlock}
{dialogs} {dialogs}

View File

@@ -10,16 +10,18 @@ import {
getPlayConfigVersion, getPlayConfigVersion,
getPlayConfigVersions, getPlayConfigVersions,
postPlayConfigVersion, postPlayConfigVersion,
patchAdminPlayType,
publishPlayConfigVersion, publishPlayConfigVersion,
putPlayConfigItems, putPlayConfigItems,
} from "@/api/admin-config"; } from "@/api/admin-config";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ConfigChipGroup } from "@/modules/config/config-chip-group"; import { ConfigChipGroup } from "@/modules/config/config-chip-group";
import { ConfigContextBanner, ConfigContextEmphasis } from "@/modules/config/config-context-banner";
import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page"; import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page";
import {
ConfigVersionToolbarMeta,
ConfigVersionToolbarMetaEmphasis,
} from "@/modules/config/config-version-toolbar-meta";
import { ConfigSection } from "@/modules/config/config-section"; import { ConfigSection } from "@/modules/config/config-section";
import { Switch } from "@/components/ui/switch"; import { ConfirmableSwitch } from "@/components/admin/confirmable-switch";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -136,7 +138,7 @@ function buildPlayConfigSavePayload(
export function PlayConfigDocScreen() { export function PlayConfigDocScreen() {
const { t } = useTranslation(["config", "adminUsers", "common"]); const { t } = useTranslation(["config", "adminUsers", "common"]);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction(); const { request: requestConfirm, ConfirmDialog, busy: confirmBusy } = useConfirmAction();
const profile = useAdminProfile(); const profile = useAdminProfile();
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_PLAY_SWITCH_MANAGE]); const canManage = adminHasAnyPermission(profile?.permissions, [PRD_PLAY_SWITCH_MANAGE]);
const formatDt = useAdminDateTimeFormatter(); const formatDt = useAdminDateTimeFormatter();
@@ -269,25 +271,10 @@ export function PlayConfigDocScreen() {
setDraftRows((prev) => prev.map((r) => (r.play_code === playCode ? { ...r, ...patch } : r))); setDraftRows((prev) => prev.map((r) => (r.play_code === playCode ? { ...r, ...patch } : r)));
} }
async function applyPlayToggleInstant(playCode: string, enabled: boolean) { function applyBatchSwitch(group: PlayBatchSwitchGroup, enabled: boolean) {
try {
await patchAdminPlayType(playCode, { is_enabled: enabled });
} catch (e) {
toast.error(
e instanceof LotteryApiBizError ? e.message : t("play.toggleInstantFailed", { ns: "config" }),
);
throw e;
}
}
async function applyBatchSwitch(group: PlayBatchSwitchGroup, enabled: boolean) {
const targets = draftRows.filter(group.match);
setDraftRows((prev) => setDraftRows((prev) =>
prev.map((row) => (group.match(row) ? { ...row, is_enabled: enabled } : row)), prev.map((row) => (group.match(row) ? { ...row, is_enabled: enabled } : row)),
); );
for (const row of targets) {
await applyPlayToggleInstant(row.play_code, enabled);
}
} }
const batchSwitchStates = useMemo( const batchSwitchStates = useMemo(
@@ -448,26 +435,27 @@ export function PlayConfigDocScreen() {
} }
/> />
} }
footer={
detail ? (
<ConfigVersionToolbarMeta emphasis={!isDraft}>
{activeHead ? (
<span>
{t("play.activeVersion", { ns: "config", version: activeHead.version_no })}
{activeHead.effective_at ? ` · ${formatDt(activeHead.effective_at)}` : ""}
</span>
) : null}
{!isDraft ? (
<ConfigVersionToolbarMetaEmphasis>
{t("play.readOnlyHint", { ns: "config" })}
</ConfigVersionToolbarMetaEmphasis>
) : activeHead ? (
<span>{t("versionToolbar.draftEditing", { ns: "config" })}</span>
) : null}
</ConfigVersionToolbarMeta>
) : null
}
/> />
} }
context={
detail ? (
<ConfigContextBanner emphasis={!isDraft}>
{activeHead ? (
<>
{t("play.activeVersion", { ns: "config", version: activeHead.version_no })}
{activeHead.effective_at ? ` · ${formatDt(activeHead.effective_at)}` : ""}
</>
) : null}
{!isDraft ? (
<>
{activeHead ? " — " : ""}
<ConfigContextEmphasis>{t("play.readOnlyHint", { ns: "config" })}</ConfigContextEmphasis>
</>
) : null}
</ConfigContextBanner>
) : null
}
> >
{detail ? ( {detail ? (
<ConfigSection <ConfigSection
@@ -476,7 +464,9 @@ export function PlayConfigDocScreen() {
> >
<ConfigChipGroup> <ConfigChipGroup>
{batchSwitchStates.map((group) => { {batchSwitchStates.map((group) => {
const groupOn = group.enabledCount > 0; const groupOn = group.allEnabled;
const isPartial =
group.total > 0 && group.enabledCount > 0 && group.enabledCount < group.total;
return ( return (
<div <div
key={group.key} key={group.key}
@@ -486,16 +476,23 @@ export function PlayConfigDocScreen() {
<p className="text-sm font-medium text-foreground">{group.label}</p> <p className="text-sm font-medium text-foreground">{group.label}</p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{group.total > 0 {group.total > 0
? t("play.batchEnabledCount", { ? isPartial
ns: "config", ? t("play.batchPartialEnabled", {
enabledCount: group.enabledCount, ns: "config",
total: group.total, enabledCount: group.enabledCount,
}) total: group.total,
})
: t("play.batchEnabledCount", {
ns: "config",
enabledCount: group.enabledCount,
total: group.total,
})
: t("play.noPlayTypes", { ns: "config" })} : t("play.noPlayTypes", { ns: "config" })}
</p> </p>
</div> </div>
<Switch <ConfirmableSwitch
checked={groupOn} checked={groupOn}
confirmBusy={confirmBusy}
disabled={!isDraft || saving || group.total === 0} disabled={!isDraft || saving || group.total === 0}
aria-label={t("play.aria.batchGroupSwitch", { aria-label={t("play.aria.batchGroupSwitch", {
ns: "config", ns: "config",
@@ -537,8 +534,8 @@ export function PlayConfigDocScreen() {
<TableHead className="text-center">{t("play.table.playCode", { ns: "config" })}</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-[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="w-[88px] text-center">{t("play.table.status", { ns: "config" })}</TableHead>
<TableHead className="w-32 text-center">{t("play.table.displayName", { ns: "config" })}</TableHead> <TableHead className="w-36 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-24 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.minBet", { ns: "config" })}</TableHead>
<TableHead className="w-[110px] text-center">{t("play.table.maxBet", { 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> <TableHead className="w-[140px] text-center">{t("play.table.actions", { ns: "config" })}</TableHead>
@@ -550,41 +547,47 @@ export function PlayConfigDocScreen() {
<TableCell className="text-center font-mono text-sm">{row.play_code}</TableCell> <TableCell className="text-center font-mono text-sm">{row.play_code}</TableCell>
<TableCell className="text-center text-muted-foreground text-sm">{row.category ?? "—"}</TableCell> <TableCell className="text-center text-muted-foreground text-sm">{row.category ?? "—"}</TableCell>
<TableCell className="text-center"> <TableCell className="text-center">
<div className="flex justify-center"> {isDraft ? (
<Switch <div className="flex justify-center">
checked={row.is_enabled} <ConfirmableSwitch
disabled={!isDraft || saving} checked={row.is_enabled}
aria-label={t("play.aria.enablePlay", { ns: "config", playCode: row.play_code })} confirmBusy={confirmBusy}
onCheckedChange={(checked) => { disabled={saving}
if (!isDraft) { aria-label={t("play.aria.enablePlay", { ns: "config", playCode: row.play_code })}
return; onCheckedChange={(enabled) => {
} const action = enabled
const enabled = checked; ? t("play.toggleEnable", { ns: "config" })
const action = enabled : t("play.toggleDisable", { ns: "config" });
? t("play.toggleEnable", { ns: "config" }) requestConfirm({
: t("play.toggleDisable", { ns: "config" }); title: t("play.toggleConfirmTitle", {
requestConfirm({ ns: "config",
title: t("play.toggleConfirmTitle", { action,
ns: "config", playCode: row.play_code,
action, }),
playCode: row.play_code, description: t("play.toggleConfirmDescription", { ns: "config" }),
}), confirmVariant: enabled ? "default" : "destructive",
description: t("play.toggleConfirmDescription", { ns: "config" }), onConfirm: () => {
confirmVariant: enabled ? "default" : "destructive", updateConfigRow(row.play_code, { is_enabled: enabled });
onConfirm: () => { },
updateConfigRow(row.play_code, { is_enabled: enabled }); });
void applyPlayToggleInstant(row.play_code, enabled); }}
}, />
}); </div>
}} ) : (
/> <div className="flex justify-center">
</div> <AdminStatusBadge status={row.is_enabled ? "enabled" : "disabled"}>
{row.is_enabled
? t("play.states.enabled", { ns: "config" })
: t("play.states.disabled", { ns: "config" })}
</AdminStatusBadge>
</div>
)}
</TableCell> </TableCell>
<TableCell className="w-32 text-center"> <TableCell className="w-36 text-center">
{isDraft ? ( {isDraft ? (
<Input <Input
type="text" type="text"
className="mx-auto h-8 w-28 text-center text-sm" className="mx-auto h-8 w-full max-w-[9rem] text-center text-sm"
disabled={saving} disabled={saving}
value={row.display_name ?? ""} value={row.display_name ?? ""}
placeholder={row.play_code} placeholder={row.play_code}
@@ -604,12 +607,12 @@ export function PlayConfigDocScreen() {
</ConfigReadonlyValue> </ConfigReadonlyValue>
)} )}
</TableCell> </TableCell>
<TableCell className="w-[96px] text-center"> <TableCell className="w-24 text-center">
{isDraft ? ( {isDraft ? (
<Input <Input
type="text" type="text"
inputMode="numeric" inputMode="numeric"
className="h-8 w-16 font-mono tabular-nums text-center" className="mx-auto h-8 w-16 font-mono tabular-nums text-center"
value={row.display_order} value={row.display_order}
disabled={saving} disabled={saving}
onChange={(e) => { onChange={(e) => {
@@ -630,7 +633,7 @@ export function PlayConfigDocScreen() {
<Input <Input
type="text" type="text"
inputMode="decimal" inputMode="decimal"
className="h-8 text-center font-mono tabular-nums" className="mx-auto h-8 w-24 text-center font-mono tabular-nums"
disabled={saving} disabled={saving}
value={formatAdminMinorDecimal(row.min_bet_amount, amountCurrencyCode)} value={formatAdminMinorDecimal(row.min_bet_amount, amountCurrencyCode)}
onChange={(e) => onChange={(e) =>
@@ -651,7 +654,7 @@ export function PlayConfigDocScreen() {
<Input <Input
type="text" type="text"
inputMode="decimal" inputMode="decimal"
className="h-8 text-center font-mono tabular-nums" className="mx-auto h-8 w-24 text-center font-mono tabular-nums"
disabled={saving} disabled={saving}
value={formatAdminMinorDecimal(row.max_bet_amount, amountCurrencyCode)} value={formatAdminMinorDecimal(row.max_bet_amount, amountCurrencyCode)}
onChange={(e) => onChange={(e) =>

View File

@@ -14,9 +14,12 @@ import {
publishOddsVersion, publishOddsVersion,
putOddsItems, putOddsItems,
} from "@/api/admin-config"; } from "@/api/admin-config";
import { ConfigContextBanner, ConfigContextEmphasis } from "@/modules/config/config-context-banner";
import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page"; import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page";
import { Switch } from "@/components/ui/switch"; import {
ConfigVersionToolbarMeta,
ConfigVersionToolbarMetaEmphasis,
} from "@/modules/config/config-version-toolbar-meta";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value"; import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
@@ -54,9 +57,15 @@ function inferPercentFrom(dim: 2 | 3 | 4, rows: OddsItemRow[], typeList: AdminPl
type RebateConfigDocScreenProps = { type RebateConfigDocScreenProps = {
embedded?: boolean; embedded?: boolean;
versionId?: string;
onVersionIdChange?: (id: string) => void;
}; };
export function RebateConfigDocScreen({ embedded = false }: RebateConfigDocScreenProps) { export function RebateConfigDocScreen({
embedded = false,
versionId: controlledVersionId,
onVersionIdChange,
}: RebateConfigDocScreenProps) {
const { t } = useTranslation(["config", "common"]); const { t } = useTranslation(["config", "common"]);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction(); const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const profile = useAdminProfile(); const profile = useAdminProfile();
@@ -65,7 +74,9 @@ export function RebateConfigDocScreen({ embedded = false }: RebateConfigDocScree
const [types, setTypes] = useState<AdminPlayTypeRow[]>([]); const [types, setTypes] = useState<AdminPlayTypeRow[]>([]);
const [listRows, setListRows] = useState<ConfigVersionSummary[]>([]); const [listRows, setListRows] = useState<ConfigVersionSummary[]>([]);
const [selectedId, setSelectedId] = useState(""); const [internalSelectedId, setInternalSelectedId] = useState("");
const selectedId = controlledVersionId ?? internalSelectedId;
const setSelectedId = onVersionIdChange ?? setInternalSelectedId;
const [detail, setDetail] = useState<OddsVersionDetail | null>(null); const [detail, setDetail] = useState<OddsVersionDetail | null>(null);
const [draftRows, setDraftRows] = useState<OddsItemRow[]>([]); const [draftRows, setDraftRows] = useState<OddsItemRow[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -313,31 +324,34 @@ export function RebateConfigDocScreen({ embedded = false }: RebateConfigDocScree
} }
/> />
} }
footer={
embedded || !detail ? null : (
<ConfigVersionToolbarMeta emphasis={!isDraft}>
<span>
{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" }),
})}
</span>
{!isDraft ? (
<ConfigVersionToolbarMetaEmphasis>
{t("rebate.readOnlyHint", { ns: "config" })}
</ConfigVersionToolbarMetaEmphasis>
) : (
<span>{t("versionToolbar.draftEditing", { ns: "config" })}</span>
)}
</ConfigVersionToolbarMeta>
)
}
/> />
); );
const contextBlock =
embedded || !detail ? null : (
<ConfigContextBanner emphasis={!isDraft}>
{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 ? (
<>
{" "}
<ConfigContextEmphasis>{t("rebate.readOnlyHint", { ns: "config" })}</ConfigContextEmphasis>
</>
) : null}
</ConfigContextBanner>
);
const fieldsBlock = ( const fieldsBlock = (
<> <>
<div className="grid gap-5 sm:grid-cols-3"> <div className="grid gap-5 sm:grid-cols-3">
@@ -392,10 +406,10 @@ export function RebateConfigDocScreen({ embedded = false }: RebateConfigDocScree
</div> </div>
<div className="flex items-center justify-between gap-3 rounded-xl border border-border/60 px-4 py-3"> <div className="flex items-center justify-between gap-3 rounded-xl border border-border/60 px-4 py-3">
<Label htmlFor="win-enjoy" className="font-medium leading-snug"> <p className="text-sm font-medium">{t("rebate.winEnjoy.label", { ns: "config" })}</p>
{t("rebate.winEnjoy.label", { ns: "config" })} <AdminStatusBadge status="enabled">
</Label> {t("system.states.enabled", { ns: "config" })}
<Switch id="win-enjoy" checked disabled aria-label={t("rebate.winEnjoy.label", { ns: "config" })} /> </AdminStatusBadge>
</div> </div>
{!embedded ? ( {!embedded ? (
@@ -415,8 +429,7 @@ export function RebateConfigDocScreen({ embedded = false }: RebateConfigDocScree
if (embedded) { if (embedded) {
return ( return (
<div className="space-y-6"> <div className="space-y-4">
{contextBlock}
{fieldsBlock} {fieldsBlock}
<ConfirmDialog /> <ConfirmDialog />
</div> </div>
@@ -427,7 +440,6 @@ export function RebateConfigDocScreen({ embedded = false }: RebateConfigDocScree
<ConfigDocPage <ConfigDocPage
title={t("nav.items.rebate", { ns: "config" })} title={t("nav.items.rebate", { ns: "config" })}
toolbar={toolbarBlock} toolbar={toolbarBlock}
context={contextBlock}
> >
{fieldsBlock} {fieldsBlock}
<ConfirmDialog /> <ConfirmDialog />

View File

@@ -14,8 +14,11 @@ import {
putRiskCapItems, putRiskCapItems,
} from "@/api/admin-config"; } from "@/api/admin-config";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ConfigContextBanner, ConfigContextEmphasis } from "@/modules/config/config-context-banner";
import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page"; import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page";
import {
ConfigVersionToolbarMeta,
ConfigVersionToolbarMetaEmphasis,
} from "@/modules/config/config-version-toolbar-meta";
import { ConfigSection } from "@/modules/config/config-section"; import { ConfigSection } from "@/modules/config/config-section";
import { import {
Dialog, Dialog,
@@ -375,21 +378,27 @@ export function RiskCapDocScreen() {
} }
/> />
} }
footer={
detail ? (
<ConfigVersionToolbarMeta emphasis={!isDraft}>
<span>
{t("riskCap.effectiveAt", {
ns: "config",
value: detail.effective_at ? formatDt(detail.effective_at) : "—",
})}
</span>
{!isDraft ? (
<ConfigVersionToolbarMetaEmphasis>
{t("riskCap.readOnlyHint", { ns: "config" })}
</ConfigVersionToolbarMetaEmphasis>
) : (
<span>{t("versionToolbar.draftEditing", { ns: "config" })}</span>
)}
</ConfigVersionToolbarMeta>
) : null
}
/> />
} }
context={
detail ? (
<ConfigContextBanner emphasis={!isDraft}>
{t("riskCap.effectiveAt", { ns: "config", value: detail.effective_at ? formatDt(detail.effective_at) : "—" })}
{!isDraft ? (
<>
{" "}
<ConfigContextEmphasis>{t("riskCap.readOnlyHint", { ns: "config" })}</ConfigContextEmphasis>
</>
) : null}
</ConfigContextBanner>
) : null
}
contentClassName="space-y-8" contentClassName="space-y-8"
> >
{error ? <p className="text-sm text-destructive">{error}</p> : null} {error ? <p className="text-sm text-destructive">{error}</p> : null}
@@ -447,8 +456,8 @@ export function RiskCapDocScreen() {
<TableRow> <TableRow>
<TableHead className="w-[110px]">{t("riskCap.table.number", { ns: "config" })}</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-[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-center">{t("riskCap.table.used", { ns: "config" })}</TableHead>
<TableHead className="w-[90px] text-right">{t("riskCap.table.remaining", { ns: "config" })}</TableHead> <TableHead className="w-[90px] text-center">{t("riskCap.table.remaining", { ns: "config" })}</TableHead>
<TableHead className="w-[72px] text-center">{t("riskCap.table.soldOut", { 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> <TableHead className="w-[160px]">{t("riskCap.table.actions", { ns: "config" })}</TableHead>
</TableRow> </TableRow>
@@ -494,8 +503,8 @@ export function RiskCapDocScreen() {
</ConfigReadonlyValue> </ConfigReadonlyValue>
)} )}
</TableCell> </TableCell>
<TableCell className="text-right text-muted-foreground tabular-nums text-sm"></TableCell> <TableCell className="text-center text-muted-foreground tabular-nums text-sm"></TableCell>
<TableCell className="text-right text-muted-foreground tabular-nums text-sm"></TableCell> <TableCell className="text-center text-muted-foreground tabular-nums text-sm"></TableCell>
<TableCell className="text-center text-muted-foreground text-sm"></TableCell> <TableCell className="text-center text-muted-foreground text-sm"></TableCell>
<TableCell> <TableCell>
{canEditDraft ? ( {canEditDraft ? (
@@ -546,9 +555,9 @@ export function RiskCapDocScreen() {
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>{t("riskCap.table.number", { ns: "config" })}</TableHead> <TableHead>{t("riskCap.table.number", { ns: "config" })}</TableHead>
<TableHead className="text-right">{t("riskCap.table.used", { ns: "config" })}</TableHead> <TableHead className="text-center">{t("riskCap.table.used", { ns: "config" })}</TableHead>
<TableHead className="text-right">{t("riskCap.table.remaining", { ns: "config" })}</TableHead> <TableHead className="text-center">{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.ratio", { ns: "config" })}</TableHead>
<TableHead className="text-center">{t("riskCap.table.soldOut", { 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> <TableHead className="w-[140px]">{t("riskCap.table.actions", { ns: "config" })}</TableHead>
</TableRow> </TableRow>
@@ -557,15 +566,11 @@ export function RiskCapDocScreen() {
{occFiltered.map((r) => ( {occFiltered.map((r) => (
<TableRow key={`occ-${r.clientKey}`}> <TableRow key={`occ-${r.clientKey}`}>
<TableCell className="font-mono text-sm">{r.normalized_number}</TableCell> <TableCell className="font-mono text-sm">{r.normalized_number}</TableCell>
<TableCell className="text-right text-muted-foreground"></TableCell>
<TableCell className="text-right text-muted-foreground"></TableCell>
<TableCell className="text-right text-muted-foreground"></TableCell>
<TableCell className="text-center text-muted-foreground"></TableCell> <TableCell className="text-center text-muted-foreground"></TableCell>
<TableCell> <TableCell className="text-center text-muted-foreground"></TableCell>
<Button type="button" variant="ghost" disabled> <TableCell className="text-center text-muted-foreground"></TableCell>
{t("riskCap.actions.close", { ns: "config" })} <TableCell className="text-center text-muted-foreground"></TableCell>
</Button> <TableCell className="text-center text-muted-foreground"></TableCell>
</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>

View File

@@ -21,6 +21,7 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog"; import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog";
import { getAdminRequestLocale } from "@/lib/admin-locale";
import { formatAdminMinorUnits, getAdminCurrencyDecimalPlaces } from "@/lib/money"; import { formatAdminMinorUnits, getAdminCurrencyDecimalPlaces } from "@/lib/money";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { StatCard } from "@/modules/dashboard/dashboard-visuals"; import { StatCard } from "@/modules/dashboard/dashboard-visuals";
@@ -52,7 +53,7 @@ function formatMoneyMinor(minor: number, currencyCode: string | null): string {
const decimals = getAdminCurrencyDecimalPlaces(code); const decimals = getAdminCurrencyDecimalPlaces(code);
const major = minor / 10 ** decimals; const major = minor / 10 ** decimals;
try { try {
return new Intl.NumberFormat("zh-CN", { return new Intl.NumberFormat(getAdminRequestLocale(), {
style: "currency", style: "currency",
currency: code, currency: code,
minimumFractionDigits: decimals, minimumFractionDigits: decimals,
@@ -287,8 +288,8 @@ export function DashboardAnalyticsPanel({
label={t("analytics.summaryBet")} label={t("analytics.summaryBet")}
value={formatMoneyMinor(summary.total_bet_minor, currency)} value={formatMoneyMinor(summary.total_bet_minor, currency)}
hint={t("lifetimeActivityHint", { hint={t("lifetimeActivityHint", {
draws: summary.draw_count.toLocaleString("zh-CN"), draws: summary.draw_count.toLocaleString(getAdminRequestLocale()),
days: summary.business_day_count.toLocaleString("zh-CN"), days: summary.business_day_count.toLocaleString(getAdminRequestLocale()),
})} })}
icon={<Wallet className="size-5" aria-hidden />} icon={<Wallet className="size-5" aria-hidden />}
/> />

View File

@@ -40,6 +40,7 @@ import {
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog"; import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
import { adminWeekdayKeyForDate, formatAdminCalendarToday } from "@/lib/admin-datetime"; import { adminWeekdayKeyForDate, formatAdminCalendarToday } from "@/lib/admin-datetime";
import { normalizeAdminLanguage } from "@/i18n"; import { normalizeAdminLanguage } from "@/i18n";
import { getAdminRequestLocale } from "@/lib/admin-locale";
import { formatAdminMinorUnits, getAdminCurrencyDecimalPlaces } from "@/lib/money"; import { formatAdminMinorUnits, getAdminCurrencyDecimalPlaces } from "@/lib/money";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { LotteryApiBizError } from "@/types/api/errors"; import { LotteryApiBizError } from "@/types/api/errors";
@@ -63,7 +64,7 @@ function formatMoneyMinor(minor: number, currencyCode: string | null): string {
const decimals = getAdminCurrencyDecimalPlaces(code); const decimals = getAdminCurrencyDecimalPlaces(code);
const major = minor / 10 ** decimals; const major = minor / 10 ** decimals;
try { try {
return new Intl.NumberFormat("zh-CN", { return new Intl.NumberFormat(getAdminRequestLocale(), {
style: "currency", style: "currency",
currency: code, currency: code,
minimumFractionDigits: decimals, minimumFractionDigits: decimals,

View File

@@ -191,10 +191,10 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
<TableRow> <TableRow>
<TableHead className="w-20">{t("table.id", { ns: "common" })}</TableHead> <TableHead className="w-20">{t("table.id", { ns: "common" })}</TableHead>
<TableHead>{t("status")}</TableHead> <TableHead>{t("status")}</TableHead>
<TableHead className="text-right">{t("ticketCount")}</TableHead> <TableHead className="text-center">{t("ticketCount")}</TableHead>
<TableHead className="text-right">{t("winCount")}</TableHead> <TableHead className="text-center">{t("winCount")}</TableHead>
<TableHead className="text-right">{t("payoutTotal")}</TableHead> <TableHead className="text-center">{t("payoutTotal")}</TableHead>
<TableHead className="text-right">{t("jackpotPayout")}</TableHead> <TableHead className="text-center">{t("jackpotPayout")}</TableHead>
<TableHead>{t("finishedAt")}</TableHead> <TableHead>{t("finishedAt")}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -207,16 +207,16 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
{settlementBatchStatusLabel(b.status, t)} {settlementBatchStatusLabel(b.status, t)}
</AdminStatusBadge> </AdminStatusBadge>
</TableCell> </TableCell>
<TableCell className="text-right tabular-nums text-xs"> <TableCell className="text-center tabular-nums text-xs">
{b.total_ticket_count} {b.total_ticket_count}
</TableCell> </TableCell>
<TableCell className="text-right tabular-nums text-xs"> <TableCell className="text-center tabular-nums text-xs">
{b.total_win_count} {b.total_win_count}
</TableCell> </TableCell>
<TableCell className="text-right tabular-nums text-xs"> <TableCell className="text-center tabular-nums text-xs">
{formatMoney(b.total_payout_amount)} {formatMoney(b.total_payout_amount)}
</TableCell> </TableCell>
<TableCell className="text-right tabular-nums text-xs"> <TableCell className="text-center tabular-nums text-xs">
{formatMoney(b.total_jackpot_payout_amount)} {formatMoney(b.total_jackpot_payout_amount)}
</TableCell> </TableCell>
<TableCell className="font-mono text-[11px] text-muted-foreground"> <TableCell className="font-mono text-[11px] text-muted-foreground">

View File

@@ -204,7 +204,7 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
<TableHead>{t("batchId")}</TableHead> <TableHead>{t("batchId")}</TableHead>
<TableHead>{t("version", { version: "" }).replace(" v", "").trim()}</TableHead> <TableHead>{t("version", { version: "" }).replace(" v", "").trim()}</TableHead>
<TableHead>{t("numberCount")}</TableHead> <TableHead>{t("numberCount")}</TableHead>
<TableHead className="text-right">{t("actions")}</TableHead> <TableHead className="text-center">{t("actions")}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -213,7 +213,7 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
<TableCell className="font-mono text-xs">{b.id}</TableCell> <TableCell className="font-mono text-xs">{b.id}</TableCell>
<TableCell>v{b.result_version}</TableCell> <TableCell>v{b.result_version}</TableCell>
<TableCell>{b.items.length}</TableCell> <TableCell>{b.items.length}</TableCell>
<TableCell className="text-right"> <TableCell className="text-center">
{canManageDraw ? ( {canManageDraw ? (
<Link <Link
href={`/admin/draws/${drawId}/publish/${b.id}`} href={`/admin/draws/${drawId}/publish/${b.id}`}

View File

@@ -395,10 +395,10 @@ export function DrawsIndexConsole() {
<TableHead>{t("closeTime")}</TableHead> <TableHead>{t("closeTime")}</TableHead>
<TableHead>{t("drawTime")}</TableHead> <TableHead>{t("drawTime")}</TableHead>
<TableHead>{t("status")}</TableHead> <TableHead>{t("status")}</TableHead>
<TableHead className="text-right">{t("betTotal")}</TableHead> <TableHead className="text-center">{t("betTotal")}</TableHead>
<TableHead className="text-right">{t("payoutTotal")}</TableHead> <TableHead className="text-center">{t("payoutTotal")}</TableHead>
<TableHead className="text-right">{t("profitLoss")}</TableHead> <TableHead className="text-center">{t("profitLoss")}</TableHead>
<TableHead className="text-right">{t("actions")}</TableHead> <TableHead className="text-center">{t("actions")}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -435,19 +435,19 @@ export function DrawsIndexConsole() {
label={drawStatusLabel(row.status, t)} label={drawStatusLabel(row.status, t)}
/> />
</TableCell> </TableCell>
<TableCell className="text-right text-xs tabular-nums"> <TableCell className="text-center text-xs tabular-nums">
{row.total_bet_minor != null {row.total_bet_minor != null
? formatAdminMinorUnits(row.total_bet_minor, defaultCurrency) ? formatAdminMinorUnits(row.total_bet_minor, defaultCurrency)
: "—"} : "—"}
</TableCell> </TableCell>
<TableCell className="text-right text-xs tabular-nums"> <TableCell className="text-center text-xs tabular-nums">
{row.total_payout_minor != null {row.total_payout_minor != null
? formatAdminMinorUnits(row.total_payout_minor, defaultCurrency) ? formatAdminMinorUnits(row.total_payout_minor, defaultCurrency)
: "—"} : "—"}
</TableCell> </TableCell>
<TableCell <TableCell
className={cn( className={cn(
"text-right text-xs tabular-nums", "text-center text-xs tabular-nums",
(row.profit_loss_minor ?? 0) < 0 ? "text-destructive" : "text-emerald-600", (row.profit_loss_minor ?? 0) < 0 ? "text-destructive" : "text-emerald-600",
)} )}
> >
@@ -455,8 +455,8 @@ export function DrawsIndexConsole() {
? formatAdminMinorUnits(row.profit_loss_minor, defaultCurrency) ? formatAdminMinorUnits(row.profit_loss_minor, defaultCurrency)
: "—"} : "—"}
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-center">
<div className="flex flex-wrap items-center justify-end gap-1.5"> <div className="flex flex-wrap items-center justify-center gap-1.5">
<Link <Link
href={`/admin/draws/${row.id}`} href={`/admin/draws/${row.id}`}
className={cn(buttonVariants({ variant: "outline", size: "sm" }))} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}

View File

@@ -243,8 +243,8 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
<TableHead className="w-14">{t("table.id", { ns: "common" })}</TableHead> <TableHead className="w-14">{t("table.id", { ns: "common" })}</TableHead>
<TableHead className="w-[11rem]">{t("drawNo")}</TableHead> <TableHead className="w-[11rem]">{t("drawNo")}</TableHead>
<TableHead className="w-28">{t("trigger")}</TableHead> <TableHead className="w-28">{t("trigger")}</TableHead>
<TableHead className="w-32 text-right">{t("payoutAmount")}</TableHead> <TableHead className="w-32 text-center">{t("payoutAmount")}</TableHead>
<TableHead className="w-24 text-right">{t("winnerCount")}</TableHead> <TableHead className="w-24 text-center">{t("winnerCount")}</TableHead>
<TableHead className="w-[11rem]">{t("time")}</TableHead> <TableHead className="w-[11rem]">{t("time")}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -261,10 +261,10 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
<TableCell className="font-mono text-xs">{r.id}</TableCell> <TableCell className="font-mono text-xs">{r.id}</TableCell>
<TableCell className="font-mono text-xs">{r.draw_no ?? "—"}</TableCell> <TableCell className="font-mono text-xs">{r.draw_no ?? "—"}</TableCell>
<TableCell className="text-xs">{triggerTypeText(r.trigger_type)}</TableCell> <TableCell className="text-xs">{triggerTypeText(r.trigger_type)}</TableCell>
<TableCell className="text-right font-mono text-xs tabular-nums"> <TableCell className="text-center font-mono text-xs tabular-nums">
{formatAdminMinorUnits(r.total_payout_amount, r.currency_code ?? "NPR")} {formatAdminMinorUnits(r.total_payout_amount, r.currency_code ?? "NPR")}
</TableCell> </TableCell>
<TableCell className="text-right tabular-nums">{r.winner_count}</TableCell> <TableCell className="text-center tabular-nums">{r.winner_count}</TableCell>
<TableCell className="text-xs text-muted-foreground whitespace-nowrap"> <TableCell className="text-xs text-muted-foreground whitespace-nowrap">
{formatDt(r.created_at)} {formatDt(r.created_at)}
</TableCell> </TableCell>
@@ -293,7 +293,7 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
<TableHead className="w-[11rem]">{t("drawNo")}</TableHead> <TableHead className="w-[11rem]">{t("drawNo")}</TableHead>
<TableHead className="w-[11rem]">{t("ticketNo")}</TableHead> <TableHead className="w-[11rem]">{t("ticketNo")}</TableHead>
<TableHead>{t("player")}</TableHead> <TableHead>{t("player")}</TableHead>
<TableHead className="w-32 text-right">{t("contributionAmount")}</TableHead> <TableHead className="w-32 text-center">{t("contributionAmount")}</TableHead>
<TableHead className="w-[11rem]">{t("time")}</TableHead> <TableHead className="w-[11rem]">{t("time")}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -311,7 +311,7 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
<TableCell className="font-mono text-xs">{r.draw_no ?? "—"}</TableCell> <TableCell className="font-mono text-xs">{r.draw_no ?? "—"}</TableCell>
<TableCell className="font-mono text-xs">{r.ticket_no ?? "—"}</TableCell> <TableCell className="font-mono text-xs">{r.ticket_no ?? "—"}</TableCell>
<TableCell className="max-w-[12rem] truncate text-xs">{r.player_username ?? "—"}</TableCell> <TableCell className="max-w-[12rem] truncate text-xs">{r.player_username ?? "—"}</TableCell>
<TableCell className="text-right font-mono text-xs tabular-nums"> <TableCell className="text-center font-mono text-xs tabular-nums">
{formatAdminMinorUnits(r.contribution_amount, r.currency_code ?? "NPR")} {formatAdminMinorUnits(r.contribution_amount, r.currency_code ?? "NPR")}
</TableCell> </TableCell>
<TableCell className="text-xs text-muted-foreground whitespace-nowrap"> <TableCell className="text-xs text-muted-foreground whitespace-nowrap">

View File

@@ -2,6 +2,7 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useConfirmAction } from "@/hooks/use-confirm-action"; import { useConfirmAction } from "@/hooks/use-confirm-action";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { useExportLabels } from "@/hooks/use-export-labels"; import { useExportLabels } from "@/hooks/use-export-labels";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -16,8 +17,10 @@ import {
} from "@/api/admin-player"; } from "@/api/admin-player";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer"; import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button"; import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { ConfirmableSwitch } from "@/components/admin/confirmable-switch";
import { resolvePlayerStatusTone } from "@/lib/admin-status-tone";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { import {
Dialog, Dialog,
@@ -66,7 +69,8 @@ const PLAYER_STATUS_OPTIONS = [
export function PlayersConsole(): React.ReactElement { export function PlayersConsole(): React.ReactElement {
const { t } = useTranslation(["players", "common"]); const { t } = useTranslation(["players", "common"]);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction(); const { request: requestConfirm, ConfirmDialog, busy: confirmBusy } = useConfirmAction();
const formatDt = useAdminDateTimeFormatter();
const exportLabels = useExportLabels("players"); const exportLabels = useExportLabels("players");
const profile = useAdminProfile(); const profile = useAdminProfile();
useAdminCurrencyCatalog(); useAdminCurrencyCatalog();
@@ -338,8 +342,8 @@ export function PlayersConsole(): React.ReactElement {
<TableHead>{t("username")}</TableHead> <TableHead>{t("username")}</TableHead>
<TableHead>{t("nickname")}</TableHead> <TableHead>{t("nickname")}</TableHead>
<TableHead className="whitespace-nowrap">{t("currency")}</TableHead> <TableHead className="whitespace-nowrap">{t("currency")}</TableHead>
<TableHead className="whitespace-nowrap text-right">{t("balance")}</TableHead> <TableHead className="whitespace-nowrap text-center">{t("balance")}</TableHead>
<TableHead className="whitespace-nowrap text-right">{t("available")}</TableHead> <TableHead className="whitespace-nowrap text-center">{t("available")}</TableHead>
<TableHead className="w-20 whitespace-nowrap">{t("status")}</TableHead> <TableHead className="w-20 whitespace-nowrap">{t("status")}</TableHead>
<TableHead className="whitespace-nowrap">{t("lastLogin")}</TableHead> <TableHead className="whitespace-nowrap">{t("lastLogin")}</TableHead>
<TableHead className="min-w-[10rem]">{t("actions")}</TableHead> <TableHead className="min-w-[10rem]">{t("actions")}</TableHead>
@@ -365,21 +369,28 @@ export function PlayersConsole(): React.ReactElement {
<TableCell>{row.username ?? "—"}</TableCell> <TableCell>{row.username ?? "—"}</TableCell>
<TableCell>{row.nickname ?? "—"}</TableCell> <TableCell>{row.nickname ?? "—"}</TableCell>
<TableCell>{row.default_currency}</TableCell> <TableCell>{row.default_currency}</TableCell>
<TableCell className="whitespace-nowrap text-right tabular-nums text-xs"> <TableCell className="whitespace-nowrap text-center tabular-nums text-xs">
{row.wallets.length > 0 {row.wallets.length > 0
? formatAdminMinorUnits(row.wallets[0].balance, row.wallets[0].currency_code) ? formatAdminMinorUnits(row.wallets[0].balance, row.wallets[0].currency_code)
: "—"} : "—"}
</TableCell> </TableCell>
<TableCell className="whitespace-nowrap text-right tabular-nums text-xs"> <TableCell className="whitespace-nowrap text-center tabular-nums text-xs">
{row.wallets.length > 0 {row.wallets.length > 0
? formatAdminMinorUnits(row.wallets[0].available_balance, row.wallets[0].currency_code) ? formatAdminMinorUnits(row.wallets[0].available_balance, row.wallets[0].currency_code)
: "—"} : "—"}
</TableCell> </TableCell>
<TableCell> <TableCell>
{canFreezePlayers ? ( {row.status === 2 ? (
<div className="flex justify-center"> <div className="flex justify-center">
<Switch <AdminStatusBadge status={row.status} tone={resolvePlayerStatusTone(row.status)}>
{playerStatusLabelT(row.status, t)}
</AdminStatusBadge>
</div>
) : canFreezePlayers ? (
<div className="flex justify-center">
<ConfirmableSwitch
checked={row.status === 0} checked={row.status === 0}
confirmBusy={confirmBusy}
disabled={freezeBusyId === row.id} disabled={freezeBusyId === row.id}
aria-label={t("status")} aria-label={t("status")}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
@@ -407,15 +418,7 @@ export function PlayersConsole(): React.ReactElement {
)} )}
</TableCell> </TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground"> <TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{row.last_login_at {row.last_login_at ? formatDt(row.last_login_at) : "—"}
? new Date(row.last_login_at).toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})
: "—"}
</TableCell> </TableCell>
<TableCell> <TableCell>
{canManagePlayers || canFreezePlayers ? ( {canManagePlayers || canFreezePlayers ? (

View File

@@ -312,20 +312,25 @@ function parsePositiveInteger(value: string): number | null {
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null; return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null;
} }
async function resolveDraw(filters: ReportFilters): Promise<{ id: number; draw_no: string }> { async function resolveDraw(
filters: ReportFilters,
t: (key: string, options?: { ns?: string; drawNo?: string }) => string,
): Promise<{ id: number; draw_no: string }> {
if (filters.drawId && filters.drawNo.trim()) { if (filters.drawId && filters.drawNo.trim()) {
return { id: filters.drawId, draw_no: filters.drawNo.trim() }; return { id: filters.drawId, draw_no: filters.drawNo.trim() };
} }
const drawNo = filters.drawNo.trim(); const drawNo = filters.drawNo.trim();
if (!drawNo) { if (!drawNo) {
throw new LotteryApiBizError("请输入期号", -1, null); throw new LotteryApiBizError(t("validation.drawNoRequired", { ns: "reports" }), -1, null);
} }
const data = await getAdminDraws({ draw_no: drawNo, page: 1, per_page: 1 }); const data = await getAdminDraws({ draw_no: drawNo, page: 1, per_page: 1 });
const matched = data.items.find((item) => item.draw_no === drawNo) ?? data.items[0]; const matched = data.items.find((item) => item.draw_no === drawNo) ?? data.items[0];
if (!matched) { if (!matched) {
throw new LotteryApiBizError("未找到期号", -1, { drawNo }); throw new LotteryApiBizError(t("validation.drawNoNotFound", { ns: "reports", drawNo }), -1, {
drawNo,
});
} }
return { id: matched.id, draw_no: matched.draw_no }; return { id: matched.id, draw_no: matched.draw_no };
} }
@@ -459,7 +464,7 @@ export function ReportsConsole() {
try { try {
switch (selectedReport.key) { switch (selectedReport.key) {
case "draw_profit": { case "draw_profit": {
const draw = await resolveDraw(filters); const draw = await resolveDraw(filters, t);
const summary = await getAdminDrawFinanceSummary(draw.id); const summary = await getAdminDrawFinanceSummary(draw.id);
setResult({ setResult({
key: "draw_profit", key: "draw_profit",
@@ -580,7 +585,7 @@ export function ReportsConsole() {
if (!filters.number.trim()) { if (!filters.number.trim()) {
throw new LotteryApiBizError(t("validation.drawNoNumberRequired"), -1, null); throw new LotteryApiBizError(t("validation.drawNoNumberRequired"), -1, null);
} }
const draw = await resolveDraw(filters); const draw = await resolveDraw(filters, t);
const detail = await getAdminRiskPoolDetail(draw.id, filters.number.trim(), { page, per_page: perPage }); const detail = await getAdminRiskPoolDetail(draw.id, filters.number.trim(), { page, per_page: perPage });
const rows: ExportRow[] = [ const rows: ExportRow[] = [
{ {
@@ -627,7 +632,7 @@ export function ReportsConsole() {
break; break;
} }
case "sold_out_number": { case "sold_out_number": {
const draw = await resolveDraw(filters); const draw = await resolveDraw(filters, t);
const payload = await getAdminRiskPools(draw.id, { page, per_page: perPage, sold_out_only: true, sort: "number_asc" }); const payload = await getAdminRiskPools(draw.id, { page, per_page: perPage, sold_out_only: true, sort: "number_asc" });
const rows = payload.items.map((item) => ({ const rows = payload.items.map((item) => ({
draw_id: payload.draw_id, draw_id: payload.draw_id,
@@ -1022,22 +1027,22 @@ export function ReportsConsole() {
<TableRow> <TableRow>
<TableCell className="font-medium">{summary.draw_no}</TableCell> <TableCell className="font-medium">{summary.draw_no}</TableCell>
<TableCell>{summary.draw_status}</TableCell> <TableCell>{summary.draw_status}</TableCell>
<TableCell className="text-right">{summary.order_count}</TableCell> <TableCell className="text-center">{summary.order_count}</TableCell>
<TableCell className="text-right">{summary.ticket_item_count}</TableCell> <TableCell className="text-center">{summary.ticket_item_count}</TableCell>
<TableCell className="text-right">{formatPlainMoney(summary.total_bet_minor, summary.currency_code)}</TableCell> <TableCell className="text-center">{formatPlainMoney(summary.total_bet_minor, summary.currency_code)}</TableCell>
<TableCell className="text-right">{formatPlainMoney(summary.total_payout_minor, summary.currency_code)}</TableCell> <TableCell className="text-center">{formatPlainMoney(summary.total_payout_minor, summary.currency_code)}</TableCell>
<TableCell className="text-right">{formatPlainMoney(summary.approx_house_gross_minor, summary.currency_code)}</TableCell> <TableCell className="text-center">{formatPlainMoney(summary.approx_house_gross_minor, summary.currency_code)}</TableCell>
<TableCell>{summary.settlement_batches.length}</TableCell> <TableCell>{summary.settlement_batches.length}</TableCell>
</TableRow> </TableRow>
{summary.settlement_batches.map((batch) => ( {summary.settlement_batches.map((batch) => (
<TableRow key={batch.id} className="bg-muted/15"> <TableRow key={batch.id} className="bg-muted/15">
<TableCell>#{batch.id}</TableCell> <TableCell>#{batch.id}</TableCell>
<TableCell>{batch.status}</TableCell> <TableCell>{batch.status}</TableCell>
<TableCell className="text-right">{batch.total_ticket_count}</TableCell> <TableCell className="text-center">{batch.total_ticket_count}</TableCell>
<TableCell className="text-right">{batch.total_win_count}</TableCell> <TableCell className="text-center">{batch.total_win_count}</TableCell>
<TableCell className="text-right">-</TableCell> <TableCell className="text-center">-</TableCell>
<TableCell className="text-right">{formatPlainMoney(batch.total_payout_amount, summary.currency_code)}</TableCell> <TableCell className="text-center">{formatPlainMoney(batch.total_payout_amount, summary.currency_code)}</TableCell>
<TableCell className="text-right">{formatPlainMoney(batch.total_jackpot_payout_amount, summary.currency_code)}</TableCell> <TableCell className="text-center">{formatPlainMoney(batch.total_jackpot_payout_amount, summary.currency_code)}</TableCell>
<TableCell>{formatTs(batch.finished_at)}</TableCell> <TableCell>{formatTs(batch.finished_at)}</TableCell>
</TableRow> </TableRow>
))} ))}
@@ -1052,7 +1057,7 @@ export function ReportsConsole() {
<TableCell>{optionText(item.username, item.nickname) || item.player_id}</TableCell> <TableCell>{optionText(item.username, item.nickname) || item.player_id}</TableCell>
<TableCell>{item.direction}</TableCell> <TableCell>{item.direction}</TableCell>
<TableCell>{item.status}</TableCell> <TableCell>{item.status}</TableCell>
<TableCell className="text-right">{item.currency_code} {item.amount}</TableCell> <TableCell className="text-center">{item.currency_code} {item.amount}</TableCell>
<TableCell>{item.external_ref_no || "-"}</TableCell> <TableCell>{item.external_ref_no || "-"}</TableCell>
<TableCell>{item.fail_reason || "-"}</TableCell> <TableCell>{item.fail_reason || "-"}</TableCell>
<TableCell>{formatTs(item.created_at)}</TableCell> <TableCell>{formatTs(item.created_at)}</TableCell>
@@ -1066,9 +1071,9 @@ export function ReportsConsole() {
<TableRow> <TableRow>
<TableCell className="font-medium">{result.raw.pool.normalized_number}</TableCell> <TableCell className="font-medium">{result.raw.pool.normalized_number}</TableCell>
<TableCell>{result.raw.draw_no}</TableCell> <TableCell>{result.raw.draw_no}</TableCell>
<TableCell className="text-right">{formatPlainMoney(result.raw.pool.total_cap_amount, result.raw.currency_code)}</TableCell> <TableCell className="text-center">{formatPlainMoney(result.raw.pool.total_cap_amount, result.raw.currency_code)}</TableCell>
<TableCell className="text-right">{formatPlainMoney(result.raw.pool.locked_amount, result.raw.currency_code)}</TableCell> <TableCell className="text-center">{formatPlainMoney(result.raw.pool.locked_amount, result.raw.currency_code)}</TableCell>
<TableCell className="text-right">{formatPlainMoney(result.raw.pool.remaining_amount, result.raw.currency_code)}</TableCell> <TableCell className="text-center">{formatPlainMoney(result.raw.pool.remaining_amount, result.raw.currency_code)}</TableCell>
<TableCell>{result.raw.pool.is_sold_out ? t("yes") : t("no")}</TableCell> <TableCell>{result.raw.pool.is_sold_out ? t("yes") : t("no")}</TableCell>
<TableCell>{result.raw.pool.usage_ratio == null ? "-" : `${result.raw.pool.usage_ratio}%`}</TableCell> <TableCell>{result.raw.pool.usage_ratio == null ? "-" : `${result.raw.pool.usage_ratio}%`}</TableCell>
<TableCell>v{result.raw.pool.version}</TableCell> <TableCell>v{result.raw.pool.version}</TableCell>
@@ -1077,7 +1082,7 @@ export function ReportsConsole() {
<TableRow key={item.id} className="bg-muted/15"> <TableRow key={item.id} className="bg-muted/15">
<TableCell className="font-mono text-xs">#{item.id}</TableCell> <TableCell className="font-mono text-xs">#{item.id}</TableCell>
<TableCell>{item.action_type}</TableCell> <TableCell>{item.action_type}</TableCell>
<TableCell className="text-right">{formatPlainMoney(item.amount, result.raw.currency_code)}</TableCell> <TableCell className="text-center">{formatPlainMoney(item.amount, result.raw.currency_code)}</TableCell>
<TableCell>{playCodeLabel(item.play_code)}</TableCell> <TableCell>{playCodeLabel(item.play_code)}</TableCell>
<TableCell>{item.ticket_no || "-"}</TableCell> <TableCell>{item.ticket_no || "-"}</TableCell>
<TableCell>{item.player_id || "-"}</TableCell> <TableCell>{item.player_id || "-"}</TableCell>
@@ -1094,9 +1099,9 @@ export function ReportsConsole() {
<TableRow key={item.normalized_number}> <TableRow key={item.normalized_number}>
<TableCell className="font-medium">{item.normalized_number}</TableCell> <TableCell className="font-medium">{item.normalized_number}</TableCell>
<TableCell>{filters.drawNo}</TableCell> <TableCell>{filters.drawNo}</TableCell>
<TableCell className="text-right">{formatPlainMoney(item.total_cap_amount, null)}</TableCell> <TableCell className="text-center">{formatPlainMoney(item.total_cap_amount, null)}</TableCell>
<TableCell className="text-right">{formatPlainMoney(item.locked_amount, null)}</TableCell> <TableCell className="text-center">{formatPlainMoney(item.locked_amount, null)}</TableCell>
<TableCell className="text-right">{formatPlainMoney(item.remaining_amount, null)}</TableCell> <TableCell className="text-center">{formatPlainMoney(item.remaining_amount, null)}</TableCell>
<TableCell>{item.is_sold_out ? t("yes") : t("no")}</TableCell> <TableCell>{item.is_sold_out ? t("yes") : t("no")}</TableCell>
<TableCell>{item.usage_ratio == null ? "-" : `${item.usage_ratio}%`}</TableCell> <TableCell>{item.usage_ratio == null ? "-" : `${item.usage_ratio}%`}</TableCell>
<TableCell>v{item.version}</TableCell> <TableCell>v{item.version}</TableCell>
@@ -1109,9 +1114,9 @@ export function ReportsConsole() {
<TableRow key={item.business_date}> <TableRow key={item.business_date}>
<TableCell className="font-medium">{item.business_date}</TableCell> <TableCell className="font-medium">{item.business_date}</TableCell>
<TableCell>-</TableCell> <TableCell>-</TableCell>
<TableCell className="text-right">{formatPlainMoney(item.total_bet_minor, "NPR")}</TableCell> <TableCell className="text-center">{formatPlainMoney(item.total_bet_minor, "NPR")}</TableCell>
<TableCell className="text-right">{formatPlainMoney(item.total_payout_minor, "NPR")}</TableCell> <TableCell className="text-center">{formatPlainMoney(item.total_payout_minor, "NPR")}</TableCell>
<TableCell className="text-right">{formatPlainMoney(item.approx_house_gross_minor, "NPR")}</TableCell> <TableCell className="text-center">{formatPlainMoney(item.approx_house_gross_minor, "NPR")}</TableCell>
<TableCell>-</TableCell> <TableCell>-</TableCell>
<TableCell>-</TableCell> <TableCell>-</TableCell>
<TableCell>-</TableCell> <TableCell>-</TableCell>
@@ -1124,9 +1129,9 @@ export function ReportsConsole() {
<TableRow key={item.player_id}> <TableRow key={item.player_id}>
<TableCell className="font-medium">{item.username}</TableCell> <TableCell className="font-medium">{item.username}</TableCell>
<TableCell>ID {item.player_id}</TableCell> <TableCell>ID {item.player_id}</TableCell>
<TableCell className="text-right">{formatPlainMoney(item.total_bet_minor, "NPR")}</TableCell> <TableCell className="text-center">{formatPlainMoney(item.total_bet_minor, "NPR")}</TableCell>
<TableCell className="text-right">{formatPlainMoney(item.total_payout_minor, "NPR")}</TableCell> <TableCell className="text-center">{formatPlainMoney(item.total_payout_minor, "NPR")}</TableCell>
<TableCell className="text-right">{formatPlainMoney(item.net_win_loss_minor, "NPR")}</TableCell> <TableCell className="text-center">{formatPlainMoney(item.net_win_loss_minor, "NPR")}</TableCell>
<TableCell>-</TableCell> <TableCell>-</TableCell>
<TableCell>-</TableCell> <TableCell>-</TableCell>
<TableCell>-</TableCell> <TableCell>-</TableCell>
@@ -1139,9 +1144,9 @@ export function ReportsConsole() {
<TableRow key={`${item.play_code}-${item.dimension}`}> <TableRow key={`${item.play_code}-${item.dimension}`}>
<TableCell className="font-medium">{playCodeLabel(item.play_code)}</TableCell> <TableCell className="font-medium">{playCodeLabel(item.play_code)}</TableCell>
<TableCell>{item.dimension}D</TableCell> <TableCell>{item.dimension}D</TableCell>
<TableCell className="text-right">{formatPlainMoney(item.total_bet_minor, "NPR")}</TableCell> <TableCell className="text-center">{formatPlainMoney(item.total_bet_minor, "NPR")}</TableCell>
<TableCell className="text-right">{formatPlainMoney(item.total_payout_minor, "NPR")}</TableCell> <TableCell className="text-center">{formatPlainMoney(item.total_payout_minor, "NPR")}</TableCell>
<TableCell className="text-right">{formatPlainMoney(item.approx_house_gross_minor, "NPR")}</TableCell> <TableCell className="text-center">{formatPlainMoney(item.approx_house_gross_minor, "NPR")}</TableCell>
<TableCell>-</TableCell> <TableCell>-</TableCell>
<TableCell>-</TableCell> <TableCell>-</TableCell>
<TableCell>-</TableCell> <TableCell>-</TableCell>
@@ -1154,8 +1159,8 @@ export function ReportsConsole() {
<TableRow key={item.play_code}> <TableRow key={item.play_code}>
<TableCell className="font-medium">{playCodeLabel(item.play_code)}</TableCell> <TableCell className="font-medium">{playCodeLabel(item.play_code)}</TableCell>
<TableCell>{item.order_count}</TableCell> <TableCell>{item.order_count}</TableCell>
<TableCell className="text-right">{formatPlainMoney(item.total_rebate_minor, "NPR")}</TableCell> <TableCell className="text-center">{formatPlainMoney(item.total_rebate_minor, "NPR")}</TableCell>
<TableCell className="text-right">{item.ticket_item_count}</TableCell> <TableCell className="text-center">{item.ticket_item_count}</TableCell>
<TableCell>-</TableCell> <TableCell>-</TableCell>
<TableCell>-</TableCell> <TableCell>-</TableCell>
<TableCell>-</TableCell> <TableCell>-</TableCell>
@@ -1294,9 +1299,9 @@ export function ReportsConsole() {
<TableRow> <TableRow>
<TableHead>{t("preview.columns.primary")}</TableHead> <TableHead>{t("preview.columns.primary")}</TableHead>
<TableHead>{t("preview.columns.secondary")}</TableHead> <TableHead>{t("preview.columns.secondary")}</TableHead>
<TableHead className="text-right">{t("preview.columns.metricA")}</TableHead> <TableHead className="text-center">{t("preview.columns.metricA")}</TableHead>
<TableHead className="text-right">{t("preview.columns.metricB")}</TableHead> <TableHead className="text-center">{t("preview.columns.metricB")}</TableHead>
<TableHead className="text-right">{t("preview.columns.metricC")}</TableHead> <TableHead className="text-center">{t("preview.columns.metricC")}</TableHead>
<TableHead>{t("preview.columns.status")}</TableHead> <TableHead>{t("preview.columns.status")}</TableHead>
<TableHead>{t("preview.columns.extra")}</TableHead> <TableHead>{t("preview.columns.extra")}</TableHead>
<TableHead>{t("preview.columns.time")}</TableHead> <TableHead>{t("preview.columns.time")}</TableHead>

View File

@@ -184,7 +184,7 @@ export function RiskIndexConsole() {
<TableHead>{t("drawNo")}</TableHead> <TableHead>{t("drawNo")}</TableHead>
<TableHead>{t("status")}</TableHead> <TableHead>{t("status")}</TableHead>
<TableHead>{t("closeTime")}</TableHead> <TableHead>{t("closeTime")}</TableHead>
<TableHead className="text-right">{t("actions")}</TableHead> <TableHead className="text-center">{t("actions")}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -204,7 +204,7 @@ export function RiskIndexConsole() {
<TableCell className="text-sm text-muted-foreground"> <TableCell className="text-sm text-muted-foreground">
{row.close_time ? formatDt(row.close_time) : "—"} {row.close_time ? formatDt(row.close_time) : "—"}
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-center">
<Link <Link
href={`/admin/draws/${row.id}/risk/occupancy`} href={`/admin/draws/${row.id}/risk/occupancy`}
className={cn(buttonVariants({ variant: "secondary", size: "sm" }))} className={cn(buttonVariants({ variant: "secondary", size: "sm" }))}

View File

@@ -168,7 +168,7 @@ export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
<TableHead>{t("time")}</TableHead> <TableHead>{t("time")}</TableHead>
<TableHead>{t("searchNumber")}</TableHead> <TableHead>{t("searchNumber")}</TableHead>
<TableHead>{t("action")}</TableHead> <TableHead>{t("action")}</TableHead>
<TableHead className="text-right">{t("amount")}</TableHead> <TableHead className="text-center">{t("amount")}</TableHead>
<TableHead>{t("source")}</TableHead> <TableHead>{t("source")}</TableHead>
<TableHead>{t("ticketNo")}</TableHead> <TableHead>{t("ticketNo")}</TableHead>
<TableHead>{t("playCode")}</TableHead> <TableHead>{t("playCode")}</TableHead>
@@ -184,7 +184,7 @@ export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
<TableCell className="text-sm"> <TableCell className="text-sm">
{riskActionTypeLabel(row.action_type, t)} {riskActionTypeLabel(row.action_type, t)}
</TableCell> </TableCell>
<TableCell className="text-right text-sm tabular-nums"> <TableCell className="text-center text-sm tabular-nums">
{formatAdminMinorUnits(row.amount, data?.currency_code ?? "NPR")} {formatAdminMinorUnits(row.amount, data?.currency_code ?? "NPR")}
</TableCell> </TableCell>
<TableCell className="text-xs text-muted-foreground"> <TableCell className="text-xs text-muted-foreground">

View File

@@ -163,7 +163,7 @@ export function RiskPoolDetailConsole({
<TableRow> <TableRow>
<TableHead>{t("time")}</TableHead> <TableHead>{t("time")}</TableHead>
<TableHead>{t("action")}</TableHead> <TableHead>{t("action")}</TableHead>
<TableHead className="text-right">{t("amount")}</TableHead> <TableHead className="text-center">{t("amount")}</TableHead>
<TableHead>{t("source")}</TableHead> <TableHead>{t("source")}</TableHead>
<TableHead>{t("ticketNo")}</TableHead> <TableHead>{t("ticketNo")}</TableHead>
<TableHead>{t("playCode")}</TableHead> <TableHead>{t("playCode")}</TableHead>
@@ -176,7 +176,7 @@ export function RiskPoolDetailConsole({
{row.created_at ? formatDt(row.created_at) : "—"} {row.created_at ? formatDt(row.created_at) : "—"}
</TableCell> </TableCell>
<TableCell className="text-sm">{riskActionTypeLabel(row.action_type, t)}</TableCell> <TableCell className="text-sm">{riskActionTypeLabel(row.action_type, t)}</TableCell>
<TableCell className="text-right text-sm tabular-nums"> <TableCell className="text-center text-sm tabular-nums">
{formatAdminMinorUnits(row.amount, currencyCode)} {formatAdminMinorUnits(row.amount, currencyCode)}
</TableCell> </TableCell>
<TableCell className="text-xs text-muted-foreground"> <TableCell className="text-xs text-muted-foreground">

View File

@@ -249,12 +249,12 @@ export function RiskPoolsConsole({
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>{t("searchNumber")}</TableHead> <TableHead>{t("searchNumber")}</TableHead>
<TableHead className="text-right">{t("capAmount")}</TableHead> <TableHead className="text-center">{t("capAmount")}</TableHead>
<TableHead className="text-right">{t("lockedAmount")}</TableHead> <TableHead className="text-center">{t("lockedAmount")}</TableHead>
<TableHead className="text-right">{t("remainingAmount")}</TableHead> <TableHead className="text-center">{t("remainingAmount")}</TableHead>
<TableHead className="text-right">{t("usageRatio")}</TableHead> <TableHead className="text-center">{t("usageRatio")}</TableHead>
<TableHead>{t("poolStatus")}</TableHead> <TableHead>{t("poolStatus")}</TableHead>
<TableHead className="text-right">{t("actions")}</TableHead> <TableHead className="text-center">{t("actions")}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -275,16 +275,16 @@ export function RiskPoolsConsole({
)} )}
> >
<TableCell className="font-mono font-medium">{row.normalized_number}</TableCell> <TableCell className="font-mono font-medium">{row.normalized_number}</TableCell>
<TableCell className="text-right text-sm tabular-nums"> <TableCell className="text-center text-sm tabular-nums">
{formatAdminMinorUnits(row.total_cap_amount, currencyCode)} {formatAdminMinorUnits(row.total_cap_amount, currencyCode)}
</TableCell> </TableCell>
<TableCell className="text-right text-sm tabular-nums"> <TableCell className="text-center text-sm tabular-nums">
{formatAdminMinorUnits(row.locked_amount, currencyCode)} {formatAdminMinorUnits(row.locked_amount, currencyCode)}
</TableCell> </TableCell>
<TableCell className="text-right text-sm tabular-nums"> <TableCell className="text-center text-sm tabular-nums">
{formatAdminMinorUnits(row.remaining_amount, currencyCode)} {formatAdminMinorUnits(row.remaining_amount, currencyCode)}
</TableCell> </TableCell>
<TableCell className="text-right text-sm tabular-nums"> <TableCell className="text-center text-sm tabular-nums">
{row.usage_ratio != null ? `${(row.usage_ratio * 100).toFixed(2)}%` : "—"} {row.usage_ratio != null ? `${(row.usage_ratio * 100).toFixed(2)}%` : "—"}
</TableCell> </TableCell>
<TableCell> <TableCell>
@@ -301,8 +301,8 @@ export function RiskPoolsConsole({
{row.is_sold_out ? t("soldOut") : highRisk ? t("warning") : t("normal")} {row.is_sold_out ? t("soldOut") : highRisk ? t("warning") : t("normal")}
</span> </span>
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-center">
<div className="flex justify-end gap-2"> <div className="flex justify-center gap-2">
{canManageRiskPools ? ( {canManageRiskPools ? (
<Button <Button
type="button" type="button"

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate"; import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
@@ -14,6 +14,7 @@ import { RulesPageShell } from "@/modules/rules/rules-page-shell";
/** 赔率与回水:共用赔率版本线,单页上下分区。 */ /** 赔率与回水:共用赔率版本线,单页上下分区。 */
export function RulesOddsConfigScreen() { export function RulesOddsConfigScreen() {
const { t } = useTranslation("config"); const { t } = useTranslation("config");
const [sharedVersionId, setSharedVersionId] = useState("");
useEffect(() => { useEffect(() => {
const scrollToRebate = () => { const scrollToRebate = () => {
@@ -30,16 +31,20 @@ export function RulesOddsConfigScreen() {
return ( return (
<RulesPageShell> <RulesPageShell>
<AdminPermissionGate requiredAny={PRD_RULES_ODDS_ACCESS_ANY}> <AdminPermissionGate requiredAny={PRD_RULES_ODDS_ACCESS_ANY}>
<ConfigDocPage <ConfigDocPage title={t("nav.rulesOddsTitle")} contentClassName="space-y-8">
title={t("nav.rulesOddsTitle")} <ConfigSection title={t("nav.items.odds")}>
description={t("nav.rulesOddsDescription")} <OddsConfigDocScreen
contentClassName="space-y-10" embedded
> versionId={sharedVersionId}
<ConfigSection title={t("nav.items.odds")} description={t("odds.sectionHint")}> onVersionIdChange={setSharedVersionId}
<OddsConfigDocScreen embedded /> />
</ConfigSection> </ConfigSection>
<ConfigSection id="rebate" title={t("nav.items.rebate")} description={t("rebate.sectionHint")}> <ConfigSection id="rebate" title={t("nav.items.rebate")}>
<RebateConfigDocScreen embedded /> <RebateConfigDocScreen
embedded
versionId={sharedVersionId}
onVersionIdChange={setSharedVersionId}
/>
</ConfigSection> </ConfigSection>
</ConfigDocPage> </ConfigDocPage>
</AdminPermissionGate> </AdminPermissionGate>

View File

@@ -12,6 +12,7 @@ import {
putAdminCurrency, putAdminCurrency,
} from "@/api/admin-currencies"; } from "@/api/admin-currencies";
import { AdminPageCard } from "@/components/admin/admin-page-card"; import { AdminPageCard } from "@/components/admin/admin-page-card";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button"; import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
@@ -250,22 +251,20 @@ export function CurrencySettingsPanel() {
<TableCell>{row.name}</TableCell> <TableCell>{row.name}</TableCell>
<TableCell>{row.decimal_places}</TableCell> <TableCell>{row.decimal_places}</TableCell>
<TableCell> <TableCell>
<div className="flex justify-center"> <AdminStatusBadge status={row.is_enabled ? "enabled" : "disabled"}>
<Switch {row.is_enabled
checked={row.is_enabled} ? t("system.states.enabled", { ns: "config" })
disabled : t("system.states.disabled", { ns: "config" })}
aria-label={t("currencies.form.enabled", { ns: "config", code: row.code })} </AdminStatusBadge>
/>
</div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex justify-center"> <AdminStatusBadge
<Switch status={row.is_enabled && row.is_bettable ? "enabled" : "disabled"}
checked={row.is_enabled && row.is_bettable} >
disabled {row.is_enabled && row.is_bettable
aria-label={t("currencies.form.bettable", { ns: "config", code: row.code })} ? t("system.states.enabled", { ns: "config" })
/> : t("system.states.disabled", { ns: "config" })}
</div> </AdminStatusBadge>
</TableCell> </TableCell>
<TableCell className="text-center"> <TableCell className="text-center">
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">

View File

@@ -338,8 +338,8 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
<TableHead>{t("playCode")}</TableHead> <TableHead>{t("playCode")}</TableHead>
<TableHead>{t("player")}</TableHead> <TableHead>{t("player")}</TableHead>
<TableHead>{t("matchedTier")}</TableHead> <TableHead>{t("matchedTier")}</TableHead>
<TableHead className="text-right">{t("regularPayout")}</TableHead> <TableHead className="text-center">{t("regularPayout")}</TableHead>
<TableHead className="text-right">{t("jackpot")}</TableHead> <TableHead className="text-center">{t("jackpot")}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -351,10 +351,10 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
{r.player_username ?? r.site_player_id ?? r.player_id ?? "—"} {r.player_username ?? r.site_player_id ?? r.player_id ?? "—"}
</TableCell> </TableCell>
<TableCell className="text-xs">{r.matched_prize_tier ?? "—"}</TableCell> <TableCell className="text-xs">{r.matched_prize_tier ?? "—"}</TableCell>
<TableCell className="text-right font-mono text-xs tabular-nums"> <TableCell className="text-center font-mono text-xs tabular-nums">
{formatAdminMinorUnits(r.win_amount, r.currency_code ?? batchCurrency)} {formatAdminMinorUnits(r.win_amount, r.currency_code ?? batchCurrency)}
</TableCell> </TableCell>
<TableCell className="text-right font-mono text-xs tabular-nums"> <TableCell className="text-center font-mono text-xs tabular-nums">
{formatAdminMinorUnits( {formatAdminMinorUnits(
r.jackpot_allocation_amount, r.jackpot_allocation_amount,
r.currency_code ?? batchCurrency, r.currency_code ?? batchCurrency,

View File

@@ -242,10 +242,10 @@ export function SettlementBatchesConsole() {
<TableRow> <TableRow>
<TableHead>{t("table.id", { ns: "common" })}</TableHead> <TableHead>{t("table.id", { ns: "common" })}</TableHead>
<TableHead>{t("drawNo")}</TableHead> <TableHead>{t("drawNo")}</TableHead>
<TableHead className="text-right">{t("totalBet")}</TableHead> <TableHead className="text-center">{t("totalBet")}</TableHead>
<TableHead className="text-right">{t("actualDeduct")}</TableHead> <TableHead className="text-center">{t("actualDeduct")}</TableHead>
<TableHead className="text-right">{t("payoutTotal")}</TableHead> <TableHead className="text-center">{t("payoutTotal")}</TableHead>
<TableHead className="text-right">{t("platformProfit")}</TableHead> <TableHead className="text-center">{t("platformProfit")}</TableHead>
<TableHead>{t("reviewStatus")}</TableHead> <TableHead>{t("reviewStatus")}</TableHead>
<TableHead>{t("status")}</TableHead> <TableHead>{t("status")}</TableHead>
<TableHead /> <TableHead />
@@ -256,18 +256,18 @@ export function SettlementBatchesConsole() {
<TableRow key={row.id}> <TableRow key={row.id}>
<TableCell className="font-mono text-xs">{row.id}</TableCell> <TableCell className="font-mono text-xs">{row.id}</TableCell>
<TableCell className="font-mono text-sm">{row.draw_no ?? "—"}</TableCell> <TableCell className="font-mono text-sm">{row.draw_no ?? "—"}</TableCell>
<TableCell className="text-right font-mono text-xs tabular-nums"> <TableCell className="text-center font-mono text-xs tabular-nums">
{formatAdminMinorUnits(row.total_bet_amount, row.currency_code ?? "NPR")} {formatAdminMinorUnits(row.total_bet_amount, row.currency_code ?? "NPR")}
</TableCell> </TableCell>
<TableCell className="text-right font-mono text-xs tabular-nums"> <TableCell className="text-center font-mono text-xs tabular-nums">
{formatAdminMinorUnits(row.total_actual_deduct, row.currency_code ?? "NPR")} {formatAdminMinorUnits(row.total_actual_deduct, row.currency_code ?? "NPR")}
</TableCell> </TableCell>
<TableCell className="text-right font-mono text-xs tabular-nums"> <TableCell className="text-center font-mono text-xs tabular-nums">
{formatAdminMinorUnits(row.total_payout_amount, row.currency_code ?? "NPR")} {formatAdminMinorUnits(row.total_payout_amount, row.currency_code ?? "NPR")}
</TableCell> </TableCell>
<TableCell <TableCell
className={cn( className={cn(
"text-right font-mono text-xs tabular-nums", "text-center font-mono text-xs tabular-nums",
row.platform_profit < 0 ? "text-destructive" : "text-emerald-700", row.platform_profit < 0 ? "text-destructive" : "text-emerald-700",
)} )}
> >
@@ -288,7 +288,7 @@ export function SettlementBatchesConsole() {
</AdminStatusBadge> </AdminStatusBadge>
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex flex-wrap justify-end gap-1.5"> <div className="flex flex-wrap justify-center gap-1.5">
<Link <Link
href={`/admin/settlement-batches/${row.id}/details`} href={`/admin/settlement-batches/${row.id}/details`}
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "!border-border")} className={cn(buttonVariants({ variant: "outline", size: "sm" }), "!border-border")}

View File

@@ -308,11 +308,11 @@ export function PlayerTicketsConsole(): React.ReactElement {
<TableHead>{t("drawNo")}</TableHead> <TableHead>{t("drawNo")}</TableHead>
<TableHead>{t("playCode")}</TableHead> <TableHead>{t("playCode")}</TableHead>
<TableHead>{t("number")}</TableHead> <TableHead>{t("number")}</TableHead>
<TableHead className="text-right">{t("betAmount")}</TableHead> <TableHead className="text-center">{t("betAmount")}</TableHead>
<TableHead className="text-right">{t("actualDeduct")}</TableHead> <TableHead className="text-center">{t("actualDeduct")}</TableHead>
<TableHead>{t("status")}</TableHead> <TableHead>{t("status")}</TableHead>
<TableHead>{t("failReason")}</TableHead> <TableHead>{t("failReason")}</TableHead>
<TableHead className="text-right">{t("winAmount")}</TableHead> <TableHead className="text-center">{t("winAmount")}</TableHead>
<TableHead>{t("placedAt")}</TableHead> <TableHead>{t("placedAt")}</TableHead>
<TableHead>{t("updatedAt")}</TableHead> <TableHead>{t("updatedAt")}</TableHead>
</TableRow> </TableRow>
@@ -349,10 +349,10 @@ export function PlayerTicketsConsole(): React.ReactElement {
<TableCell className="font-mono text-xs">{row.draw_no ?? "—"}</TableCell> <TableCell className="font-mono text-xs">{row.draw_no ?? "—"}</TableCell>
<TableCell className="text-xs">{playCodeLabel(row.play_code)}</TableCell> <TableCell className="text-xs">{playCodeLabel(row.play_code)}</TableCell>
<TableCell className="font-mono text-xs">{row.original_number ?? "—"}</TableCell> <TableCell className="font-mono text-xs">{row.original_number ?? "—"}</TableCell>
<TableCell className="text-right tabular-nums text-xs"> <TableCell className="text-center tabular-nums text-xs">
{row.total_bet_amount_formatted} {row.total_bet_amount_formatted}
</TableCell> </TableCell>
<TableCell className="text-right tabular-nums text-xs"> <TableCell className="text-center tabular-nums text-xs">
{row.actual_deduct_amount_formatted} {row.actual_deduct_amount_formatted}
</TableCell> </TableCell>
<TableCell className="text-xs"> <TableCell className="text-xs">
@@ -363,7 +363,7 @@ export function PlayerTicketsConsole(): React.ReactElement {
<TableCell className="max-w-[14rem] text-xs text-muted-foreground"> <TableCell className="max-w-[14rem] text-xs text-muted-foreground">
{row.fail_reason_text ?? row.fail_reason_code ?? "—"} {row.fail_reason_text ?? row.fail_reason_code ?? "—"}
</TableCell> </TableCell>
<TableCell className="text-right tabular-nums text-xs">{winLabel}</TableCell> <TableCell className="text-center tabular-nums text-xs">{winLabel}</TableCell>
<TableCell className="text-xs">{formatTs(row.placed_at)}</TableCell> <TableCell className="text-xs">{formatTs(row.placed_at)}</TableCell>
<TableCell className="text-xs">{formatTs(row.updated_at)}</TableCell> <TableCell className="text-xs">{formatTs(row.updated_at)}</TableCell>
</TableRow> </TableRow>