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;
}
[data-slot="table-cell"]:has(> [data-slot="button"]),
[data-slot="table-cell"]:has(> a) {
[data-slot="table-head"],
[data-slot="table-cell"] {
text-align: center;
}
[data-slot="table-cell"] > .flex:has([data-slot="button"]),
[data-slot="table-cell"] > .flex:has(a) {
justify-content: center;
[data-slot="table-cell"] > .flex,
[data-slot="table-head"] > .flex {
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",
] 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();
if (normalized === "") {
return true;
@@ -33,9 +36,10 @@ function stripOmittedColumns(table: HTMLTableElement): HTMLTableElement {
const omitIndexes = Array.from(headerRow.children)
.map((cell, index) => ({
index,
cell,
text: cell.textContent ?? "",
}))
.filter((item) => shouldOmitColumn(item.text))
.filter((item) => shouldOmitColumn(item.cell, item.text))
.map((item) => item.index)
.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
data-slot="table-head"
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
)}
{...props}
@@ -83,7 +83,7 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
<td
data-slot="table-cell"
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
)}
{...props}

View File

@@ -47,6 +47,7 @@
"effectiveAt": "Effective at: {{value}}",
"note": "Note: {{value}}",
"current": "Current",
"moreActions": "More actions for v{{version}}",
"selected": "Selected",
"view": "View",
"rollback": "Rollback",
@@ -54,8 +55,11 @@
"deleteConfirmTitle": "Delete this version?",
"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": {
"publishCurrent": "Set as current version",
"publishCurrent": "Publish",
"refreshing": "Refreshing",
"refresh": "Refresh versions",
"newDraft": "New draft",
@@ -194,7 +198,8 @@
"batchSwitchEnable": "Enable",
"batchSwitchDisable": "Disable",
"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",
"toggleDisable": "Disable",
"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.",
"validation": {
"drawNoRequired": "Please enter a draw number",
"drawNoNotFound": "Draw number «{{drawNo}}» was not found",
"drawNoNumberRequired": "Please enter both draw number and number"
},
"formats": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -25,16 +25,17 @@ type ConfigChipProps = {
onClick: () => void;
children: ReactNode;
disabled?: boolean;
className?: string;
};
export function ConfigChip({ active, onClick, children, disabled }: ConfigChipProps) {
export function ConfigChip({ active, onClick, children, disabled, className }: ConfigChipProps) {
return (
<Button
type="button"
variant={active ? "default" : "outline"}
size="sm"
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}
>
{children}

View File

@@ -12,6 +12,7 @@ type ConfigDocPageProps = {
/** Category / play-type chips etc., rendered above the version toolbar. */
filters?: ReactNode;
toolbar?: ReactNode;
/** @deprecated Pass `footer` on `ConfigDocToolbar` instead. */
context?: ReactNode;
children: ReactNode;
className?: string;
@@ -21,23 +22,29 @@ type ConfigDocPageProps = {
export function ConfigDocToolbar({
switcher,
actions,
footer,
className,
}: {
switcher: ReactNode;
actions: ReactNode;
/** Live version / read-only hint — rendered as a subtle line under the main row. */
footer?: ReactNode;
className?: string;
}) {
return (
<div
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,
)}
>
<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="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>
{footer ? (
<div className="border-t border-border/50 bg-muted/20 px-3 py-2.5 sm:px-4">{footer}</div>
) : null}
</div>
);
}
@@ -65,8 +72,8 @@ export function ConfigDocPage({
{description ? <CardDescription className="text-base">{description}</CardDescription> : null}
</CardHeader>
<CardContent className={cn("space-y-6 pt-6", contentClassName)}>
{filters}
{toolbar}
{filters}
{context}
{children}
</CardContent>

View File

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

View File

@@ -1,7 +1,7 @@
"use client";
import { useMemo, useState } from "react";
import { Layers } from "lucide-react";
import { Check, ChevronRight, Layers, MoreHorizontal } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
@@ -13,6 +13,12 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Sheet,
SheetContent,
@@ -41,6 +47,27 @@ export type ConfigVersionSwitcherProps = {
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({
versions,
selectedId,
@@ -85,14 +112,13 @@ export function ConfigVersionSwitcher({
[selectedId, sortedVersions],
);
const statusCounts = useMemo(
const visibleSections = useMemo(
() =>
STATUS_ORDER.map((status) => ({
status,
label: t(`versionStatus.${status}`, { ns: "config" }),
count: groupedVersions.get(status)?.length ?? 0,
})),
[groupedVersions, t],
rows: groupedVersions.get(status) ?? [],
})).filter((section) => section.rows.length > 0),
[groupedVersions],
);
function switchTo(id: number) {
@@ -118,15 +144,14 @@ export function ConfigVersionSwitcher({
return (
<>
<div className={cn("flex min-w-0 items-center gap-2", className)}>
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
<div className={cn("flex min-w-0 items-center gap-3", className)}>
<div className="flex min-w-0 flex-1 items-center gap-2">
{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}
</span>
<ConfigStatusBadge status={selectedVersion.status} />
<span className="text-sm text-muted-foreground">#{selectedVersion.id}</span>
<ConfigStatusBadge status={selectedVersion.status} className="h-5 px-1.5 text-[11px]" />
</>
) : (
<span className="text-sm text-muted-foreground">
@@ -138,12 +163,13 @@ export function ConfigVersionSwitcher({
</div>
<Button
type="button"
variant="outline"
variant="ghost"
size="sm"
disabled={loading || sortedVersions.length === 0}
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" })}
</Button>
</div>
@@ -151,172 +177,142 @@ export function ConfigVersionSwitcher({
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetContent
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-2 text-left">
<SheetTitle className="text-base font-semibold tracking-tight text-foreground">
{resolvedSheetTitle}
</SheetTitle>
{resolvedSheetDescription ? (
<SheetDescription className="max-w-[320px] text-sm leading-relaxed text-muted-foreground">
{resolvedSheetDescription}
</SheetDescription>
) : null}
</SheetHeader>
</div>
<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">
<SheetHeader className="space-y-1 border-b border-border/60 px-5 py-4 text-left">
<SheetTitle className="text-[15px] font-semibold tracking-tight">
{resolvedSheetTitle}
</SheetTitle>
{resolvedSheetDescription ? (
<SheetDescription className="text-[13px] leading-relaxed">
{resolvedSheetDescription}
</SheetDescription>
) : null}
</SheetHeader>
<div className="flex-1 overflow-y-auto px-3 py-3">
{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" })}
</div>
</p>
) : (
STATUS_ORDER.map((status) => {
const rows = groupedVersions.get(status) ?? [];
if (rows.length === 0) {
return null;
}
return (
<section key={status} className="border-b border-border/60 pb-4 last:border-b-0 last:pb-0">
<div className="mb-2 flex items-center justify-between px-1">
<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) => {
<div className="space-y-5">
{visibleSections.map((section) => (
<section key={section.status}>
<p className="mb-2 px-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
{t(`versionStatus.${section.status}`, { ns: "config" })}
</p>
<ul className="space-y-1">
{section.rows.map((v) => {
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 (
<div
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",
)}
>
<li key={v.id}>
<div
className={cn(
"mt-1 h-auto w-1 shrink-0 rounded-full bg-border",
v.status === "draft" && "bg-muted-foreground/60",
v.status === "active" && "bg-primary",
v.status === "archived" && "bg-muted-foreground/30",
"group flex items-stretch gap-0.5 rounded-lg border transition-colors",
isCurrent
? "border-primary/40 bg-primary/[0.04]"
: "border-transparent hover:border-border/60 hover:bg-muted/30",
)}
/>
<div className="min-w-0 flex-1 space-y-2">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1.5">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-lg font-semibold leading-none tabular-nums text-foreground">
>
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-3 px-3 py-2.5 text-left"
onClick={() => switchTo(v.id)}
>
<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}
</span>
<ConfigStatusBadge status={v.status} />
<span className="rounded-full bg-muted px-2 py-0.5 text-sm font-medium tabular-nums text-muted-foreground">
#{v.id}
</span>
<ConfigStatusBadge
status={v.status}
className="h-5 px-1.5 text-[11px]"
/>
</div>
<p className="line-clamp-2 text-sm leading-relaxed text-muted-foreground">
{t("versionSwitcher.effectiveAt", {
ns: "config",
value: v.effective_at ? formatDt(v.effective_at) : "—",
})}
{v.reason
? ` · ${t("versionSwitcher.note", {
ns: "config",
value: v.reason,
})}`
: ""}
</p>
{meta ? (
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{meta}
</p>
) : null}
</div>
{isCurrent ? (
<span className="shrink-0 rounded-full bg-primary px-2.5 py-1 text-sm font-medium text-primary-foreground">
{t("versionSwitcher.current", { ns: "config" })}
<span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Check className="size-3.5" strokeWidth={2.5} aria-hidden />
<span className="sr-only">
{t("versionSwitcher.current", { ns: "config" })}
</span>
</span>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant={isCurrent ? "secondary" : "outline"}
size="sm"
className={cn(
"h-9 rounded-full px-3 text-sm",
isCurrent && "bg-muted text-muted-foreground hover:bg-muted",
)}
onClick={() => switchTo(v.id)}
>
{isCurrent
? t("versionSwitcher.selected", { ns: "config" })
: t("versionSwitcher.view", { ns: "config" })}
</Button>
{onRollbackVersion && v.status !== "draft" ? (
<Button
type="button"
variant="ghost"
size="sm"
className="rounded-full text-sm text-muted-foreground hover:bg-muted hover:text-foreground"
disabled={rollbackBusy}
onClick={() => {
onRollbackVersion(v);
setSheetOpen(false);
}}
>
{t("versionSwitcher.rollback", { ns: "config" })}
</Button>
) : null}
{onDeleteVersion && v.status !== "active" ? (
<Button
type="button"
variant="ghost"
size="sm"
className="rounded-full text-sm text-destructive hover:bg-destructive/10 hover:text-destructive"
disabled={deletingId === v.id}
onClick={() => setDeleteTarget(v)}
>
{t("versionSwitcher.delete", { ns: "config" })}
</Button>
) : null}
</div>
) : (
<ChevronRight
className="size-4 shrink-0 text-muted-foreground/50 group-hover:text-muted-foreground"
aria-hidden
/>
)}
</button>
{showMenu ? (
<div className="flex shrink-0 items-center pr-1">
<DropdownMenu>
<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"
aria-label={t("versionSwitcher.moreActions", {
ns: "config",
version: v.version_no,
})}
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-36">
{onRollbackVersion && v.status !== "draft" ? (
<DropdownMenuItem
disabled={rollbackBusy}
onClick={() => {
onRollbackVersion(v);
setSheetOpen(false);
}}
>
{t("versionSwitcher.rollback", { ns: "config" })}
</DropdownMenuItem>
) : null}
{onDeleteVersion && v.status !== "active" ? (
<DropdownMenuItem
variant="destructive"
disabled={deletingId === v.id}
onClick={() => setDeleteTarget(v)}
>
{t("versionSwitcher.delete", { ns: "config" })}
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</div>
) : null}
</div>
</div>
</li>
);
})}
</div>
</ul>
</section>
);
})
))}
</div>
)}
</div>
</SheetContent>
</Sheet>
<Dialog open={deleteTarget !== null} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<DialogContent>
<DialogContent showCloseButton className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("versionSwitcher.deleteConfirmTitle", { ns: "config" })}</DialogTitle>
<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";
import { Button } from "@/components/ui/button";
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 {
ConfigVersionToolbarMeta,
ConfigVersionToolbarMetaEmphasis,
} from "@/modules/config/config-version-toolbar-meta";
import {
Dialog,
DialogContent,
@@ -34,6 +37,7 @@ import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher"
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { resolveAdminPlayTypeDisplayName } from "@/lib/admin-play-types";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { cn } from "@/lib/utils";
import { PRD_ODDS_MANAGE, PRD_REBATE_MANAGE } from "@/lib/admin-prd";
import { useAdminProfile } from "@/stores/admin-session";
import { LotteryApiBizError } from "@/types/api/errors";
@@ -77,16 +81,25 @@ function filterTypes(tab: CatTab, types: AdminPlayTypeRow[]): AdminPlayTypeRow[]
type OddsConfigDocScreenProps = {
/** 嵌入「赔率与回水」合并页时去掉外层 ConfigDocPage */
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 profile = useAdminProfile();
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_ODDS_MANAGE, PRD_REBATE_MANAGE]);
const formatDt = useAdminDateTimeFormatter();
const [types, setTypes] = useState<AdminPlayTypeRow[]>([]);
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 [draftRows, setDraftRows] = useState<OddsItemRow[]>([]);
const [loadingTypes, setLoadingTypes] = useState(true);
@@ -417,38 +430,42 @@ export function OddsConfigDocScreen({ embedded = false }: OddsConfigDocScreenPro
];
const filtersBlock = (
<div className="space-y-4 rounded-xl border border-border/60 bg-card p-4">
<ConfigChipGroup label={t("odds.category", { ns: "config" })}>
{catTabs.map((tab) => (
<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" })}>
{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
key={tab.id}
active={catTab === tab.id}
onClick={() => setCatTab(tab.id)}
key={type.play_code}
active={resolvedPlayCode === type.play_code}
onClick={() => setPlayCode(type.play_code)}
className="shrink-0"
>
{tab.label}
{resolveAdminPlayTypeDisplayName(type.play_code, i18n.language, type)}
</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>
) : (
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>
</div>
)}
</ConfigChipGroup>
</div>
);
const toolbarBlock = (
<ConfigDocToolbar
className={embedded ? "rounded-none border-0 shadow-none" : undefined}
switcher={
<ConfigVersionSwitcher
versions={list}
@@ -475,57 +492,64 @@ export function OddsConfigDocScreen({ embedded = false }: OddsConfigDocScreenPro
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 = (
<>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
{loadingDetail || loadingTypes ? (
<div className="flex min-h-[420px] items-center">
<p className="text-base text-muted-foreground">{t("odds.loadingDetails", { ns: "config" })}</p>
</div>
<p className="py-8 text-center text-sm text-muted-foreground">
{t("odds.loadingDetails", { ns: "config" })}
</p>
) : resolvedPlayCode ? (
<div className="grid min-h-[420px] gap-4 max-w-md">
{PRIZE_SCOPE_ORDER.map((scope) => {
const row = scopeRows[scope];
const hint = embedded ? null : PRIZE_SCOPE_MULTIPLIER_HINT[scope];
const idx = row ? rowIndex(resolvedPlayCode, scope) : -1;
return (
<div key={scope} className="grid gap-1">
<Label className="flex items-baseline gap-2">
{prizeScopeLabel(scope, t)}
{hint ? <span className="text-sm text-muted-foreground font-normal">{hint}</span> : null}
</Label>
{row && idx >= 0 ? (
<div className="flex flex-wrap items-center gap-2">
{canEditDraft ? (
<div
className={cn(
embedded ? "rounded-xl border border-border/60 bg-card p-4" : undefined,
)}
>
<div className="grid grid-cols-2 gap-x-4 gap-y-4 sm:grid-cols-3 lg:grid-cols-6">
{PRIZE_SCOPE_ORDER.map((scope) => {
const row = scopeRows[scope];
const hint = embedded ? null : PRIZE_SCOPE_MULTIPLIER_HINT[scope];
const idx = row ? rowIndex(resolvedPlayCode, scope) : -1;
return (
<div key={scope} className="grid min-w-0 gap-1.5">
<Label className="truncate text-xs font-medium text-muted-foreground">
{prizeScopeLabel(scope, t)}
{hint ? <span className="ml-1 font-normal">{hint}</span> : null}
</Label>
{row && idx >= 0 ? (
canEditDraft ? (
<Input
type="text"
inputMode="decimal"
className="h-9 max-w-[200px] font-mono tabular-nums"
className="h-9 w-full font-mono tabular-nums"
disabled={saving}
value={oddsMultiplierLabel(row.odds_value)}
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)}
</ConfigReadonlyValue>
)}
{!embedded ? (
<span className="text-sm text-muted-foreground tabular-nums">
{t("odds.multiplier", {
ns: "config",
value: oddsMultiplierLabel(row.odds_value),
currency: row.currency_code,
})}
</span>
) : null}
</div>
) : (
<p className="text-sm text-destructive">{t("odds.missingScopeRow", { ns: "config", scope })}</p>
)}
</div>
);
})}
<div className="grid gap-1 pt-2 border-t">
<Label>{t("odds.rebateRate", { ns: "config" })}</Label>
{canEditDraft ? (
<Input
type="text"
inputMode="decimal"
className="h-9 max-w-[200px] font-mono tabular-nums"
disabled={saving}
value={rebatePercentUi}
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}
)
) : (
<p className="text-xs text-destructive">{t("odds.missingScopeRow", { ns: "config", scope })}</p>
)}
</div>
);
})}
<div className="grid min-w-0 gap-1.5">
<Label className="truncate text-xs font-medium text-muted-foreground">
{t("odds.rebateRate", { ns: "config" })}
</Label>
{canEditDraft ? (
<Input
type="text"
inputMode="decimal"
className="h-9 w-full font-mono tabular-nums"
disabled={saving}
value={rebatePercentUi}
onChange={(e) => setRebateForPlayPercent(e.target.value)}
/>
) : (
<ConfigReadonlyValue mono className="h-9 w-full justify-center">
{rebatePercentUi}
</ConfigReadonlyValue>
)}
</div>
</div>
{!embedded ? (
<p className="mt-3 text-xs text-muted-foreground">{t("odds.rebateRateHint", { ns: "config" })}</p>
) : null}
</div>
) : null}
</>
@@ -649,10 +666,11 @@ export function OddsConfigDocScreen({ embedded = false }: OddsConfigDocScreenPro
if (embedded) {
return (
<div className="space-y-6">
{filtersBlock}
{toolbarBlock}
{contextBlock}
<div className="space-y-4">
<div className="overflow-hidden rounded-xl border border-border/60 bg-card">
{toolbarBlock}
{filtersBlock}
</div>
{mainBlock}
{dialogs}
</div>
@@ -664,7 +682,6 @@ export function OddsConfigDocScreen({ embedded = false }: OddsConfigDocScreenPro
title={t("nav.items.odds", { ns: "config" })}
filters={filtersBlock}
toolbar={toolbarBlock}
context={contextBlock}
>
{mainBlock}
{dialogs}

View File

@@ -10,16 +10,18 @@ import {
getPlayConfigVersion,
getPlayConfigVersions,
postPlayConfigVersion,
patchAdminPlayType,
publishPlayConfigVersion,
putPlayConfigItems,
} from "@/api/admin-config";
import { Button } from "@/components/ui/button";
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 {
ConfigVersionToolbarMeta,
ConfigVersionToolbarMetaEmphasis,
} from "@/modules/config/config-version-toolbar-meta";
import { ConfigSection } from "@/modules/config/config-section";
import { Switch } from "@/components/ui/switch";
import { ConfirmableSwitch } from "@/components/admin/confirmable-switch";
import {
Dialog,
DialogContent,
@@ -136,7 +138,7 @@ function buildPlayConfigSavePayload(
export function PlayConfigDocScreen() {
const { t } = useTranslation(["config", "adminUsers", "common"]);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const { request: requestConfirm, ConfirmDialog, busy: confirmBusy } = useConfirmAction();
const profile = useAdminProfile();
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_PLAY_SWITCH_MANAGE]);
const formatDt = useAdminDateTimeFormatter();
@@ -269,25 +271,10 @@ export function PlayConfigDocScreen() {
setDraftRows((prev) => prev.map((r) => (r.play_code === playCode ? { ...r, ...patch } : r)));
}
async function applyPlayToggleInstant(playCode: string, 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);
function applyBatchSwitch(group: PlayBatchSwitchGroup, enabled: boolean) {
setDraftRows((prev) =>
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(
@@ -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 ? (
<ConfigSection
@@ -476,7 +464,9 @@ export function PlayConfigDocScreen() {
>
<ConfigChipGroup>
{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 (
<div
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 text-muted-foreground">
{group.total > 0
? t("play.batchEnabledCount", {
ns: "config",
enabledCount: group.enabledCount,
total: group.total,
})
? isPartial
? t("play.batchPartialEnabled", {
ns: "config",
enabledCount: group.enabledCount,
total: group.total,
})
: t("play.batchEnabledCount", {
ns: "config",
enabledCount: group.enabledCount,
total: group.total,
})
: t("play.noPlayTypes", { ns: "config" })}
</p>
</div>
<Switch
<ConfirmableSwitch
checked={groupOn}
confirmBusy={confirmBusy}
disabled={!isDraft || saving || group.total === 0}
aria-label={t("play.aria.batchGroupSwitch", {
ns: "config",
@@ -537,8 +534,8 @@ export function PlayConfigDocScreen() {
<TableHead className="text-center">{t("play.table.playCode", { ns: "config" })}</TableHead>
<TableHead className="w-[100px] text-center">{t("play.table.category", { ns: "config" })}</TableHead>
<TableHead className="w-[88px] text-center">{t("play.table.status", { ns: "config" })}</TableHead>
<TableHead className="w-32 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-36 text-center">{t("play.table.displayName", { 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.maxBet", { 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 text-muted-foreground text-sm">{row.category ?? "—"}</TableCell>
<TableCell className="text-center">
<div className="flex justify-center">
<Switch
checked={row.is_enabled}
disabled={!isDraft || saving}
aria-label={t("play.aria.enablePlay", { ns: "config", playCode: row.play_code })}
onCheckedChange={(checked) => {
if (!isDraft) {
return;
}
const enabled = checked;
const action = enabled
? t("play.toggleEnable", { ns: "config" })
: t("play.toggleDisable", { ns: "config" });
requestConfirm({
title: t("play.toggleConfirmTitle", {
ns: "config",
action,
playCode: row.play_code,
}),
description: t("play.toggleConfirmDescription", { ns: "config" }),
confirmVariant: enabled ? "default" : "destructive",
onConfirm: () => {
updateConfigRow(row.play_code, { is_enabled: enabled });
void applyPlayToggleInstant(row.play_code, enabled);
},
});
}}
/>
</div>
{isDraft ? (
<div className="flex justify-center">
<ConfirmableSwitch
checked={row.is_enabled}
confirmBusy={confirmBusy}
disabled={saving}
aria-label={t("play.aria.enablePlay", { ns: "config", playCode: row.play_code })}
onCheckedChange={(enabled) => {
const action = enabled
? t("play.toggleEnable", { ns: "config" })
: t("play.toggleDisable", { ns: "config" });
requestConfirm({
title: t("play.toggleConfirmTitle", {
ns: "config",
action,
playCode: row.play_code,
}),
description: t("play.toggleConfirmDescription", { ns: "config" }),
confirmVariant: enabled ? "default" : "destructive",
onConfirm: () => {
updateConfigRow(row.play_code, { is_enabled: enabled });
},
});
}}
/>
</div>
) : (
<div className="flex justify-center">
<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 className="w-32 text-center">
<TableCell className="w-36 text-center">
{isDraft ? (
<Input
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}
value={row.display_name ?? ""}
placeholder={row.play_code}
@@ -604,12 +607,12 @@ export function PlayConfigDocScreen() {
</ConfigReadonlyValue>
)}
</TableCell>
<TableCell className="w-[96px] text-center">
<TableCell className="w-24 text-center">
{isDraft ? (
<Input
type="text"
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}
disabled={saving}
onChange={(e) => {
@@ -630,7 +633,7 @@ export function PlayConfigDocScreen() {
<Input
type="text"
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}
value={formatAdminMinorDecimal(row.min_bet_amount, amountCurrencyCode)}
onChange={(e) =>
@@ -651,7 +654,7 @@ export function PlayConfigDocScreen() {
<Input
type="text"
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}
value={formatAdminMinorDecimal(row.max_bet_amount, amountCurrencyCode)}
onChange={(e) =>

View File

@@ -14,9 +14,12 @@ import {
publishOddsVersion,
putOddsItems,
} from "@/api/admin-config";
import { ConfigContextBanner, ConfigContextEmphasis } from "@/modules/config/config-context-banner";
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 { Label } from "@/components/ui/label";
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
@@ -54,9 +57,15 @@ function inferPercentFrom(dim: 2 | 3 | 4, rows: OddsItemRow[], typeList: AdminPl
type RebateConfigDocScreenProps = {
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 { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const profile = useAdminProfile();
@@ -65,7 +74,9 @@ export function RebateConfigDocScreen({ embedded = false }: RebateConfigDocScree
const [types, setTypes] = useState<AdminPlayTypeRow[]>([]);
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 [draftRows, setDraftRows] = useState<OddsItemRow[]>([]);
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 = (
<>
<div className="grid gap-5 sm:grid-cols-3">
@@ -392,10 +406,10 @@ export function RebateConfigDocScreen({ embedded = false }: RebateConfigDocScree
</div>
<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">
{t("rebate.winEnjoy.label", { ns: "config" })}
</Label>
<Switch id="win-enjoy" checked disabled aria-label={t("rebate.winEnjoy.label", { ns: "config" })} />
<p className="text-sm font-medium">{t("rebate.winEnjoy.label", { ns: "config" })}</p>
<AdminStatusBadge status="enabled">
{t("system.states.enabled", { ns: "config" })}
</AdminStatusBadge>
</div>
{!embedded ? (
@@ -415,8 +429,7 @@ export function RebateConfigDocScreen({ embedded = false }: RebateConfigDocScree
if (embedded) {
return (
<div className="space-y-6">
{contextBlock}
<div className="space-y-4">
{fieldsBlock}
<ConfirmDialog />
</div>
@@ -427,7 +440,6 @@ export function RebateConfigDocScreen({ embedded = false }: RebateConfigDocScree
<ConfigDocPage
title={t("nav.items.rebate", { ns: "config" })}
toolbar={toolbarBlock}
context={contextBlock}
>
{fieldsBlock}
<ConfirmDialog />

View File

@@ -14,8 +14,11 @@ import {
putRiskCapItems,
} from "@/api/admin-config";
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 {
ConfigVersionToolbarMeta,
ConfigVersionToolbarMetaEmphasis,
} from "@/modules/config/config-version-toolbar-meta";
import { ConfigSection } from "@/modules/config/config-section";
import {
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"
>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
@@ -447,8 +456,8 @@ export function RiskCapDocScreen() {
<TableRow>
<TableHead className="w-[110px]">{t("riskCap.table.number", { ns: "config" })}</TableHead>
<TableHead className="w-[140px]">{t("riskCap.table.capAmount", { ns: "config" })}</TableHead>
<TableHead className="w-[90px] text-right">{t("riskCap.table.used", { ns: "config" })}</TableHead>
<TableHead className="w-[90px] text-right">{t("riskCap.table.remaining", { ns: "config" })}</TableHead>
<TableHead className="w-[90px] text-center">{t("riskCap.table.used", { 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-[160px]">{t("riskCap.table.actions", { ns: "config" })}</TableHead>
</TableRow>
@@ -494,8 +503,8 @@ export function RiskCapDocScreen() {
</ConfigReadonlyValue>
)}
</TableCell>
<TableCell className="text-right 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 tabular-nums text-sm"></TableCell>
<TableCell className="text-center text-muted-foreground text-sm"></TableCell>
<TableCell>
{canEditDraft ? (
@@ -546,9 +555,9 @@ export function RiskCapDocScreen() {
<TableHeader>
<TableRow>
<TableHead>{t("riskCap.table.number", { ns: "config" })}</TableHead>
<TableHead className="text-right">{t("riskCap.table.used", { ns: "config" })}</TableHead>
<TableHead className="text-right">{t("riskCap.table.remaining", { ns: "config" })}</TableHead>
<TableHead className="text-right">{t("riskCap.table.ratio", { ns: "config" })}</TableHead>
<TableHead className="text-center">{t("riskCap.table.used", { ns: "config" })}</TableHead>
<TableHead className="text-center">{t("riskCap.table.remaining", { 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="w-[140px]">{t("riskCap.table.actions", { ns: "config" })}</TableHead>
</TableRow>
@@ -557,15 +566,11 @@ export function RiskCapDocScreen() {
{occFiltered.map((r) => (
<TableRow key={`occ-${r.clientKey}`}>
<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>
<Button type="button" variant="ghost" disabled>
{t("riskCap.actions.close", { ns: "config" })}
</Button>
</TableCell>
<TableCell className="text-center text-muted-foreground"></TableCell>
<TableCell className="text-center text-muted-foreground"></TableCell>
<TableCell className="text-center text-muted-foreground"></TableCell>
<TableCell className="text-center text-muted-foreground"></TableCell>
</TableRow>
))}
</TableBody>

View File

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

View File

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

View File

@@ -191,10 +191,10 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
<TableRow>
<TableHead className="w-20">{t("table.id", { ns: "common" })}</TableHead>
<TableHead>{t("status")}</TableHead>
<TableHead className="text-right">{t("ticketCount")}</TableHead>
<TableHead className="text-right">{t("winCount")}</TableHead>
<TableHead className="text-right">{t("payoutTotal")}</TableHead>
<TableHead className="text-right">{t("jackpotPayout")}</TableHead>
<TableHead className="text-center">{t("ticketCount")}</TableHead>
<TableHead className="text-center">{t("winCount")}</TableHead>
<TableHead className="text-center">{t("payoutTotal")}</TableHead>
<TableHead className="text-center">{t("jackpotPayout")}</TableHead>
<TableHead>{t("finishedAt")}</TableHead>
</TableRow>
</TableHeader>
@@ -207,16 +207,16 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
{settlementBatchStatusLabel(b.status, t)}
</AdminStatusBadge>
</TableCell>
<TableCell className="text-right tabular-nums text-xs">
<TableCell className="text-center tabular-nums text-xs">
{b.total_ticket_count}
</TableCell>
<TableCell className="text-right tabular-nums text-xs">
<TableCell className="text-center tabular-nums text-xs">
{b.total_win_count}
</TableCell>
<TableCell className="text-right tabular-nums text-xs">
<TableCell className="text-center tabular-nums text-xs">
{formatMoney(b.total_payout_amount)}
</TableCell>
<TableCell className="text-right tabular-nums text-xs">
<TableCell className="text-center tabular-nums text-xs">
{formatMoney(b.total_jackpot_payout_amount)}
</TableCell>
<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("version", { version: "" }).replace(" v", "").trim()}</TableHead>
<TableHead>{t("numberCount")}</TableHead>
<TableHead className="text-right">{t("actions")}</TableHead>
<TableHead className="text-center">{t("actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -213,7 +213,7 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
<TableCell className="font-mono text-xs">{b.id}</TableCell>
<TableCell>v{b.result_version}</TableCell>
<TableCell>{b.items.length}</TableCell>
<TableCell className="text-right">
<TableCell className="text-center">
{canManageDraw ? (
<Link
href={`/admin/draws/${drawId}/publish/${b.id}`}

View File

@@ -395,10 +395,10 @@ export function DrawsIndexConsole() {
<TableHead>{t("closeTime")}</TableHead>
<TableHead>{t("drawTime")}</TableHead>
<TableHead>{t("status")}</TableHead>
<TableHead className="text-right">{t("betTotal")}</TableHead>
<TableHead className="text-right">{t("payoutTotal")}</TableHead>
<TableHead className="text-right">{t("profitLoss")}</TableHead>
<TableHead className="text-right">{t("actions")}</TableHead>
<TableHead className="text-center">{t("betTotal")}</TableHead>
<TableHead className="text-center">{t("payoutTotal")}</TableHead>
<TableHead className="text-center">{t("profitLoss")}</TableHead>
<TableHead className="text-center">{t("actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -435,19 +435,19 @@ export function DrawsIndexConsole() {
label={drawStatusLabel(row.status, t)}
/>
</TableCell>
<TableCell className="text-right text-xs tabular-nums">
<TableCell className="text-center text-xs tabular-nums">
{row.total_bet_minor != null
? formatAdminMinorUnits(row.total_bet_minor, defaultCurrency)
: "—"}
</TableCell>
<TableCell className="text-right text-xs tabular-nums">
<TableCell className="text-center text-xs tabular-nums">
{row.total_payout_minor != null
? formatAdminMinorUnits(row.total_payout_minor, defaultCurrency)
: "—"}
</TableCell>
<TableCell
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",
)}
>
@@ -455,8 +455,8 @@ export function DrawsIndexConsole() {
? formatAdminMinorUnits(row.profit_loss_minor, defaultCurrency)
: "—"}
</TableCell>
<TableCell className="text-right">
<div className="flex flex-wrap items-center justify-end gap-1.5">
<TableCell className="text-center">
<div className="flex flex-wrap items-center justify-center gap-1.5">
<Link
href={`/admin/draws/${row.id}`}
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-[11rem]">{t("drawNo")}</TableHead>
<TableHead className="w-28">{t("trigger")}</TableHead>
<TableHead className="w-32 text-right">{t("payoutAmount")}</TableHead>
<TableHead className="w-24 text-right">{t("winnerCount")}</TableHead>
<TableHead className="w-32 text-center">{t("payoutAmount")}</TableHead>
<TableHead className="w-24 text-center">{t("winnerCount")}</TableHead>
<TableHead className="w-[11rem]">{t("time")}</TableHead>
</TableRow>
</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.draw_no ?? "—"}</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")}
</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">
{formatDt(r.created_at)}
</TableCell>
@@ -293,7 +293,7 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
<TableHead className="w-[11rem]">{t("drawNo")}</TableHead>
<TableHead className="w-[11rem]">{t("ticketNo")}</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>
</TableRow>
</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.ticket_no ?? "—"}</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")}
</TableCell>
<TableCell className="text-xs text-muted-foreground whitespace-nowrap">

View File

@@ -2,6 +2,7 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { useExportLabels } from "@/hooks/use-export-labels";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -16,8 +17,10 @@ import {
} from "@/api/admin-player";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
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 { Switch } from "@/components/ui/switch";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
@@ -66,7 +69,8 @@ const PLAYER_STATUS_OPTIONS = [
export function PlayersConsole(): React.ReactElement {
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 profile = useAdminProfile();
useAdminCurrencyCatalog();
@@ -338,8 +342,8 @@ export function PlayersConsole(): React.ReactElement {
<TableHead>{t("username")}</TableHead>
<TableHead>{t("nickname")}</TableHead>
<TableHead className="whitespace-nowrap">{t("currency")}</TableHead>
<TableHead className="whitespace-nowrap text-right">{t("balance")}</TableHead>
<TableHead className="whitespace-nowrap text-right">{t("available")}</TableHead>
<TableHead className="whitespace-nowrap text-center">{t("balance")}</TableHead>
<TableHead className="whitespace-nowrap text-center">{t("available")}</TableHead>
<TableHead className="w-20 whitespace-nowrap">{t("status")}</TableHead>
<TableHead className="whitespace-nowrap">{t("lastLogin")}</TableHead>
<TableHead className="min-w-[10rem]">{t("actions")}</TableHead>
@@ -365,21 +369,28 @@ export function PlayersConsole(): React.ReactElement {
<TableCell>{row.username ?? "—"}</TableCell>
<TableCell>{row.nickname ?? "—"}</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
? formatAdminMinorUnits(row.wallets[0].balance, row.wallets[0].currency_code)
: "—"}
</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
? formatAdminMinorUnits(row.wallets[0].available_balance, row.wallets[0].currency_code)
: "—"}
</TableCell>
<TableCell>
{canFreezePlayers ? (
{row.status === 2 ? (
<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}
confirmBusy={confirmBusy}
disabled={freezeBusyId === row.id}
aria-label={t("status")}
onCheckedChange={(checked) => {
@@ -407,15 +418,7 @@ export function PlayersConsole(): React.ReactElement {
)}
</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{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",
})
: "—"}
{row.last_login_at ? formatDt(row.last_login_at) : "—"}
</TableCell>
<TableCell>
{canManagePlayers || canFreezePlayers ? (

View File

@@ -312,20 +312,25 @@ function parsePositiveInteger(value: string): number | 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()) {
return { id: filters.drawId, draw_no: filters.drawNo.trim() };
}
const drawNo = filters.drawNo.trim();
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 matched = data.items.find((item) => item.draw_no === drawNo) ?? data.items[0];
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 };
}
@@ -459,7 +464,7 @@ export function ReportsConsole() {
try {
switch (selectedReport.key) {
case "draw_profit": {
const draw = await resolveDraw(filters);
const draw = await resolveDraw(filters, t);
const summary = await getAdminDrawFinanceSummary(draw.id);
setResult({
key: "draw_profit",
@@ -580,7 +585,7 @@ export function ReportsConsole() {
if (!filters.number.trim()) {
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 rows: ExportRow[] = [
{
@@ -627,7 +632,7 @@ export function ReportsConsole() {
break;
}
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 rows = payload.items.map((item) => ({
draw_id: payload.draw_id,
@@ -1022,22 +1027,22 @@ export function ReportsConsole() {
<TableRow>
<TableCell className="font-medium">{summary.draw_no}</TableCell>
<TableCell>{summary.draw_status}</TableCell>
<TableCell className="text-right">{summary.order_count}</TableCell>
<TableCell className="text-right">{summary.ticket_item_count}</TableCell>
<TableCell className="text-right">{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-right">{formatPlainMoney(summary.approx_house_gross_minor, summary.currency_code)}</TableCell>
<TableCell className="text-center">{summary.order_count}</TableCell>
<TableCell className="text-center">{summary.ticket_item_count}</TableCell>
<TableCell className="text-center">{formatPlainMoney(summary.total_bet_minor, summary.currency_code)}</TableCell>
<TableCell className="text-center">{formatPlainMoney(summary.total_payout_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>
</TableRow>
{summary.settlement_batches.map((batch) => (
<TableRow key={batch.id} className="bg-muted/15">
<TableCell>#{batch.id}</TableCell>
<TableCell>{batch.status}</TableCell>
<TableCell className="text-right">{batch.total_ticket_count}</TableCell>
<TableCell className="text-right">{batch.total_win_count}</TableCell>
<TableCell className="text-right">-</TableCell>
<TableCell className="text-right">{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">{batch.total_ticket_count}</TableCell>
<TableCell className="text-center">{batch.total_win_count}</TableCell>
<TableCell className="text-center">-</TableCell>
<TableCell className="text-center">{formatPlainMoney(batch.total_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>
</TableRow>
))}
@@ -1052,7 +1057,7 @@ export function ReportsConsole() {
<TableCell>{optionText(item.username, item.nickname) || item.player_id}</TableCell>
<TableCell>{item.direction}</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.fail_reason || "-"}</TableCell>
<TableCell>{formatTs(item.created_at)}</TableCell>
@@ -1066,9 +1071,9 @@ export function ReportsConsole() {
<TableRow>
<TableCell className="font-medium">{result.raw.pool.normalized_number}</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-right">{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.total_cap_amount, result.raw.currency_code)}</TableCell>
<TableCell className="text-center">{formatPlainMoney(result.raw.pool.locked_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.usage_ratio == null ? "-" : `${result.raw.pool.usage_ratio}%`}</TableCell>
<TableCell>v{result.raw.pool.version}</TableCell>
@@ -1077,7 +1082,7 @@ export function ReportsConsole() {
<TableRow key={item.id} className="bg-muted/15">
<TableCell className="font-mono text-xs">#{item.id}</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>{item.ticket_no || "-"}</TableCell>
<TableCell>{item.player_id || "-"}</TableCell>
@@ -1094,9 +1099,9 @@ export function ReportsConsole() {
<TableRow key={item.normalized_number}>
<TableCell className="font-medium">{item.normalized_number}</TableCell>
<TableCell>{filters.drawNo}</TableCell>
<TableCell className="text-right">{formatPlainMoney(item.total_cap_amount, null)}</TableCell>
<TableCell className="text-right">{formatPlainMoney(item.locked_amount, null)}</TableCell>
<TableCell className="text-right">{formatPlainMoney(item.remaining_amount, null)}</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.total_cap_amount, null)}</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.locked_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.usage_ratio == null ? "-" : `${item.usage_ratio}%`}</TableCell>
<TableCell>v{item.version}</TableCell>
@@ -1109,9 +1114,9 @@ export function ReportsConsole() {
<TableRow key={item.business_date}>
<TableCell className="font-medium">{item.business_date}</TableCell>
<TableCell>-</TableCell>
<TableCell className="text-right">{formatPlainMoney(item.total_bet_minor, "NPR")}</TableCell>
<TableCell className="text-right">{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.total_bet_minor, "NPR")}</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.total_payout_minor, "NPR")}</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.approx_house_gross_minor, "NPR")}</TableCell>
<TableCell>-</TableCell>
<TableCell>-</TableCell>
<TableCell>-</TableCell>
@@ -1124,9 +1129,9 @@ export function ReportsConsole() {
<TableRow key={item.player_id}>
<TableCell className="font-medium">{item.username}</TableCell>
<TableCell>ID {item.player_id}</TableCell>
<TableCell className="text-right">{formatPlainMoney(item.total_bet_minor, "NPR")}</TableCell>
<TableCell className="text-right">{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.total_bet_minor, "NPR")}</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.total_payout_minor, "NPR")}</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.net_win_loss_minor, "NPR")}</TableCell>
<TableCell>-</TableCell>
<TableCell>-</TableCell>
<TableCell>-</TableCell>
@@ -1139,9 +1144,9 @@ export function ReportsConsole() {
<TableRow key={`${item.play_code}-${item.dimension}`}>
<TableCell className="font-medium">{playCodeLabel(item.play_code)}</TableCell>
<TableCell>{item.dimension}D</TableCell>
<TableCell className="text-right">{formatPlainMoney(item.total_bet_minor, "NPR")}</TableCell>
<TableCell className="text-right">{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.total_bet_minor, "NPR")}</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.total_payout_minor, "NPR")}</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.approx_house_gross_minor, "NPR")}</TableCell>
<TableCell>-</TableCell>
<TableCell>-</TableCell>
<TableCell>-</TableCell>
@@ -1154,8 +1159,8 @@ export function ReportsConsole() {
<TableRow key={item.play_code}>
<TableCell className="font-medium">{playCodeLabel(item.play_code)}</TableCell>
<TableCell>{item.order_count}</TableCell>
<TableCell className="text-right">{formatPlainMoney(item.total_rebate_minor, "NPR")}</TableCell>
<TableCell className="text-right">{item.ticket_item_count}</TableCell>
<TableCell className="text-center">{formatPlainMoney(item.total_rebate_minor, "NPR")}</TableCell>
<TableCell className="text-center">{item.ticket_item_count}</TableCell>
<TableCell>-</TableCell>
<TableCell>-</TableCell>
<TableCell>-</TableCell>
@@ -1294,9 +1299,9 @@ export function ReportsConsole() {
<TableRow>
<TableHead>{t("preview.columns.primary")}</TableHead>
<TableHead>{t("preview.columns.secondary")}</TableHead>
<TableHead className="text-right">{t("preview.columns.metricA")}</TableHead>
<TableHead className="text-right">{t("preview.columns.metricB")}</TableHead>
<TableHead className="text-right">{t("preview.columns.metricC")}</TableHead>
<TableHead className="text-center">{t("preview.columns.metricA")}</TableHead>
<TableHead className="text-center">{t("preview.columns.metricB")}</TableHead>
<TableHead className="text-center">{t("preview.columns.metricC")}</TableHead>
<TableHead>{t("preview.columns.status")}</TableHead>
<TableHead>{t("preview.columns.extra")}</TableHead>
<TableHead>{t("preview.columns.time")}</TableHead>

View File

@@ -184,7 +184,7 @@ export function RiskIndexConsole() {
<TableHead>{t("drawNo")}</TableHead>
<TableHead>{t("status")}</TableHead>
<TableHead>{t("closeTime")}</TableHead>
<TableHead className="text-right">{t("actions")}</TableHead>
<TableHead className="text-center">{t("actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -204,7 +204,7 @@ export function RiskIndexConsole() {
<TableCell className="text-sm text-muted-foreground">
{row.close_time ? formatDt(row.close_time) : "—"}
</TableCell>
<TableCell className="text-right">
<TableCell className="text-center">
<Link
href={`/admin/draws/${row.id}/risk/occupancy`}
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("searchNumber")}</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("ticketNo")}</TableHead>
<TableHead>{t("playCode")}</TableHead>
@@ -184,7 +184,7 @@ export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
<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, data?.currency_code ?? "NPR")}
</TableCell>
<TableCell className="text-xs text-muted-foreground">

View File

@@ -163,7 +163,7 @@ export function RiskPoolDetailConsole({
<TableRow>
<TableHead>{t("time")}</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("ticketNo")}</TableHead>
<TableHead>{t("playCode")}</TableHead>
@@ -176,7 +176,7 @@ export function RiskPoolDetailConsole({
{row.created_at ? formatDt(row.created_at) : "—"}
</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)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ import {
putAdminCurrency,
} from "@/api/admin-currencies";
import { AdminPageCard } from "@/components/admin/admin-page-card";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { Button } from "@/components/ui/button";
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
import { Switch } from "@/components/ui/switch";
@@ -250,22 +251,20 @@ export function CurrencySettingsPanel() {
<TableCell>{row.name}</TableCell>
<TableCell>{row.decimal_places}</TableCell>
<TableCell>
<div className="flex justify-center">
<Switch
checked={row.is_enabled}
disabled
aria-label={t("currencies.form.enabled", { ns: "config", code: row.code })}
/>
</div>
<AdminStatusBadge status={row.is_enabled ? "enabled" : "disabled"}>
{row.is_enabled
? t("system.states.enabled", { ns: "config" })
: t("system.states.disabled", { ns: "config" })}
</AdminStatusBadge>
</TableCell>
<TableCell>
<div className="flex justify-center">
<Switch
checked={row.is_enabled && row.is_bettable}
disabled
aria-label={t("currencies.form.bettable", { ns: "config", code: row.code })}
/>
</div>
<AdminStatusBadge
status={row.is_enabled && row.is_bettable ? "enabled" : "disabled"}
>
{row.is_enabled && row.is_bettable
? t("system.states.enabled", { ns: "config" })
: t("system.states.disabled", { ns: "config" })}
</AdminStatusBadge>
</TableCell>
<TableCell className="text-center">
<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("player")}</TableHead>
<TableHead>{t("matchedTier")}</TableHead>
<TableHead className="text-right">{t("regularPayout")}</TableHead>
<TableHead className="text-right">{t("jackpot")}</TableHead>
<TableHead className="text-center">{t("regularPayout")}</TableHead>
<TableHead className="text-center">{t("jackpot")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -351,10 +351,10 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
{r.player_username ?? r.site_player_id ?? r.player_id ?? "—"}
</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)}
</TableCell>
<TableCell className="text-right font-mono text-xs tabular-nums">
<TableCell className="text-center font-mono text-xs tabular-nums">
{formatAdminMinorUnits(
r.jackpot_allocation_amount,
r.currency_code ?? batchCurrency,

View File

@@ -242,10 +242,10 @@ export function SettlementBatchesConsole() {
<TableRow>
<TableHead>{t("table.id", { ns: "common" })}</TableHead>
<TableHead>{t("drawNo")}</TableHead>
<TableHead className="text-right">{t("totalBet")}</TableHead>
<TableHead className="text-right">{t("actualDeduct")}</TableHead>
<TableHead className="text-right">{t("payoutTotal")}</TableHead>
<TableHead className="text-right">{t("platformProfit")}</TableHead>
<TableHead className="text-center">{t("totalBet")}</TableHead>
<TableHead className="text-center">{t("actualDeduct")}</TableHead>
<TableHead className="text-center">{t("payoutTotal")}</TableHead>
<TableHead className="text-center">{t("platformProfit")}</TableHead>
<TableHead>{t("reviewStatus")}</TableHead>
<TableHead>{t("status")}</TableHead>
<TableHead />
@@ -256,18 +256,18 @@ export function SettlementBatchesConsole() {
<TableRow key={row.id}>
<TableCell className="font-mono text-xs">{row.id}</TableCell>
<TableCell className="font-mono text-sm">{row.draw_no ?? "—"}</TableCell>
<TableCell className="text-right font-mono text-xs tabular-nums">
<TableCell className="text-center font-mono text-xs tabular-nums">
{formatAdminMinorUnits(row.total_bet_amount, row.currency_code ?? "NPR")}
</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")}
</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")}
</TableCell>
<TableCell
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",
)}
>
@@ -288,7 +288,7 @@ export function SettlementBatchesConsole() {
</AdminStatusBadge>
</TableCell>
<TableCell>
<div className="flex flex-wrap justify-end gap-1.5">
<div className="flex flex-wrap justify-center gap-1.5">
<Link
href={`/admin/settlement-batches/${row.id}/details`}
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("playCode")}</TableHead>
<TableHead>{t("number")}</TableHead>
<TableHead className="text-right">{t("betAmount")}</TableHead>
<TableHead className="text-right">{t("actualDeduct")}</TableHead>
<TableHead className="text-center">{t("betAmount")}</TableHead>
<TableHead className="text-center">{t("actualDeduct")}</TableHead>
<TableHead>{t("status")}</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("updatedAt")}</TableHead>
</TableRow>
@@ -349,10 +349,10 @@ export function PlayerTicketsConsole(): React.ReactElement {
<TableCell className="font-mono text-xs">{row.draw_no ?? "—"}</TableCell>
<TableCell className="text-xs">{playCodeLabel(row.play_code)}</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}
</TableCell>
<TableCell className="text-right tabular-nums text-xs">
<TableCell className="text-center tabular-nums text-xs">
{row.actual_deduct_amount_formatted}
</TableCell>
<TableCell className="text-xs">
@@ -363,7 +363,7 @@ export function PlayerTicketsConsole(): React.ReactElement {
<TableCell className="max-w-[14rem] text-xs text-muted-foreground">
{row.fail_reason_text ?? row.fail_reason_code ?? "—"}
</TableCell>
<TableCell className="text-right tabular-nums text-xs">{winLabel}</TableCell>
<TableCell className="text-center tabular-nums text-xs">{winLabel}</TableCell>
<TableCell className="text-xs">{formatTs(row.placed_at)}</TableCell>
<TableCell className="text-xs">{formatTs(row.updated_at)}</TableCell>
</TableRow>