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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
32
src/components/admin/confirmable-switch.tsx
Normal file
32
src/components/admin/confirmable-switch.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": "खेल स्विच तुरुन्त लागू गर्न असफल। पछि पुनः प्रयास गर्नुहोस्।",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"exportHint": "निर्यात API जोडिएपछि हालका फिल्टरअनुसार छानिएको फाइल ढाँचा बनाइनेछ।",
|
||||
"validation": {
|
||||
"drawNoRequired": "कृपया ड्र नं. प्रविष्ट गर्नुहोस्",
|
||||
"drawNoNotFound": "ड्र नं. «{{drawNo}}» फेला परेन",
|
||||
"drawNoNumberRequired": "कृपया ड्र नं. र नम्बर दुवै प्रविष्ट गर्नुहोस्"
|
||||
},
|
||||
"formats": {
|
||||
|
||||
@@ -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": "玩法开关即时生效失败,请稍后重试",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"exportHint": "接入导出接口后,会按当前条件生成对应格式的文件。",
|
||||
"validation": {
|
||||
"drawNoRequired": "请输入期号",
|
||||
"drawNoNotFound": "未找到期号「{{drawNo}}」",
|
||||
"drawNoNumberRequired": "请输入期号和号码"
|
||||
},
|
||||
"formats": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
40
src/modules/config/config-version-toolbar-meta.tsx
Normal file
40
src/modules/config/config-version-toolbar-meta.tsx
Normal 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>;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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" }))}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" }))}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user