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;
|
@apply text-sm text-muted-foreground;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="table-cell"]:has(> [data-slot="button"]),
|
[data-slot="table-head"],
|
||||||
[data-slot="table-cell"]:has(> a) {
|
[data-slot="table-cell"] {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-slot="table-cell"] > .flex:has([data-slot="button"]),
|
[data-slot="table-cell"] > .flex,
|
||||||
[data-slot="table-cell"] > .flex:has(a) {
|
[data-slot="table-head"] > .flex {
|
||||||
justify-content: center;
|
justify-content: center !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="table-cell"] > .flex.flex-col {
|
||||||
|
align-items: center !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="table-cell"] > .flex.flex-wrap {
|
||||||
|
justify-content: center !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="table-cell"] [data-slot="badge"],
|
||||||
|
[data-slot="table-cell"] [data-slot="switch"],
|
||||||
|
[data-slot="table-cell"] [role="checkbox"],
|
||||||
|
[data-slot="table-cell"] [data-slot="button"],
|
||||||
|
[data-slot="table-cell"] > a {
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="table-cell"]:has(> [data-slot="button"]),
|
||||||
|
[data-slot="table-cell"]:has(> a),
|
||||||
|
[data-slot="table-cell"]:has(> [role="checkbox"]),
|
||||||
|
[data-slot="table-cell"]:has(> [data-slot="switch"]) {
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ const OMIT_HEADER_TOKENS = [
|
|||||||
"download",
|
"download",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
function shouldOmitColumn(headerText: string): boolean {
|
function shouldOmitColumn(cell: Element, headerText: string): boolean {
|
||||||
|
if (cell.hasAttribute("data-export-ignore")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
const normalized = headerText.trim().toLowerCase();
|
const normalized = headerText.trim().toLowerCase();
|
||||||
if (normalized === "") {
|
if (normalized === "") {
|
||||||
return true;
|
return true;
|
||||||
@@ -33,9 +36,10 @@ function stripOmittedColumns(table: HTMLTableElement): HTMLTableElement {
|
|||||||
const omitIndexes = Array.from(headerRow.children)
|
const omitIndexes = Array.from(headerRow.children)
|
||||||
.map((cell, index) => ({
|
.map((cell, index) => ({
|
||||||
index,
|
index,
|
||||||
|
cell,
|
||||||
text: cell.textContent ?? "",
|
text: cell.textContent ?? "",
|
||||||
}))
|
}))
|
||||||
.filter((item) => shouldOmitColumn(item.text))
|
.filter((item) => shouldOmitColumn(item.cell, item.text))
|
||||||
.map((item) => item.index)
|
.map((item) => item.index)
|
||||||
.sort((a, b) => b - a);
|
.sort((a, b) => b - a);
|
||||||
|
|
||||||
|
|||||||
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
|
<th
|
||||||
data-slot="table-head"
|
data-slot="table-head"
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-11 px-3 text-left align-middle font-semibold tracking-wide whitespace-nowrap text-[#17305f] [&:has([role=checkbox])]:pr-0",
|
"h-11 px-3 text-center align-middle font-semibold tracking-wide whitespace-nowrap text-[#17305f] [&:has([role=checkbox])]:pr-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -83,7 +83,7 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
|||||||
<td
|
<td
|
||||||
data-slot="table-cell"
|
data-slot="table-cell"
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-3 py-2.5 text-left align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
|
"px-3 py-2.5 text-center align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -47,6 +47,7 @@
|
|||||||
"effectiveAt": "Effective at: {{value}}",
|
"effectiveAt": "Effective at: {{value}}",
|
||||||
"note": "Note: {{value}}",
|
"note": "Note: {{value}}",
|
||||||
"current": "Current",
|
"current": "Current",
|
||||||
|
"moreActions": "More actions for v{{version}}",
|
||||||
"selected": "Selected",
|
"selected": "Selected",
|
||||||
"view": "View",
|
"view": "View",
|
||||||
"rollback": "Rollback",
|
"rollback": "Rollback",
|
||||||
@@ -54,8 +55,11 @@
|
|||||||
"deleteConfirmTitle": "Delete this version?",
|
"deleteConfirmTitle": "Delete this version?",
|
||||||
"deleteConfirmDescription": "Version ID {{id}} (version_no {{version}}) will be permanently deleted. Active versions cannot be deleted."
|
"deleteConfirmDescription": "Version ID {{id}} (version_no {{version}}) will be permanently deleted. Active versions cannot be deleted."
|
||||||
},
|
},
|
||||||
|
"versionToolbar": {
|
||||||
|
"draftEditing": "Editing a draft — save and publish to go live"
|
||||||
|
},
|
||||||
"versionActions": {
|
"versionActions": {
|
||||||
"publishCurrent": "Set as current version",
|
"publishCurrent": "Publish",
|
||||||
"refreshing": "Refreshing",
|
"refreshing": "Refreshing",
|
||||||
"refresh": "Refresh versions",
|
"refresh": "Refresh versions",
|
||||||
"newDraft": "New draft",
|
"newDraft": "New draft",
|
||||||
@@ -194,7 +198,8 @@
|
|||||||
"batchSwitchEnable": "Enable",
|
"batchSwitchEnable": "Enable",
|
||||||
"batchSwitchDisable": "Disable",
|
"batchSwitchDisable": "Disable",
|
||||||
"toggleConfirmTitle": "{{action}} play {{playCode}}?",
|
"toggleConfirmTitle": "{{action}} play {{playCode}}?",
|
||||||
"toggleConfirmDescription": "This calls the API immediately (not draft-only).",
|
"toggleConfirmDescription": "This updates the current draft only. Players see changes after save and publish.",
|
||||||
|
"batchPartialEnabled": "{{enabledCount}}/{{total}} enabled (not all on — turn on to enable all)",
|
||||||
"toggleEnable": "Enable",
|
"toggleEnable": "Enable",
|
||||||
"toggleDisable": "Disable",
|
"toggleDisable": "Disable",
|
||||||
"toggleInstantFailed": "Failed to apply play switch. Try again later.",
|
"toggleInstantFailed": "Failed to apply play switch. Try again later.",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"exportHint": "Once export APIs are connected, the current filters will generate the selected file format.",
|
"exportHint": "Once export APIs are connected, the current filters will generate the selected file format.",
|
||||||
"validation": {
|
"validation": {
|
||||||
"drawNoRequired": "Please enter a draw number",
|
"drawNoRequired": "Please enter a draw number",
|
||||||
|
"drawNoNotFound": "Draw number «{{drawNo}}» was not found",
|
||||||
"drawNoNumberRequired": "Please enter both draw number and number"
|
"drawNoNumberRequired": "Please enter both draw number and number"
|
||||||
},
|
},
|
||||||
"formats": {
|
"formats": {
|
||||||
|
|||||||
@@ -47,6 +47,7 @@
|
|||||||
"effectiveAt": "लागू समय: {{value}}",
|
"effectiveAt": "लागू समय: {{value}}",
|
||||||
"note": "टिप्पणी: {{value}}",
|
"note": "टिप्पणी: {{value}}",
|
||||||
"current": "हाल हेर्दै",
|
"current": "हाल हेर्दै",
|
||||||
|
"moreActions": "v{{version}} थप कार्य",
|
||||||
"selected": "छानिएको",
|
"selected": "छानिएको",
|
||||||
"view": "हेर्नुहोस्",
|
"view": "हेर्नुहोस्",
|
||||||
"rollback": "रोलब्याक",
|
"rollback": "रोलब्याक",
|
||||||
@@ -54,8 +55,11 @@
|
|||||||
"deleteConfirmTitle": "यो संस्करण मेटाउने?",
|
"deleteConfirmTitle": "यो संस्करण मेटाउने?",
|
||||||
"deleteConfirmDescription": "संस्करण ID {{id}} (version_no {{version}}) स्थायी रूपमा मेटाइनेछ। सक्रिय संस्करण मेटाउन मिल्दैन।"
|
"deleteConfirmDescription": "संस्करण ID {{id}} (version_no {{version}}) स्थायी रूपमा मेटाइनेछ। सक्रिय संस्करण मेटाउन मिल्दैन।"
|
||||||
},
|
},
|
||||||
|
"versionToolbar": {
|
||||||
|
"draftEditing": "ड्राफ्ट सम्पादन — सेभ र प्रकाशित गरेपछि लाइभ हुन्छ"
|
||||||
|
},
|
||||||
"versionActions": {
|
"versionActions": {
|
||||||
"publishCurrent": "हालको संस्करण बनाउनुहोस्",
|
"publishCurrent": "प्रकाशित गर्नुहोस्",
|
||||||
"refreshing": "रिफ्रेस हुँदैछ",
|
"refreshing": "रिफ्रेस हुँदैछ",
|
||||||
"refresh": "संस्करण रिफ्रेस",
|
"refresh": "संस्करण रिफ्रेस",
|
||||||
"newDraft": "नयाँ ड्राफ्ट",
|
"newDraft": "नयाँ ड्राफ्ट",
|
||||||
@@ -194,7 +198,8 @@
|
|||||||
"batchSwitchEnable": "सक्रिय",
|
"batchSwitchEnable": "सक्रिय",
|
||||||
"batchSwitchDisable": "निष्क्रिय",
|
"batchSwitchDisable": "निष्क्रिय",
|
||||||
"toggleConfirmTitle": "खेल {{playCode}} {{action}} गर्ने?",
|
"toggleConfirmTitle": "खेल {{playCode}} {{action}} गर्ने?",
|
||||||
"toggleConfirmDescription": "यो तुरुन्त API मार्फत लागू हुन्छ (केवल ड्राफ्ट मात्र होइन)।",
|
"toggleConfirmDescription": "यो हालको ड्राफ्ट मात्र अपडेट गर्छ। सेभ र प्रकाशित गरेपछि खेलाडीलाई देखिन्छ।",
|
||||||
|
"batchPartialEnabled": "{{enabledCount}}/{{total}} सक्रिय (सबै खुला छैन — अन गर्दा सबै सक्रिय हुन्छ)",
|
||||||
"toggleEnable": "सक्रिय",
|
"toggleEnable": "सक्रिय",
|
||||||
"toggleDisable": "निष्क्रिय",
|
"toggleDisable": "निष्क्रिय",
|
||||||
"toggleInstantFailed": "खेल स्विच तुरुन्त लागू गर्न असफल। पछि पुनः प्रयास गर्नुहोस्।",
|
"toggleInstantFailed": "खेल स्विच तुरुन्त लागू गर्न असफल। पछि पुनः प्रयास गर्नुहोस्।",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"exportHint": "निर्यात API जोडिएपछि हालका फिल्टरअनुसार छानिएको फाइल ढाँचा बनाइनेछ।",
|
"exportHint": "निर्यात API जोडिएपछि हालका फिल्टरअनुसार छानिएको फाइल ढाँचा बनाइनेछ।",
|
||||||
"validation": {
|
"validation": {
|
||||||
"drawNoRequired": "कृपया ड्र नं. प्रविष्ट गर्नुहोस्",
|
"drawNoRequired": "कृपया ड्र नं. प्रविष्ट गर्नुहोस्",
|
||||||
|
"drawNoNotFound": "ड्र नं. «{{drawNo}}» फेला परेन",
|
||||||
"drawNoNumberRequired": "कृपया ड्र नं. र नम्बर दुवै प्रविष्ट गर्नुहोस्"
|
"drawNoNumberRequired": "कृपया ड्र नं. र नम्बर दुवै प्रविष्ट गर्नुहोस्"
|
||||||
},
|
},
|
||||||
"formats": {
|
"formats": {
|
||||||
|
|||||||
@@ -47,6 +47,7 @@
|
|||||||
"effectiveAt": "生效时间:{{value}}",
|
"effectiveAt": "生效时间:{{value}}",
|
||||||
"note": "备注:{{value}}",
|
"note": "备注:{{value}}",
|
||||||
"current": "当前查看",
|
"current": "当前查看",
|
||||||
|
"moreActions": "版本 v{{version}} 更多操作",
|
||||||
"selected": "已选中",
|
"selected": "已选中",
|
||||||
"view": "查看",
|
"view": "查看",
|
||||||
"rollback": "回滚",
|
"rollback": "回滚",
|
||||||
@@ -54,8 +55,11 @@
|
|||||||
"deleteConfirmTitle": "确认删除版本?",
|
"deleteConfirmTitle": "确认删除版本?",
|
||||||
"deleteConfirmDescription": "将永久删除版本 ID {{id}}(version_no {{version}})。生效中的版本不可删除。"
|
"deleteConfirmDescription": "将永久删除版本 ID {{id}}(version_no {{version}})。生效中的版本不可删除。"
|
||||||
},
|
},
|
||||||
|
"versionToolbar": {
|
||||||
|
"draftEditing": "正在编辑草稿,保存并发布后生效"
|
||||||
|
},
|
||||||
"versionActions": {
|
"versionActions": {
|
||||||
"publishCurrent": "启用为当前版本",
|
"publishCurrent": "发布生效",
|
||||||
"refreshing": "刷新中",
|
"refreshing": "刷新中",
|
||||||
"refresh": "刷新版本",
|
"refresh": "刷新版本",
|
||||||
"newDraft": "新建草稿",
|
"newDraft": "新建草稿",
|
||||||
@@ -194,7 +198,8 @@
|
|||||||
"batchSwitchEnable": "开启",
|
"batchSwitchEnable": "开启",
|
||||||
"batchSwitchDisable": "关闭",
|
"batchSwitchDisable": "关闭",
|
||||||
"toggleConfirmTitle": "确认{{action}}玩法 {{playCode}}?",
|
"toggleConfirmTitle": "确认{{action}}玩法 {{playCode}}?",
|
||||||
"toggleConfirmDescription": "将立即调用接口生效(不仅限于草稿)。",
|
"toggleConfirmDescription": "将写入当前草稿;保存并发布后才会影响玩家端。",
|
||||||
|
"batchPartialEnabled": "{{enabledCount}}/{{total}} 已开启(未全开,打开开关将全部开启)",
|
||||||
"toggleEnable": "开启",
|
"toggleEnable": "开启",
|
||||||
"toggleDisable": "关闭",
|
"toggleDisable": "关闭",
|
||||||
"toggleInstantFailed": "玩法开关即时生效失败,请稍后重试",
|
"toggleInstantFailed": "玩法开关即时生效失败,请稍后重试",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"exportHint": "接入导出接口后,会按当前条件生成对应格式的文件。",
|
"exportHint": "接入导出接口后,会按当前条件生成对应格式的文件。",
|
||||||
"validation": {
|
"validation": {
|
||||||
"drawNoRequired": "请输入期号",
|
"drawNoRequired": "请输入期号",
|
||||||
|
"drawNoNotFound": "未找到期号「{{drawNo}}」",
|
||||||
"drawNoNumberRequired": "请输入期号和号码"
|
"drawNoNumberRequired": "请输入期号和号码"
|
||||||
},
|
},
|
||||||
"formats": {
|
"formats": {
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ import {
|
|||||||
putAdminRole,
|
putAdminRole,
|
||||||
putAdminRolePermissions,
|
putAdminRolePermissions,
|
||||||
} from "@/api/admin-users";
|
} from "@/api/admin-users";
|
||||||
|
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { resolveRoleStatusTone } from "@/lib/admin-status-tone";
|
||||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
@@ -369,13 +371,9 @@ export function AdminRolesConsole(): React.ReactElement {
|
|||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex justify-center">
|
<AdminStatusBadge status={role.status} tone={resolveRoleStatusTone(role.status)}>
|
||||||
<Switch
|
{role.status === 1 ? t("status.enabled") : t("status.disabled")}
|
||||||
checked={role.status === 1}
|
</AdminStatusBadge>
|
||||||
disabled
|
|
||||||
aria-label={t("roleDialog.status")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="tabular-nums">{role.user_count}</TableCell>
|
<TableCell className="tabular-nums">{role.user_count}</TableCell>
|
||||||
<TableCell className="tabular-nums">{role.permission_slugs.length}</TableCell>
|
<TableCell className="tabular-nums">{role.permission_slugs.length}</TableCell>
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ import {
|
|||||||
} from "@/api/admin-users";
|
} from "@/api/admin-users";
|
||||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||||
|
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { resolveAdminUserStatusTone } from "@/lib/admin-status-tone";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
@@ -391,13 +393,9 @@ export function AdminUsersConsole(): React.ReactElement {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{row.nickname ?? ""}</TableCell>
|
<TableCell>{row.nickname ?? ""}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex justify-center">
|
<AdminStatusBadge status={row.status} tone={resolveAdminUserStatusTone(row.status)}>
|
||||||
<Switch
|
{row.status === 0 ? t("status.enabled") : t("status.disabled")}
|
||||||
checked={row.status === 0}
|
</AdminStatusBadge>
|
||||||
disabled
|
|
||||||
aria-label={t("table.status")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
|
|||||||
@@ -25,16 +25,17 @@ type ConfigChipProps = {
|
|||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ConfigChip({ active, onClick, children, disabled }: ConfigChipProps) {
|
export function ConfigChip({ active, onClick, children, disabled, className }: ConfigChipProps) {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant={active ? "default" : "outline"}
|
variant={active ? "default" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={cn("h-9 rounded-full px-4 text-sm font-medium", active && "shadow-sm")}
|
className={cn("h-8 rounded-full px-3 text-sm font-medium", active && "shadow-sm", className)}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ type ConfigDocPageProps = {
|
|||||||
/** Category / play-type chips etc., rendered above the version toolbar. */
|
/** Category / play-type chips etc., rendered above the version toolbar. */
|
||||||
filters?: ReactNode;
|
filters?: ReactNode;
|
||||||
toolbar?: ReactNode;
|
toolbar?: ReactNode;
|
||||||
|
/** @deprecated Pass `footer` on `ConfigDocToolbar` instead. */
|
||||||
context?: ReactNode;
|
context?: ReactNode;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -21,23 +22,29 @@ type ConfigDocPageProps = {
|
|||||||
export function ConfigDocToolbar({
|
export function ConfigDocToolbar({
|
||||||
switcher,
|
switcher,
|
||||||
actions,
|
actions,
|
||||||
|
footer,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
switcher: ReactNode;
|
switcher: ReactNode;
|
||||||
actions: ReactNode;
|
actions: ReactNode;
|
||||||
|
/** Live version / read-only hint — rendered as a subtle line under the main row. */
|
||||||
|
footer?: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-xl border border-border/60 bg-secondary/50 p-4 shadow-sm",
|
"overflow-hidden rounded-xl border border-border/60 bg-card",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
<div className="flex flex-col gap-3 p-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4 sm:p-4">
|
||||||
<div className="min-w-0 flex-1">{switcher}</div>
|
<div className="min-w-0 flex-1">{switcher}</div>
|
||||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">{actions}</div>
|
<div className="flex shrink-0 flex-wrap items-center gap-1.5 sm:justify-end">{actions}</div>
|
||||||
</div>
|
</div>
|
||||||
|
{footer ? (
|
||||||
|
<div className="border-t border-border/50 bg-muted/20 px-3 py-2.5 sm:px-4">{footer}</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -65,8 +72,8 @@ export function ConfigDocPage({
|
|||||||
{description ? <CardDescription className="text-base">{description}</CardDescription> : null}
|
{description ? <CardDescription className="text-base">{description}</CardDescription> : null}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className={cn("space-y-6 pt-6", contentClassName)}>
|
<CardContent className={cn("space-y-6 pt-6", contentClassName)}>
|
||||||
{filters}
|
|
||||||
{toolbar}
|
{toolbar}
|
||||||
|
{filters}
|
||||||
{context}
|
{context}
|
||||||
{children}
|
{children}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -39,32 +39,44 @@ export function ConfigVersionActions({
|
|||||||
const resolvedPublishLabel = publishLabel ?? t("versionActions.publishCurrent");
|
const resolvedPublishLabel = publishLabel ?? t("versionActions.publishCurrent");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-wrap items-center gap-2", className)}>
|
<div className={cn("flex flex-wrap items-center gap-1", className)}>
|
||||||
<Button type="button" variant="outline" disabled={loadingList} onClick={onRefresh}>
|
<Button
|
||||||
<RefreshCw className={loadingList ? "size-4 animate-spin" : "size-4"} aria-hidden />
|
type="button"
|
||||||
{loadingList ? t("versionActions.refreshing") : t("versionActions.refresh")}
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
disabled={loadingList}
|
||||||
|
onClick={onRefresh}
|
||||||
|
aria-label={loadingList ? t("versionActions.refreshing") : t("versionActions.refresh")}
|
||||||
|
title={loadingList ? t("versionActions.refreshing") : t("versionActions.refresh")}
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn("size-4", loadingList && "animate-spin")} aria-hidden />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{canManage ? (
|
{canManage ? (
|
||||||
<Button type="button" disabled={saving} onClick={onNewDraft}>
|
|
||||||
<Plus className="size-4" aria-hidden />
|
|
||||||
{t("versionActions.newDraft")}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
{canManage && isDraft ? (
|
|
||||||
<>
|
<>
|
||||||
<Button
|
<span className="mx-0.5 hidden h-5 w-px bg-border/60 sm:block" aria-hidden />
|
||||||
type="button"
|
<Button type="button" variant="outline" size="sm" disabled={saving} onClick={onNewDraft}>
|
||||||
variant="secondary"
|
<Plus className="size-3.5" aria-hidden />
|
||||||
disabled={draftActionBusy}
|
{t("versionActions.newDraft")}
|
||||||
onClick={onSaveDraft}
|
|
||||||
>
|
|
||||||
<Save className="size-4" aria-hidden />
|
|
||||||
{t("versionActions.saveDraft")}
|
|
||||||
</Button>
|
|
||||||
<Button type="button" disabled={draftActionBusy} onClick={onPublish}>
|
|
||||||
<Rocket className="size-4" aria-hidden />
|
|
||||||
{resolvedPublishLabel}
|
|
||||||
</Button>
|
</Button>
|
||||||
|
{isDraft ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={draftActionBusy}
|
||||||
|
onClick={onSaveDraft}
|
||||||
|
>
|
||||||
|
<Save className="size-3.5" aria-hidden />
|
||||||
|
{t("versionActions.saveDraft")}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" size="sm" disabled={draftActionBusy} onClick={onPublish}>
|
||||||
|
<Rocket className="size-3.5" aria-hidden />
|
||||||
|
{resolvedPublishLabel}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Layers } from "lucide-react";
|
import { Check, ChevronRight, Layers, MoreHorizontal } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -13,6 +13,12 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
@@ -41,6 +47,27 @@ export type ConfigVersionSwitcherProps = {
|
|||||||
rollbackBusy?: boolean;
|
rollbackBusy?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function formatVersionNote(
|
||||||
|
reason: string | null | undefined,
|
||||||
|
formatDt: (iso: string | null | undefined) => string,
|
||||||
|
): string | null {
|
||||||
|
if (!reason?.trim()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const trimmed = reason.trim();
|
||||||
|
const isoMatch = trimmed.match(/\d{4}-\d{2}-\d{2}T[\d:.]+Z?/);
|
||||||
|
if (trimmed.startsWith("draft") && isoMatch) {
|
||||||
|
return formatDt(isoMatch[0]);
|
||||||
|
}
|
||||||
|
if (trimmed.startsWith("seed:")) {
|
||||||
|
return trimmed.slice(5);
|
||||||
|
}
|
||||||
|
if (trimmed.length > 56) {
|
||||||
|
return `${trimmed.slice(0, 56)}…`;
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
export function ConfigVersionSwitcher({
|
export function ConfigVersionSwitcher({
|
||||||
versions,
|
versions,
|
||||||
selectedId,
|
selectedId,
|
||||||
@@ -85,14 +112,13 @@ export function ConfigVersionSwitcher({
|
|||||||
[selectedId, sortedVersions],
|
[selectedId, sortedVersions],
|
||||||
);
|
);
|
||||||
|
|
||||||
const statusCounts = useMemo(
|
const visibleSections = useMemo(
|
||||||
() =>
|
() =>
|
||||||
STATUS_ORDER.map((status) => ({
|
STATUS_ORDER.map((status) => ({
|
||||||
status,
|
status,
|
||||||
label: t(`versionStatus.${status}`, { ns: "config" }),
|
rows: groupedVersions.get(status) ?? [],
|
||||||
count: groupedVersions.get(status)?.length ?? 0,
|
})).filter((section) => section.rows.length > 0),
|
||||||
})),
|
[groupedVersions],
|
||||||
[groupedVersions, t],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
function switchTo(id: number) {
|
function switchTo(id: number) {
|
||||||
@@ -118,15 +144,14 @@ export function ConfigVersionSwitcher({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={cn("flex min-w-0 items-center gap-2", className)}>
|
<div className={cn("flex min-w-0 items-center gap-3", className)}>
|
||||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||||
{selectedVersion ? (
|
{selectedVersion ? (
|
||||||
<>
|
<>
|
||||||
<span className="font-mono text-lg font-semibold leading-none tabular-nums text-foreground">
|
<span className="font-mono text-base font-semibold tabular-nums text-foreground">
|
||||||
v{selectedVersion.version_no}
|
v{selectedVersion.version_no}
|
||||||
</span>
|
</span>
|
||||||
<ConfigStatusBadge status={selectedVersion.status} />
|
<ConfigStatusBadge status={selectedVersion.status} className="h-5 px-1.5 text-[11px]" />
|
||||||
<span className="text-sm text-muted-foreground">#{selectedVersion.id}</span>
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
@@ -138,12 +163,13 @@ export function ConfigVersionSwitcher({
|
|||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
disabled={loading || sortedVersions.length === 0}
|
disabled={loading || sortedVersions.length === 0}
|
||||||
onClick={() => setSheetOpen(true)}
|
onClick={() => setSheetOpen(true)}
|
||||||
className="shrink-0"
|
className="h-8 shrink-0 gap-1.5 text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<Layers className="size-4" aria-hidden />
|
<Layers className="size-3.5" aria-hidden />
|
||||||
{t("versionSwitcher.switch", { ns: "config" })}
|
{t("versionSwitcher.switch", { ns: "config" })}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -151,172 +177,142 @@ export function ConfigVersionSwitcher({
|
|||||||
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
||||||
<SheetContent
|
<SheetContent
|
||||||
side="right"
|
side="right"
|
||||||
className="flex flex-col overflow-hidden border-l bg-background p-0 shadow-xl sm:max-w-[430px]"
|
className="flex w-full flex-col gap-0 overflow-hidden border-l bg-background p-0 sm:max-w-md"
|
||||||
>
|
>
|
||||||
<div className="border-b border-border/60 bg-card px-5 pb-4 pt-5">
|
<SheetHeader className="space-y-1 border-b border-border/60 px-5 py-4 text-left">
|
||||||
<SheetHeader className="space-y-2 text-left">
|
<SheetTitle className="text-[15px] font-semibold tracking-tight">
|
||||||
<SheetTitle className="text-base font-semibold tracking-tight text-foreground">
|
{resolvedSheetTitle}
|
||||||
{resolvedSheetTitle}
|
</SheetTitle>
|
||||||
</SheetTitle>
|
{resolvedSheetDescription ? (
|
||||||
{resolvedSheetDescription ? (
|
<SheetDescription className="text-[13px] leading-relaxed">
|
||||||
<SheetDescription className="max-w-[320px] text-sm leading-relaxed text-muted-foreground">
|
{resolvedSheetDescription}
|
||||||
{resolvedSheetDescription}
|
</SheetDescription>
|
||||||
</SheetDescription>
|
) : null}
|
||||||
) : null}
|
</SheetHeader>
|
||||||
</SheetHeader>
|
|
||||||
</div>
|
<div className="flex-1 overflow-y-auto px-3 py-3">
|
||||||
<div className="border-b border-border/60 bg-card px-4 py-3">
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{statusCounts.map((s) => (
|
|
||||||
<div
|
|
||||||
key={s.status}
|
|
||||||
className="inline-flex items-center gap-2 rounded-full border border-border/60 bg-muted/50 px-3 py-1.5 text-sm text-muted-foreground"
|
|
||||||
>
|
|
||||||
<span className="font-medium text-foreground/80">{s.label}</span>
|
|
||||||
<span className="font-mono tabular-nums">{s.count}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 overflow-auto px-4 py-4">
|
|
||||||
{sortedVersions.length === 0 ? (
|
{sortedVersions.length === 0 ? (
|
||||||
<div className="rounded-2xl border border-dashed border-border/60 bg-muted/30 p-5 text-center text-sm text-muted-foreground">
|
<p className="px-2 py-8 text-center text-sm text-muted-foreground">
|
||||||
{t("versionSwitcher.empty", { ns: "config" })}
|
{t("versionSwitcher.empty", { ns: "config" })}
|
||||||
</div>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
STATUS_ORDER.map((status) => {
|
<div className="space-y-5">
|
||||||
const rows = groupedVersions.get(status) ?? [];
|
{visibleSections.map((section) => (
|
||||||
if (rows.length === 0) {
|
<section key={section.status}>
|
||||||
return null;
|
<p className="mb-2 px-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||||
}
|
{t(`versionStatus.${section.status}`, { ns: "config" })}
|
||||||
return (
|
</p>
|
||||||
<section key={status} className="border-b border-border/60 pb-4 last:border-b-0 last:pb-0">
|
<ul className="space-y-1">
|
||||||
<div className="mb-2 flex items-center justify-between px-1">
|
{section.rows.map((v) => {
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"size-2.5 rounded-full",
|
|
||||||
status === "draft" && "bg-muted-foreground",
|
|
||||||
status === "active" && "bg-primary",
|
|
||||||
status === "archived" && "bg-border",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<p className="text-base font-semibold text-foreground">
|
|
||||||
{t(`versionStatus.${status}`, { ns: "config" })}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<p className="rounded-full bg-muted px-2 py-0.5 text-sm font-medium tabular-nums text-muted-foreground">
|
|
||||||
{t("versionSwitcher.count", { ns: "config", count: rows.length })}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
{rows.map((v) => {
|
|
||||||
const isCurrent = selectedId === String(v.id);
|
const isCurrent = selectedId === String(v.id);
|
||||||
|
const note = formatVersionNote(v.reason, formatDt);
|
||||||
|
const effectiveLabel = v.effective_at
|
||||||
|
? formatDt(v.effective_at)
|
||||||
|
: null;
|
||||||
|
const meta = [effectiveLabel, note].filter(Boolean).join(" · ");
|
||||||
|
const showMenu =
|
||||||
|
(onDeleteVersion && v.status !== "active") ||
|
||||||
|
(onRollbackVersion && v.status !== "draft");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<li key={v.id}>
|
||||||
key={v.id}
|
|
||||||
className={cn(
|
|
||||||
"group flex gap-3 rounded-xl border border-transparent px-2 py-3 transition-colors hover:bg-muted/30",
|
|
||||||
isCurrent && "border-border/60 bg-muted/20",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"mt-1 h-auto w-1 shrink-0 rounded-full bg-border",
|
"group flex items-stretch gap-0.5 rounded-lg border transition-colors",
|
||||||
v.status === "draft" && "bg-muted-foreground/60",
|
isCurrent
|
||||||
v.status === "active" && "bg-primary",
|
? "border-primary/40 bg-primary/[0.04]"
|
||||||
v.status === "archived" && "bg-muted-foreground/30",
|
: "border-transparent hover:border-border/60 hover:bg-muted/30",
|
||||||
)}
|
)}
|
||||||
/>
|
>
|
||||||
<div className="min-w-0 flex-1 space-y-2">
|
<button
|
||||||
<div className="flex items-start justify-between gap-3">
|
type="button"
|
||||||
<div className="min-w-0 space-y-1.5">
|
className="flex min-w-0 flex-1 items-center gap-3 px-3 py-2.5 text-left"
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
onClick={() => switchTo(v.id)}
|
||||||
<span className="font-mono text-lg font-semibold leading-none tabular-nums text-foreground">
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-mono text-[15px] font-semibold tabular-nums text-foreground">
|
||||||
v{v.version_no}
|
v{v.version_no}
|
||||||
</span>
|
</span>
|
||||||
<ConfigStatusBadge status={v.status} />
|
<ConfigStatusBadge
|
||||||
<span className="rounded-full bg-muted px-2 py-0.5 text-sm font-medium tabular-nums text-muted-foreground">
|
status={v.status}
|
||||||
#{v.id}
|
className="h-5 px-1.5 text-[11px]"
|
||||||
</span>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="line-clamp-2 text-sm leading-relaxed text-muted-foreground">
|
{meta ? (
|
||||||
{t("versionSwitcher.effectiveAt", {
|
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||||
ns: "config",
|
{meta}
|
||||||
value: v.effective_at ? formatDt(v.effective_at) : "—",
|
</p>
|
||||||
})}
|
) : null}
|
||||||
{v.reason
|
|
||||||
? ` · ${t("versionSwitcher.note", {
|
|
||||||
ns: "config",
|
|
||||||
value: v.reason,
|
|
||||||
})}`
|
|
||||||
: ""}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
{isCurrent ? (
|
{isCurrent ? (
|
||||||
<span className="shrink-0 rounded-full bg-primary px-2.5 py-1 text-sm font-medium text-primary-foreground">
|
<span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
||||||
{t("versionSwitcher.current", { ns: "config" })}
|
<Check className="size-3.5" strokeWidth={2.5} aria-hidden />
|
||||||
|
<span className="sr-only">
|
||||||
|
{t("versionSwitcher.current", { ns: "config" })}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : (
|
||||||
</div>
|
<ChevronRight
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
className="size-4 shrink-0 text-muted-foreground/50 group-hover:text-muted-foreground"
|
||||||
<Button
|
aria-hidden
|
||||||
type="button"
|
/>
|
||||||
variant={isCurrent ? "secondary" : "outline"}
|
)}
|
||||||
size="sm"
|
</button>
|
||||||
className={cn(
|
|
||||||
"h-9 rounded-full px-3 text-sm",
|
{showMenu ? (
|
||||||
isCurrent && "bg-muted text-muted-foreground hover:bg-muted",
|
<div className="flex shrink-0 items-center pr-1">
|
||||||
)}
|
<DropdownMenu>
|
||||||
onClick={() => switchTo(v.id)}
|
<DropdownMenuTrigger
|
||||||
>
|
className="inline-flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground opacity-0 outline-none transition-opacity hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring group-hover:opacity-100 data-popup-open:opacity-100"
|
||||||
{isCurrent
|
aria-label={t("versionSwitcher.moreActions", {
|
||||||
? t("versionSwitcher.selected", { ns: "config" })
|
ns: "config",
|
||||||
: t("versionSwitcher.view", { ns: "config" })}
|
version: v.version_no,
|
||||||
</Button>
|
})}
|
||||||
{onRollbackVersion && v.status !== "draft" ? (
|
onClick={(e) => e.stopPropagation()}
|
||||||
<Button
|
>
|
||||||
type="button"
|
<MoreHorizontal className="size-4" />
|
||||||
variant="ghost"
|
</DropdownMenuTrigger>
|
||||||
size="sm"
|
<DropdownMenuContent align="end" className="min-w-36">
|
||||||
className="rounded-full text-sm text-muted-foreground hover:bg-muted hover:text-foreground"
|
{onRollbackVersion && v.status !== "draft" ? (
|
||||||
disabled={rollbackBusy}
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
disabled={rollbackBusy}
|
||||||
onRollbackVersion(v);
|
onClick={() => {
|
||||||
setSheetOpen(false);
|
onRollbackVersion(v);
|
||||||
}}
|
setSheetOpen(false);
|
||||||
>
|
}}
|
||||||
{t("versionSwitcher.rollback", { ns: "config" })}
|
>
|
||||||
</Button>
|
{t("versionSwitcher.rollback", { ns: "config" })}
|
||||||
) : null}
|
</DropdownMenuItem>
|
||||||
{onDeleteVersion && v.status !== "active" ? (
|
) : null}
|
||||||
<Button
|
{onDeleteVersion && v.status !== "active" ? (
|
||||||
type="button"
|
<DropdownMenuItem
|
||||||
variant="ghost"
|
variant="destructive"
|
||||||
size="sm"
|
disabled={deletingId === v.id}
|
||||||
className="rounded-full text-sm text-destructive hover:bg-destructive/10 hover:text-destructive"
|
onClick={() => setDeleteTarget(v)}
|
||||||
disabled={deletingId === v.id}
|
>
|
||||||
onClick={() => setDeleteTarget(v)}
|
{t("versionSwitcher.delete", { ns: "config" })}
|
||||||
>
|
</DropdownMenuItem>
|
||||||
{t("versionSwitcher.delete", { ns: "config" })}
|
) : null}
|
||||||
</Button>
|
</DropdownMenuContent>
|
||||||
) : null}
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
);
|
))}
|
||||||
})
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|
||||||
<Dialog open={deleteTarget !== null} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
<Dialog open={deleteTarget !== null} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||||
<DialogContent>
|
<DialogContent showCloseButton className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t("versionSwitcher.deleteConfirmTitle", { ns: "config" })}</DialogTitle>
|
<DialogTitle>{t("versionSwitcher.deleteConfirmTitle", { ns: "config" })}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
|
|||||||
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";
|
} from "@/api/admin-config";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ConfigChip, ConfigChipGroup } from "@/modules/config/config-chip-group";
|
import { ConfigChip, ConfigChipGroup } from "@/modules/config/config-chip-group";
|
||||||
import { ConfigContextBanner, ConfigContextEmphasis } from "@/modules/config/config-context-banner";
|
|
||||||
import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page";
|
import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page";
|
||||||
|
import {
|
||||||
|
ConfigVersionToolbarMeta,
|
||||||
|
ConfigVersionToolbarMetaEmphasis,
|
||||||
|
} from "@/modules/config/config-version-toolbar-meta";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -34,6 +37,7 @@ import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher"
|
|||||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||||
import { resolveAdminPlayTypeDisplayName } from "@/lib/admin-play-types";
|
import { resolveAdminPlayTypeDisplayName } from "@/lib/admin-play-types";
|
||||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import { PRD_ODDS_MANAGE, PRD_REBATE_MANAGE } from "@/lib/admin-prd";
|
import { PRD_ODDS_MANAGE, PRD_REBATE_MANAGE } from "@/lib/admin-prd";
|
||||||
import { useAdminProfile } from "@/stores/admin-session";
|
import { useAdminProfile } from "@/stores/admin-session";
|
||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
@@ -77,16 +81,25 @@ function filterTypes(tab: CatTab, types: AdminPlayTypeRow[]): AdminPlayTypeRow[]
|
|||||||
type OddsConfigDocScreenProps = {
|
type OddsConfigDocScreenProps = {
|
||||||
/** 嵌入「赔率与回水」合并页时去掉外层 ConfigDocPage */
|
/** 嵌入「赔率与回水」合并页时去掉外层 ConfigDocPage */
|
||||||
embedded?: boolean;
|
embedded?: boolean;
|
||||||
|
/** 与回水分区共用版本选择(合并页) */
|
||||||
|
versionId?: string;
|
||||||
|
onVersionIdChange?: (id: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function OddsConfigDocScreen({ embedded = false }: OddsConfigDocScreenProps) {
|
export function OddsConfigDocScreen({
|
||||||
|
embedded = false,
|
||||||
|
versionId: controlledVersionId,
|
||||||
|
onVersionIdChange,
|
||||||
|
}: OddsConfigDocScreenProps) {
|
||||||
const { t, i18n } = useTranslation(["config", "adminUsers", "common"]);
|
const { t, i18n } = useTranslation(["config", "adminUsers", "common"]);
|
||||||
const profile = useAdminProfile();
|
const profile = useAdminProfile();
|
||||||
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_ODDS_MANAGE, PRD_REBATE_MANAGE]);
|
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_ODDS_MANAGE, PRD_REBATE_MANAGE]);
|
||||||
const formatDt = useAdminDateTimeFormatter();
|
const formatDt = useAdminDateTimeFormatter();
|
||||||
const [types, setTypes] = useState<AdminPlayTypeRow[]>([]);
|
const [types, setTypes] = useState<AdminPlayTypeRow[]>([]);
|
||||||
const [list, setList] = useState<ConfigVersionSummary[]>([]);
|
const [list, setList] = useState<ConfigVersionSummary[]>([]);
|
||||||
const [selectedId, setSelectedId] = useState("");
|
const [internalSelectedId, setInternalSelectedId] = useState("");
|
||||||
|
const selectedId = controlledVersionId ?? internalSelectedId;
|
||||||
|
const setSelectedId = onVersionIdChange ?? setInternalSelectedId;
|
||||||
const [detail, setDetail] = useState<OddsVersionDetail | null>(null);
|
const [detail, setDetail] = useState<OddsVersionDetail | null>(null);
|
||||||
const [draftRows, setDraftRows] = useState<OddsItemRow[]>([]);
|
const [draftRows, setDraftRows] = useState<OddsItemRow[]>([]);
|
||||||
const [loadingTypes, setLoadingTypes] = useState(true);
|
const [loadingTypes, setLoadingTypes] = useState(true);
|
||||||
@@ -417,38 +430,42 @@ export function OddsConfigDocScreen({ embedded = false }: OddsConfigDocScreenPro
|
|||||||
];
|
];
|
||||||
|
|
||||||
const filtersBlock = (
|
const filtersBlock = (
|
||||||
<div className="space-y-4 rounded-xl border border-border/60 bg-card p-4">
|
<div className={cn("space-y-3", embedded ? "border-t border-border/50 px-3 py-3 sm:px-4" : "rounded-xl border border-border/60 bg-card p-4")}>
|
||||||
<ConfigChipGroup label={t("odds.category", { ns: "config" })}>
|
<ConfigChipGroup label={t("odds.category", { ns: "config" })}>
|
||||||
{catTabs.map((tab) => (
|
{catTabs.map((tab) => (
|
||||||
|
<ConfigChip
|
||||||
|
key={tab.id}
|
||||||
|
active={catTab === tab.id}
|
||||||
|
onClick={() => setCatTab(tab.id)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</ConfigChip>
|
||||||
|
))}
|
||||||
|
</ConfigChipGroup>
|
||||||
|
<ConfigChipGroup label={t("odds.playType", { ns: "config" })}>
|
||||||
|
{filteredTypes.length === 0 ? (
|
||||||
|
<span className="text-sm text-muted-foreground">{t("odds.noPlayTypes", { ns: "config" })}</span>
|
||||||
|
) : (
|
||||||
|
<div className="-mx-1 flex gap-1.5 overflow-x-auto px-1 pb-0.5">
|
||||||
|
{filteredTypes.map((type) => (
|
||||||
<ConfigChip
|
<ConfigChip
|
||||||
key={tab.id}
|
key={type.play_code}
|
||||||
active={catTab === tab.id}
|
active={resolvedPlayCode === type.play_code}
|
||||||
onClick={() => setCatTab(tab.id)}
|
onClick={() => setPlayCode(type.play_code)}
|
||||||
|
className="shrink-0"
|
||||||
>
|
>
|
||||||
{tab.label}
|
{resolveAdminPlayTypeDisplayName(type.play_code, i18n.language, type)}
|
||||||
</ConfigChip>
|
</ConfigChip>
|
||||||
))}
|
))}
|
||||||
</ConfigChipGroup>
|
</div>
|
||||||
<ConfigChipGroup label={t("odds.playType", { ns: "config" })}>
|
)}
|
||||||
{filteredTypes.length === 0 ? (
|
</ConfigChipGroup>
|
||||||
<span className="text-sm text-muted-foreground">{t("odds.noPlayTypes", { ns: "config" })}</span>
|
</div>
|
||||||
) : (
|
|
||||||
filteredTypes.map((type) => (
|
|
||||||
<ConfigChip
|
|
||||||
key={type.play_code}
|
|
||||||
active={resolvedPlayCode === type.play_code}
|
|
||||||
onClick={() => setPlayCode(type.play_code)}
|
|
||||||
>
|
|
||||||
{resolveAdminPlayTypeDisplayName(type.play_code, i18n.language, type)}
|
|
||||||
</ConfigChip>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</ConfigChipGroup>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const toolbarBlock = (
|
const toolbarBlock = (
|
||||||
<ConfigDocToolbar
|
<ConfigDocToolbar
|
||||||
|
className={embedded ? "rounded-none border-0 shadow-none" : undefined}
|
||||||
switcher={
|
switcher={
|
||||||
<ConfigVersionSwitcher
|
<ConfigVersionSwitcher
|
||||||
versions={list}
|
versions={list}
|
||||||
@@ -475,57 +492,64 @@ export function OddsConfigDocScreen({ embedded = false }: OddsConfigDocScreenPro
|
|||||||
onPublish={() => void requestPublishConfirm()}
|
onPublish={() => void requestPublishConfirm()}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
footer={
|
||||||
|
!detail ? null : (
|
||||||
|
<ConfigVersionToolbarMeta emphasis={!isDraft}>
|
||||||
|
<span>
|
||||||
|
{t("odds.activeVersionPrefix", { ns: "config" })}
|
||||||
|
{activeHead ? (
|
||||||
|
<>
|
||||||
|
v{activeHead.version_no}
|
||||||
|
{activeHead.effective_at ? ` · ${formatDt(activeHead.effective_at)}` : ""}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{!isDraft ? (
|
||||||
|
<ConfigVersionToolbarMetaEmphasis>
|
||||||
|
{t("odds.readOnlyHint", { ns: "config" })}
|
||||||
|
</ConfigVersionToolbarMetaEmphasis>
|
||||||
|
) : activeHead ? (
|
||||||
|
<span>{t("versionToolbar.draftEditing", { ns: "config" })}</span>
|
||||||
|
) : null}
|
||||||
|
</ConfigVersionToolbarMeta>
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const contextBlock =
|
|
||||||
embedded || !detail ? null : (
|
|
||||||
<ConfigContextBanner emphasis={!isDraft}>
|
|
||||||
{t("odds.activeVersionPrefix", { ns: "config" })}
|
|
||||||
{activeHead ? (
|
|
||||||
<>
|
|
||||||
v{activeHead.version_no}
|
|
||||||
{activeHead.effective_at ? ` · ${formatDt(activeHead.effective_at)}` : ""}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"—"
|
|
||||||
)}
|
|
||||||
{!isDraft ? (
|
|
||||||
<>
|
|
||||||
{" "}
|
|
||||||
— <ConfigContextEmphasis>{t("odds.readOnlyHint", { ns: "config" })}</ConfigContextEmphasis>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</ConfigContextBanner>
|
|
||||||
);
|
|
||||||
|
|
||||||
const mainBlock = (
|
const mainBlock = (
|
||||||
<>
|
<>
|
||||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||||
|
|
||||||
{loadingDetail || loadingTypes ? (
|
{loadingDetail || loadingTypes ? (
|
||||||
<div className="flex min-h-[420px] items-center">
|
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||||
<p className="text-base text-muted-foreground">{t("odds.loadingDetails", { ns: "config" })}</p>
|
{t("odds.loadingDetails", { ns: "config" })}
|
||||||
</div>
|
</p>
|
||||||
) : resolvedPlayCode ? (
|
) : resolvedPlayCode ? (
|
||||||
<div className="grid min-h-[420px] gap-4 max-w-md">
|
<div
|
||||||
{PRIZE_SCOPE_ORDER.map((scope) => {
|
className={cn(
|
||||||
const row = scopeRows[scope];
|
embedded ? "rounded-xl border border-border/60 bg-card p-4" : undefined,
|
||||||
const hint = embedded ? null : PRIZE_SCOPE_MULTIPLIER_HINT[scope];
|
)}
|
||||||
const idx = row ? rowIndex(resolvedPlayCode, scope) : -1;
|
>
|
||||||
return (
|
<div className="grid grid-cols-2 gap-x-4 gap-y-4 sm:grid-cols-3 lg:grid-cols-6">
|
||||||
<div key={scope} className="grid gap-1">
|
{PRIZE_SCOPE_ORDER.map((scope) => {
|
||||||
<Label className="flex items-baseline gap-2">
|
const row = scopeRows[scope];
|
||||||
{prizeScopeLabel(scope, t)}
|
const hint = embedded ? null : PRIZE_SCOPE_MULTIPLIER_HINT[scope];
|
||||||
{hint ? <span className="text-sm text-muted-foreground font-normal">{hint}</span> : null}
|
const idx = row ? rowIndex(resolvedPlayCode, scope) : -1;
|
||||||
</Label>
|
return (
|
||||||
{row && idx >= 0 ? (
|
<div key={scope} className="grid min-w-0 gap-1.5">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<Label className="truncate text-xs font-medium text-muted-foreground">
|
||||||
{canEditDraft ? (
|
{prizeScopeLabel(scope, t)}
|
||||||
|
{hint ? <span className="ml-1 font-normal">{hint}</span> : null}
|
||||||
|
</Label>
|
||||||
|
{row && idx >= 0 ? (
|
||||||
|
canEditDraft ? (
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
className="h-9 max-w-[200px] font-mono tabular-nums"
|
className="h-9 w-full font-mono tabular-nums"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
value={oddsMultiplierLabel(row.odds_value)}
|
value={oddsMultiplierLabel(row.odds_value)}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
@@ -535,46 +559,39 @@ export function OddsConfigDocScreen({ embedded = false }: OddsConfigDocScreenPro
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ConfigReadonlyValue mono className="max-w-[200px]">
|
<ConfigReadonlyValue mono className="h-9 w-full justify-center">
|
||||||
{oddsMultiplierLabel(row.odds_value)}
|
{oddsMultiplierLabel(row.odds_value)}
|
||||||
</ConfigReadonlyValue>
|
</ConfigReadonlyValue>
|
||||||
)}
|
)
|
||||||
{!embedded ? (
|
) : (
|
||||||
<span className="text-sm text-muted-foreground tabular-nums">
|
<p className="text-xs text-destructive">{t("odds.missingScopeRow", { ns: "config", scope })}</p>
|
||||||
{t("odds.multiplier", {
|
)}
|
||||||
ns: "config",
|
</div>
|
||||||
value: oddsMultiplierLabel(row.odds_value),
|
);
|
||||||
currency: row.currency_code,
|
})}
|
||||||
})}
|
<div className="grid min-w-0 gap-1.5">
|
||||||
</span>
|
<Label className="truncate text-xs font-medium text-muted-foreground">
|
||||||
) : null}
|
{t("odds.rebateRate", { ns: "config" })}
|
||||||
</div>
|
</Label>
|
||||||
) : (
|
{canEditDraft ? (
|
||||||
<p className="text-sm text-destructive">{t("odds.missingScopeRow", { ns: "config", scope })}</p>
|
<Input
|
||||||
)}
|
type="text"
|
||||||
</div>
|
inputMode="decimal"
|
||||||
);
|
className="h-9 w-full font-mono tabular-nums"
|
||||||
})}
|
disabled={saving}
|
||||||
<div className="grid gap-1 pt-2 border-t">
|
value={rebatePercentUi}
|
||||||
<Label>{t("odds.rebateRate", { ns: "config" })}</Label>
|
onChange={(e) => setRebateForPlayPercent(e.target.value)}
|
||||||
{canEditDraft ? (
|
/>
|
||||||
<Input
|
) : (
|
||||||
type="text"
|
<ConfigReadonlyValue mono className="h-9 w-full justify-center">
|
||||||
inputMode="decimal"
|
{rebatePercentUi}
|
||||||
className="h-9 max-w-[200px] font-mono tabular-nums"
|
</ConfigReadonlyValue>
|
||||||
disabled={saving}
|
)}
|
||||||
value={rebatePercentUi}
|
</div>
|
||||||
onChange={(e) => setRebateForPlayPercent(e.target.value)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ConfigReadonlyValue mono className="max-w-[200px]">
|
|
||||||
{rebatePercentUi}
|
|
||||||
</ConfigReadonlyValue>
|
|
||||||
)}
|
|
||||||
{!embedded ? (
|
|
||||||
<p className="text-sm text-muted-foreground">{t("odds.rebateRateHint", { ns: "config" })}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
|
{!embedded ? (
|
||||||
|
<p className="mt-3 text-xs text-muted-foreground">{t("odds.rebateRateHint", { ns: "config" })}</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
@@ -649,10 +666,11 @@ export function OddsConfigDocScreen({ embedded = false }: OddsConfigDocScreenPro
|
|||||||
|
|
||||||
if (embedded) {
|
if (embedded) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-4">
|
||||||
{filtersBlock}
|
<div className="overflow-hidden rounded-xl border border-border/60 bg-card">
|
||||||
{toolbarBlock}
|
{toolbarBlock}
|
||||||
{contextBlock}
|
{filtersBlock}
|
||||||
|
</div>
|
||||||
{mainBlock}
|
{mainBlock}
|
||||||
{dialogs}
|
{dialogs}
|
||||||
</div>
|
</div>
|
||||||
@@ -664,7 +682,6 @@ export function OddsConfigDocScreen({ embedded = false }: OddsConfigDocScreenPro
|
|||||||
title={t("nav.items.odds", { ns: "config" })}
|
title={t("nav.items.odds", { ns: "config" })}
|
||||||
filters={filtersBlock}
|
filters={filtersBlock}
|
||||||
toolbar={toolbarBlock}
|
toolbar={toolbarBlock}
|
||||||
context={contextBlock}
|
|
||||||
>
|
>
|
||||||
{mainBlock}
|
{mainBlock}
|
||||||
{dialogs}
|
{dialogs}
|
||||||
|
|||||||
@@ -10,16 +10,18 @@ import {
|
|||||||
getPlayConfigVersion,
|
getPlayConfigVersion,
|
||||||
getPlayConfigVersions,
|
getPlayConfigVersions,
|
||||||
postPlayConfigVersion,
|
postPlayConfigVersion,
|
||||||
patchAdminPlayType,
|
|
||||||
publishPlayConfigVersion,
|
publishPlayConfigVersion,
|
||||||
putPlayConfigItems,
|
putPlayConfigItems,
|
||||||
} from "@/api/admin-config";
|
} from "@/api/admin-config";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ConfigChipGroup } from "@/modules/config/config-chip-group";
|
import { ConfigChipGroup } from "@/modules/config/config-chip-group";
|
||||||
import { ConfigContextBanner, ConfigContextEmphasis } from "@/modules/config/config-context-banner";
|
|
||||||
import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page";
|
import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page";
|
||||||
|
import {
|
||||||
|
ConfigVersionToolbarMeta,
|
||||||
|
ConfigVersionToolbarMetaEmphasis,
|
||||||
|
} from "@/modules/config/config-version-toolbar-meta";
|
||||||
import { ConfigSection } from "@/modules/config/config-section";
|
import { ConfigSection } from "@/modules/config/config-section";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { ConfirmableSwitch } from "@/components/admin/confirmable-switch";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -136,7 +138,7 @@ function buildPlayConfigSavePayload(
|
|||||||
|
|
||||||
export function PlayConfigDocScreen() {
|
export function PlayConfigDocScreen() {
|
||||||
const { t } = useTranslation(["config", "adminUsers", "common"]);
|
const { t } = useTranslation(["config", "adminUsers", "common"]);
|
||||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
const { request: requestConfirm, ConfirmDialog, busy: confirmBusy } = useConfirmAction();
|
||||||
const profile = useAdminProfile();
|
const profile = useAdminProfile();
|
||||||
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_PLAY_SWITCH_MANAGE]);
|
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_PLAY_SWITCH_MANAGE]);
|
||||||
const formatDt = useAdminDateTimeFormatter();
|
const formatDt = useAdminDateTimeFormatter();
|
||||||
@@ -269,25 +271,10 @@ export function PlayConfigDocScreen() {
|
|||||||
setDraftRows((prev) => prev.map((r) => (r.play_code === playCode ? { ...r, ...patch } : r)));
|
setDraftRows((prev) => prev.map((r) => (r.play_code === playCode ? { ...r, ...patch } : r)));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applyPlayToggleInstant(playCode: string, enabled: boolean) {
|
function applyBatchSwitch(group: PlayBatchSwitchGroup, enabled: boolean) {
|
||||||
try {
|
|
||||||
await patchAdminPlayType(playCode, { is_enabled: enabled });
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(
|
|
||||||
e instanceof LotteryApiBizError ? e.message : t("play.toggleInstantFailed", { ns: "config" }),
|
|
||||||
);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function applyBatchSwitch(group: PlayBatchSwitchGroup, enabled: boolean) {
|
|
||||||
const targets = draftRows.filter(group.match);
|
|
||||||
setDraftRows((prev) =>
|
setDraftRows((prev) =>
|
||||||
prev.map((row) => (group.match(row) ? { ...row, is_enabled: enabled } : row)),
|
prev.map((row) => (group.match(row) ? { ...row, is_enabled: enabled } : row)),
|
||||||
);
|
);
|
||||||
for (const row of targets) {
|
|
||||||
await applyPlayToggleInstant(row.play_code, enabled);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const batchSwitchStates = useMemo(
|
const batchSwitchStates = useMemo(
|
||||||
@@ -448,26 +435,27 @@ export function PlayConfigDocScreen() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
footer={
|
||||||
|
detail ? (
|
||||||
|
<ConfigVersionToolbarMeta emphasis={!isDraft}>
|
||||||
|
{activeHead ? (
|
||||||
|
<span>
|
||||||
|
{t("play.activeVersion", { ns: "config", version: activeHead.version_no })}
|
||||||
|
{activeHead.effective_at ? ` · ${formatDt(activeHead.effective_at)}` : ""}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{!isDraft ? (
|
||||||
|
<ConfigVersionToolbarMetaEmphasis>
|
||||||
|
{t("play.readOnlyHint", { ns: "config" })}
|
||||||
|
</ConfigVersionToolbarMetaEmphasis>
|
||||||
|
) : activeHead ? (
|
||||||
|
<span>{t("versionToolbar.draftEditing", { ns: "config" })}</span>
|
||||||
|
) : null}
|
||||||
|
</ConfigVersionToolbarMeta>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
context={
|
|
||||||
detail ? (
|
|
||||||
<ConfigContextBanner emphasis={!isDraft}>
|
|
||||||
{activeHead ? (
|
|
||||||
<>
|
|
||||||
{t("play.activeVersion", { ns: "config", version: activeHead.version_no })}
|
|
||||||
{activeHead.effective_at ? ` · ${formatDt(activeHead.effective_at)}` : ""}
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
{!isDraft ? (
|
|
||||||
<>
|
|
||||||
{activeHead ? " — " : ""}
|
|
||||||
<ConfigContextEmphasis>{t("play.readOnlyHint", { ns: "config" })}</ConfigContextEmphasis>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</ConfigContextBanner>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{detail ? (
|
{detail ? (
|
||||||
<ConfigSection
|
<ConfigSection
|
||||||
@@ -476,7 +464,9 @@ export function PlayConfigDocScreen() {
|
|||||||
>
|
>
|
||||||
<ConfigChipGroup>
|
<ConfigChipGroup>
|
||||||
{batchSwitchStates.map((group) => {
|
{batchSwitchStates.map((group) => {
|
||||||
const groupOn = group.enabledCount > 0;
|
const groupOn = group.allEnabled;
|
||||||
|
const isPartial =
|
||||||
|
group.total > 0 && group.enabledCount > 0 && group.enabledCount < group.total;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={group.key}
|
key={group.key}
|
||||||
@@ -486,16 +476,23 @@ export function PlayConfigDocScreen() {
|
|||||||
<p className="text-sm font-medium text-foreground">{group.label}</p>
|
<p className="text-sm font-medium text-foreground">{group.label}</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{group.total > 0
|
{group.total > 0
|
||||||
? t("play.batchEnabledCount", {
|
? isPartial
|
||||||
ns: "config",
|
? t("play.batchPartialEnabled", {
|
||||||
enabledCount: group.enabledCount,
|
ns: "config",
|
||||||
total: group.total,
|
enabledCount: group.enabledCount,
|
||||||
})
|
total: group.total,
|
||||||
|
})
|
||||||
|
: t("play.batchEnabledCount", {
|
||||||
|
ns: "config",
|
||||||
|
enabledCount: group.enabledCount,
|
||||||
|
total: group.total,
|
||||||
|
})
|
||||||
: t("play.noPlayTypes", { ns: "config" })}
|
: t("play.noPlayTypes", { ns: "config" })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<ConfirmableSwitch
|
||||||
checked={groupOn}
|
checked={groupOn}
|
||||||
|
confirmBusy={confirmBusy}
|
||||||
disabled={!isDraft || saving || group.total === 0}
|
disabled={!isDraft || saving || group.total === 0}
|
||||||
aria-label={t("play.aria.batchGroupSwitch", {
|
aria-label={t("play.aria.batchGroupSwitch", {
|
||||||
ns: "config",
|
ns: "config",
|
||||||
@@ -537,8 +534,8 @@ export function PlayConfigDocScreen() {
|
|||||||
<TableHead className="text-center">{t("play.table.playCode", { ns: "config" })}</TableHead>
|
<TableHead className="text-center">{t("play.table.playCode", { ns: "config" })}</TableHead>
|
||||||
<TableHead className="w-[100px] text-center">{t("play.table.category", { ns: "config" })}</TableHead>
|
<TableHead className="w-[100px] text-center">{t("play.table.category", { ns: "config" })}</TableHead>
|
||||||
<TableHead className="w-[88px] text-center">{t("play.table.status", { ns: "config" })}</TableHead>
|
<TableHead className="w-[88px] text-center">{t("play.table.status", { ns: "config" })}</TableHead>
|
||||||
<TableHead className="w-32 text-center">{t("play.table.displayName", { ns: "config" })}</TableHead>
|
<TableHead className="w-36 text-center">{t("play.table.displayName", { ns: "config" })}</TableHead>
|
||||||
<TableHead className="w-[120px] text-center">{t("play.table.order", { ns: "config" })}</TableHead>
|
<TableHead className="w-24 text-center">{t("play.table.order", { ns: "config" })}</TableHead>
|
||||||
<TableHead className="w-[110px] text-center">{t("play.table.minBet", { ns: "config" })}</TableHead>
|
<TableHead className="w-[110px] text-center">{t("play.table.minBet", { ns: "config" })}</TableHead>
|
||||||
<TableHead className="w-[110px] text-center">{t("play.table.maxBet", { ns: "config" })}</TableHead>
|
<TableHead className="w-[110px] text-center">{t("play.table.maxBet", { ns: "config" })}</TableHead>
|
||||||
<TableHead className="w-[140px] text-center">{t("play.table.actions", { ns: "config" })}</TableHead>
|
<TableHead className="w-[140px] text-center">{t("play.table.actions", { ns: "config" })}</TableHead>
|
||||||
@@ -550,41 +547,47 @@ export function PlayConfigDocScreen() {
|
|||||||
<TableCell className="text-center font-mono text-sm">{row.play_code}</TableCell>
|
<TableCell className="text-center font-mono text-sm">{row.play_code}</TableCell>
|
||||||
<TableCell className="text-center text-muted-foreground text-sm">{row.category ?? "—"}</TableCell>
|
<TableCell className="text-center text-muted-foreground text-sm">{row.category ?? "—"}</TableCell>
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
<div className="flex justify-center">
|
{isDraft ? (
|
||||||
<Switch
|
<div className="flex justify-center">
|
||||||
checked={row.is_enabled}
|
<ConfirmableSwitch
|
||||||
disabled={!isDraft || saving}
|
checked={row.is_enabled}
|
||||||
aria-label={t("play.aria.enablePlay", { ns: "config", playCode: row.play_code })}
|
confirmBusy={confirmBusy}
|
||||||
onCheckedChange={(checked) => {
|
disabled={saving}
|
||||||
if (!isDraft) {
|
aria-label={t("play.aria.enablePlay", { ns: "config", playCode: row.play_code })}
|
||||||
return;
|
onCheckedChange={(enabled) => {
|
||||||
}
|
const action = enabled
|
||||||
const enabled = checked;
|
? t("play.toggleEnable", { ns: "config" })
|
||||||
const action = enabled
|
: t("play.toggleDisable", { ns: "config" });
|
||||||
? t("play.toggleEnable", { ns: "config" })
|
requestConfirm({
|
||||||
: t("play.toggleDisable", { ns: "config" });
|
title: t("play.toggleConfirmTitle", {
|
||||||
requestConfirm({
|
ns: "config",
|
||||||
title: t("play.toggleConfirmTitle", {
|
action,
|
||||||
ns: "config",
|
playCode: row.play_code,
|
||||||
action,
|
}),
|
||||||
playCode: row.play_code,
|
description: t("play.toggleConfirmDescription", { ns: "config" }),
|
||||||
}),
|
confirmVariant: enabled ? "default" : "destructive",
|
||||||
description: t("play.toggleConfirmDescription", { ns: "config" }),
|
onConfirm: () => {
|
||||||
confirmVariant: enabled ? "default" : "destructive",
|
updateConfigRow(row.play_code, { is_enabled: enabled });
|
||||||
onConfirm: () => {
|
},
|
||||||
updateConfigRow(row.play_code, { is_enabled: enabled });
|
});
|
||||||
void applyPlayToggleInstant(row.play_code, enabled);
|
}}
|
||||||
},
|
/>
|
||||||
});
|
</div>
|
||||||
}}
|
) : (
|
||||||
/>
|
<div className="flex justify-center">
|
||||||
</div>
|
<AdminStatusBadge status={row.is_enabled ? "enabled" : "disabled"}>
|
||||||
|
{row.is_enabled
|
||||||
|
? t("play.states.enabled", { ns: "config" })
|
||||||
|
: t("play.states.disabled", { ns: "config" })}
|
||||||
|
</AdminStatusBadge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="w-32 text-center">
|
<TableCell className="w-36 text-center">
|
||||||
{isDraft ? (
|
{isDraft ? (
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
className="mx-auto h-8 w-28 text-center text-sm"
|
className="mx-auto h-8 w-full max-w-[9rem] text-center text-sm"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
value={row.display_name ?? ""}
|
value={row.display_name ?? ""}
|
||||||
placeholder={row.play_code}
|
placeholder={row.play_code}
|
||||||
@@ -604,12 +607,12 @@ export function PlayConfigDocScreen() {
|
|||||||
</ConfigReadonlyValue>
|
</ConfigReadonlyValue>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="w-[96px] text-center">
|
<TableCell className="w-24 text-center">
|
||||||
{isDraft ? (
|
{isDraft ? (
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
className="h-8 w-16 font-mono tabular-nums text-center"
|
className="mx-auto h-8 w-16 font-mono tabular-nums text-center"
|
||||||
value={row.display_order}
|
value={row.display_order}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -630,7 +633,7 @@ export function PlayConfigDocScreen() {
|
|||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
className="h-8 text-center font-mono tabular-nums"
|
className="mx-auto h-8 w-24 text-center font-mono tabular-nums"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
value={formatAdminMinorDecimal(row.min_bet_amount, amountCurrencyCode)}
|
value={formatAdminMinorDecimal(row.min_bet_amount, amountCurrencyCode)}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
@@ -651,7 +654,7 @@ export function PlayConfigDocScreen() {
|
|||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
className="h-8 text-center font-mono tabular-nums"
|
className="mx-auto h-8 w-24 text-center font-mono tabular-nums"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
value={formatAdminMinorDecimal(row.max_bet_amount, amountCurrencyCode)}
|
value={formatAdminMinorDecimal(row.max_bet_amount, amountCurrencyCode)}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
|
|||||||
@@ -14,9 +14,12 @@ import {
|
|||||||
publishOddsVersion,
|
publishOddsVersion,
|
||||||
putOddsItems,
|
putOddsItems,
|
||||||
} from "@/api/admin-config";
|
} from "@/api/admin-config";
|
||||||
import { ConfigContextBanner, ConfigContextEmphasis } from "@/modules/config/config-context-banner";
|
|
||||||
import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page";
|
import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import {
|
||||||
|
ConfigVersionToolbarMeta,
|
||||||
|
ConfigVersionToolbarMetaEmphasis,
|
||||||
|
} from "@/modules/config/config-version-toolbar-meta";
|
||||||
|
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
|
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
|
||||||
@@ -54,9 +57,15 @@ function inferPercentFrom(dim: 2 | 3 | 4, rows: OddsItemRow[], typeList: AdminPl
|
|||||||
|
|
||||||
type RebateConfigDocScreenProps = {
|
type RebateConfigDocScreenProps = {
|
||||||
embedded?: boolean;
|
embedded?: boolean;
|
||||||
|
versionId?: string;
|
||||||
|
onVersionIdChange?: (id: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function RebateConfigDocScreen({ embedded = false }: RebateConfigDocScreenProps) {
|
export function RebateConfigDocScreen({
|
||||||
|
embedded = false,
|
||||||
|
versionId: controlledVersionId,
|
||||||
|
onVersionIdChange,
|
||||||
|
}: RebateConfigDocScreenProps) {
|
||||||
const { t } = useTranslation(["config", "common"]);
|
const { t } = useTranslation(["config", "common"]);
|
||||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
||||||
const profile = useAdminProfile();
|
const profile = useAdminProfile();
|
||||||
@@ -65,7 +74,9 @@ export function RebateConfigDocScreen({ embedded = false }: RebateConfigDocScree
|
|||||||
const [types, setTypes] = useState<AdminPlayTypeRow[]>([]);
|
const [types, setTypes] = useState<AdminPlayTypeRow[]>([]);
|
||||||
const [listRows, setListRows] = useState<ConfigVersionSummary[]>([]);
|
const [listRows, setListRows] = useState<ConfigVersionSummary[]>([]);
|
||||||
|
|
||||||
const [selectedId, setSelectedId] = useState("");
|
const [internalSelectedId, setInternalSelectedId] = useState("");
|
||||||
|
const selectedId = controlledVersionId ?? internalSelectedId;
|
||||||
|
const setSelectedId = onVersionIdChange ?? setInternalSelectedId;
|
||||||
const [detail, setDetail] = useState<OddsVersionDetail | null>(null);
|
const [detail, setDetail] = useState<OddsVersionDetail | null>(null);
|
||||||
const [draftRows, setDraftRows] = useState<OddsItemRow[]>([]);
|
const [draftRows, setDraftRows] = useState<OddsItemRow[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -313,31 +324,34 @@ export function RebateConfigDocScreen({ embedded = false }: RebateConfigDocScree
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
footer={
|
||||||
|
embedded || !detail ? null : (
|
||||||
|
<ConfigVersionToolbarMeta emphasis={!isDraft}>
|
||||||
|
<span>
|
||||||
|
{t("rebate.editingVersion", {
|
||||||
|
ns: "config",
|
||||||
|
version: detail.version_no,
|
||||||
|
status:
|
||||||
|
detail.status === "draft"
|
||||||
|
? t("versionStatus.draft", { ns: "config" })
|
||||||
|
: detail.status === "active"
|
||||||
|
? t("versionStatus.active", { ns: "config" })
|
||||||
|
: t("versionStatus.archived", { ns: "config" }),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
{!isDraft ? (
|
||||||
|
<ConfigVersionToolbarMetaEmphasis>
|
||||||
|
{t("rebate.readOnlyHint", { ns: "config" })}
|
||||||
|
</ConfigVersionToolbarMetaEmphasis>
|
||||||
|
) : (
|
||||||
|
<span>{t("versionToolbar.draftEditing", { ns: "config" })}</span>
|
||||||
|
)}
|
||||||
|
</ConfigVersionToolbarMeta>
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const contextBlock =
|
|
||||||
embedded || !detail ? null : (
|
|
||||||
<ConfigContextBanner emphasis={!isDraft}>
|
|
||||||
{t("rebate.editingVersion", {
|
|
||||||
ns: "config",
|
|
||||||
version: detail.version_no,
|
|
||||||
status:
|
|
||||||
detail.status === "draft"
|
|
||||||
? t("versionStatus.draft", { ns: "config" })
|
|
||||||
: detail.status === "active"
|
|
||||||
? t("versionStatus.active", { ns: "config" })
|
|
||||||
: t("versionStatus.archived", { ns: "config" }),
|
|
||||||
})}
|
|
||||||
{!isDraft ? (
|
|
||||||
<>
|
|
||||||
{" "}
|
|
||||||
— <ConfigContextEmphasis>{t("rebate.readOnlyHint", { ns: "config" })}</ConfigContextEmphasis>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</ConfigContextBanner>
|
|
||||||
);
|
|
||||||
|
|
||||||
const fieldsBlock = (
|
const fieldsBlock = (
|
||||||
<>
|
<>
|
||||||
<div className="grid gap-5 sm:grid-cols-3">
|
<div className="grid gap-5 sm:grid-cols-3">
|
||||||
@@ -392,10 +406,10 @@ export function RebateConfigDocScreen({ embedded = false }: RebateConfigDocScree
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-3 rounded-xl border border-border/60 px-4 py-3">
|
<div className="flex items-center justify-between gap-3 rounded-xl border border-border/60 px-4 py-3">
|
||||||
<Label htmlFor="win-enjoy" className="font-medium leading-snug">
|
<p className="text-sm font-medium">{t("rebate.winEnjoy.label", { ns: "config" })}</p>
|
||||||
{t("rebate.winEnjoy.label", { ns: "config" })}
|
<AdminStatusBadge status="enabled">
|
||||||
</Label>
|
{t("system.states.enabled", { ns: "config" })}
|
||||||
<Switch id="win-enjoy" checked disabled aria-label={t("rebate.winEnjoy.label", { ns: "config" })} />
|
</AdminStatusBadge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!embedded ? (
|
{!embedded ? (
|
||||||
@@ -415,8 +429,7 @@ export function RebateConfigDocScreen({ embedded = false }: RebateConfigDocScree
|
|||||||
|
|
||||||
if (embedded) {
|
if (embedded) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-4">
|
||||||
{contextBlock}
|
|
||||||
{fieldsBlock}
|
{fieldsBlock}
|
||||||
<ConfirmDialog />
|
<ConfirmDialog />
|
||||||
</div>
|
</div>
|
||||||
@@ -427,7 +440,6 @@ export function RebateConfigDocScreen({ embedded = false }: RebateConfigDocScree
|
|||||||
<ConfigDocPage
|
<ConfigDocPage
|
||||||
title={t("nav.items.rebate", { ns: "config" })}
|
title={t("nav.items.rebate", { ns: "config" })}
|
||||||
toolbar={toolbarBlock}
|
toolbar={toolbarBlock}
|
||||||
context={contextBlock}
|
|
||||||
>
|
>
|
||||||
{fieldsBlock}
|
{fieldsBlock}
|
||||||
<ConfirmDialog />
|
<ConfirmDialog />
|
||||||
|
|||||||
@@ -14,8 +14,11 @@ import {
|
|||||||
putRiskCapItems,
|
putRiskCapItems,
|
||||||
} from "@/api/admin-config";
|
} from "@/api/admin-config";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ConfigContextBanner, ConfigContextEmphasis } from "@/modules/config/config-context-banner";
|
|
||||||
import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page";
|
import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page";
|
||||||
|
import {
|
||||||
|
ConfigVersionToolbarMeta,
|
||||||
|
ConfigVersionToolbarMetaEmphasis,
|
||||||
|
} from "@/modules/config/config-version-toolbar-meta";
|
||||||
import { ConfigSection } from "@/modules/config/config-section";
|
import { ConfigSection } from "@/modules/config/config-section";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -375,21 +378,27 @@ export function RiskCapDocScreen() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
footer={
|
||||||
|
detail ? (
|
||||||
|
<ConfigVersionToolbarMeta emphasis={!isDraft}>
|
||||||
|
<span>
|
||||||
|
{t("riskCap.effectiveAt", {
|
||||||
|
ns: "config",
|
||||||
|
value: detail.effective_at ? formatDt(detail.effective_at) : "—",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
{!isDraft ? (
|
||||||
|
<ConfigVersionToolbarMetaEmphasis>
|
||||||
|
{t("riskCap.readOnlyHint", { ns: "config" })}
|
||||||
|
</ConfigVersionToolbarMetaEmphasis>
|
||||||
|
) : (
|
||||||
|
<span>{t("versionToolbar.draftEditing", { ns: "config" })}</span>
|
||||||
|
)}
|
||||||
|
</ConfigVersionToolbarMeta>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
context={
|
|
||||||
detail ? (
|
|
||||||
<ConfigContextBanner emphasis={!isDraft}>
|
|
||||||
{t("riskCap.effectiveAt", { ns: "config", value: detail.effective_at ? formatDt(detail.effective_at) : "—" })}
|
|
||||||
{!isDraft ? (
|
|
||||||
<>
|
|
||||||
{" "}
|
|
||||||
— <ConfigContextEmphasis>{t("riskCap.readOnlyHint", { ns: "config" })}</ConfigContextEmphasis>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</ConfigContextBanner>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
contentClassName="space-y-8"
|
contentClassName="space-y-8"
|
||||||
>
|
>
|
||||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||||
@@ -447,8 +456,8 @@ export function RiskCapDocScreen() {
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-[110px]">{t("riskCap.table.number", { ns: "config" })}</TableHead>
|
<TableHead className="w-[110px]">{t("riskCap.table.number", { ns: "config" })}</TableHead>
|
||||||
<TableHead className="w-[140px]">{t("riskCap.table.capAmount", { ns: "config" })}</TableHead>
|
<TableHead className="w-[140px]">{t("riskCap.table.capAmount", { ns: "config" })}</TableHead>
|
||||||
<TableHead className="w-[90px] text-right">{t("riskCap.table.used", { ns: "config" })}</TableHead>
|
<TableHead className="w-[90px] text-center">{t("riskCap.table.used", { ns: "config" })}</TableHead>
|
||||||
<TableHead className="w-[90px] text-right">{t("riskCap.table.remaining", { ns: "config" })}</TableHead>
|
<TableHead className="w-[90px] text-center">{t("riskCap.table.remaining", { ns: "config" })}</TableHead>
|
||||||
<TableHead className="w-[72px] text-center">{t("riskCap.table.soldOut", { ns: "config" })}</TableHead>
|
<TableHead className="w-[72px] text-center">{t("riskCap.table.soldOut", { ns: "config" })}</TableHead>
|
||||||
<TableHead className="w-[160px]">{t("riskCap.table.actions", { ns: "config" })}</TableHead>
|
<TableHead className="w-[160px]">{t("riskCap.table.actions", { ns: "config" })}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -494,8 +503,8 @@ export function RiskCapDocScreen() {
|
|||||||
</ConfigReadonlyValue>
|
</ConfigReadonlyValue>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right text-muted-foreground tabular-nums text-sm">—</TableCell>
|
<TableCell className="text-center text-muted-foreground tabular-nums text-sm">—</TableCell>
|
||||||
<TableCell className="text-right text-muted-foreground tabular-nums text-sm">—</TableCell>
|
<TableCell className="text-center text-muted-foreground tabular-nums text-sm">—</TableCell>
|
||||||
<TableCell className="text-center text-muted-foreground text-sm">—</TableCell>
|
<TableCell className="text-center text-muted-foreground text-sm">—</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{canEditDraft ? (
|
{canEditDraft ? (
|
||||||
@@ -546,9 +555,9 @@ export function RiskCapDocScreen() {
|
|||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>{t("riskCap.table.number", { ns: "config" })}</TableHead>
|
<TableHead>{t("riskCap.table.number", { ns: "config" })}</TableHead>
|
||||||
<TableHead className="text-right">{t("riskCap.table.used", { ns: "config" })}</TableHead>
|
<TableHead className="text-center">{t("riskCap.table.used", { ns: "config" })}</TableHead>
|
||||||
<TableHead className="text-right">{t("riskCap.table.remaining", { ns: "config" })}</TableHead>
|
<TableHead className="text-center">{t("riskCap.table.remaining", { ns: "config" })}</TableHead>
|
||||||
<TableHead className="text-right">{t("riskCap.table.ratio", { ns: "config" })}</TableHead>
|
<TableHead className="text-center">{t("riskCap.table.ratio", { ns: "config" })}</TableHead>
|
||||||
<TableHead className="text-center">{t("riskCap.table.soldOut", { ns: "config" })}</TableHead>
|
<TableHead className="text-center">{t("riskCap.table.soldOut", { ns: "config" })}</TableHead>
|
||||||
<TableHead className="w-[140px]">{t("riskCap.table.actions", { ns: "config" })}</TableHead>
|
<TableHead className="w-[140px]">{t("riskCap.table.actions", { ns: "config" })}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -557,15 +566,11 @@ export function RiskCapDocScreen() {
|
|||||||
{occFiltered.map((r) => (
|
{occFiltered.map((r) => (
|
||||||
<TableRow key={`occ-${r.clientKey}`}>
|
<TableRow key={`occ-${r.clientKey}`}>
|
||||||
<TableCell className="font-mono text-sm">{r.normalized_number}</TableCell>
|
<TableCell className="font-mono text-sm">{r.normalized_number}</TableCell>
|
||||||
<TableCell className="text-right text-muted-foreground">—</TableCell>
|
|
||||||
<TableCell className="text-right text-muted-foreground">—</TableCell>
|
|
||||||
<TableCell className="text-right text-muted-foreground">—</TableCell>
|
|
||||||
<TableCell className="text-center text-muted-foreground">—</TableCell>
|
<TableCell className="text-center text-muted-foreground">—</TableCell>
|
||||||
<TableCell>
|
<TableCell className="text-center text-muted-foreground">—</TableCell>
|
||||||
<Button type="button" variant="ghost" disabled>
|
<TableCell className="text-center text-muted-foreground">—</TableCell>
|
||||||
{t("riskCap.actions.close", { ns: "config" })}
|
<TableCell className="text-center text-muted-foreground">—</TableCell>
|
||||||
</Button>
|
<TableCell className="text-center text-muted-foreground">—</TableCell>
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog";
|
import { useAdminPlayCodeLabel } from "@/hooks/use-admin-play-type-catalog";
|
||||||
|
import { getAdminRequestLocale } from "@/lib/admin-locale";
|
||||||
import { formatAdminMinorUnits, getAdminCurrencyDecimalPlaces } from "@/lib/money";
|
import { formatAdminMinorUnits, getAdminCurrencyDecimalPlaces } from "@/lib/money";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { StatCard } from "@/modules/dashboard/dashboard-visuals";
|
import { StatCard } from "@/modules/dashboard/dashboard-visuals";
|
||||||
@@ -52,7 +53,7 @@ function formatMoneyMinor(minor: number, currencyCode: string | null): string {
|
|||||||
const decimals = getAdminCurrencyDecimalPlaces(code);
|
const decimals = getAdminCurrencyDecimalPlaces(code);
|
||||||
const major = minor / 10 ** decimals;
|
const major = minor / 10 ** decimals;
|
||||||
try {
|
try {
|
||||||
return new Intl.NumberFormat("zh-CN", {
|
return new Intl.NumberFormat(getAdminRequestLocale(), {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency: code,
|
currency: code,
|
||||||
minimumFractionDigits: decimals,
|
minimumFractionDigits: decimals,
|
||||||
@@ -287,8 +288,8 @@ export function DashboardAnalyticsPanel({
|
|||||||
label={t("analytics.summaryBet")}
|
label={t("analytics.summaryBet")}
|
||||||
value={formatMoneyMinor(summary.total_bet_minor, currency)}
|
value={formatMoneyMinor(summary.total_bet_minor, currency)}
|
||||||
hint={t("lifetimeActivityHint", {
|
hint={t("lifetimeActivityHint", {
|
||||||
draws: summary.draw_count.toLocaleString("zh-CN"),
|
draws: summary.draw_count.toLocaleString(getAdminRequestLocale()),
|
||||||
days: summary.business_day_count.toLocaleString("zh-CN"),
|
days: summary.business_day_count.toLocaleString(getAdminRequestLocale()),
|
||||||
})}
|
})}
|
||||||
icon={<Wallet className="size-5" aria-hidden />}
|
icon={<Wallet className="size-5" aria-hidden />}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
||||||
import { adminWeekdayKeyForDate, formatAdminCalendarToday } from "@/lib/admin-datetime";
|
import { adminWeekdayKeyForDate, formatAdminCalendarToday } from "@/lib/admin-datetime";
|
||||||
import { normalizeAdminLanguage } from "@/i18n";
|
import { normalizeAdminLanguage } from "@/i18n";
|
||||||
|
import { getAdminRequestLocale } from "@/lib/admin-locale";
|
||||||
import { formatAdminMinorUnits, getAdminCurrencyDecimalPlaces } from "@/lib/money";
|
import { formatAdminMinorUnits, getAdminCurrencyDecimalPlaces } from "@/lib/money";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
@@ -63,7 +64,7 @@ function formatMoneyMinor(minor: number, currencyCode: string | null): string {
|
|||||||
const decimals = getAdminCurrencyDecimalPlaces(code);
|
const decimals = getAdminCurrencyDecimalPlaces(code);
|
||||||
const major = minor / 10 ** decimals;
|
const major = minor / 10 ** decimals;
|
||||||
try {
|
try {
|
||||||
return new Intl.NumberFormat("zh-CN", {
|
return new Intl.NumberFormat(getAdminRequestLocale(), {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency: code,
|
currency: code,
|
||||||
minimumFractionDigits: decimals,
|
minimumFractionDigits: decimals,
|
||||||
|
|||||||
@@ -191,10 +191,10 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-20">{t("table.id", { ns: "common" })}</TableHead>
|
<TableHead className="w-20">{t("table.id", { ns: "common" })}</TableHead>
|
||||||
<TableHead>{t("status")}</TableHead>
|
<TableHead>{t("status")}</TableHead>
|
||||||
<TableHead className="text-right">{t("ticketCount")}</TableHead>
|
<TableHead className="text-center">{t("ticketCount")}</TableHead>
|
||||||
<TableHead className="text-right">{t("winCount")}</TableHead>
|
<TableHead className="text-center">{t("winCount")}</TableHead>
|
||||||
<TableHead className="text-right">{t("payoutTotal")}</TableHead>
|
<TableHead className="text-center">{t("payoutTotal")}</TableHead>
|
||||||
<TableHead className="text-right">{t("jackpotPayout")}</TableHead>
|
<TableHead className="text-center">{t("jackpotPayout")}</TableHead>
|
||||||
<TableHead>{t("finishedAt")}</TableHead>
|
<TableHead>{t("finishedAt")}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -207,16 +207,16 @@ export function DrawFinanceConsole({ drawId }: { drawId: string }): React.ReactE
|
|||||||
{settlementBatchStatusLabel(b.status, t)}
|
{settlementBatchStatusLabel(b.status, t)}
|
||||||
</AdminStatusBadge>
|
</AdminStatusBadge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right tabular-nums text-xs">
|
<TableCell className="text-center tabular-nums text-xs">
|
||||||
{b.total_ticket_count}
|
{b.total_ticket_count}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right tabular-nums text-xs">
|
<TableCell className="text-center tabular-nums text-xs">
|
||||||
{b.total_win_count}
|
{b.total_win_count}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right tabular-nums text-xs">
|
<TableCell className="text-center tabular-nums text-xs">
|
||||||
{formatMoney(b.total_payout_amount)}
|
{formatMoney(b.total_payout_amount)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right tabular-nums text-xs">
|
<TableCell className="text-center tabular-nums text-xs">
|
||||||
{formatMoney(b.total_jackpot_payout_amount)}
|
{formatMoney(b.total_jackpot_payout_amount)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-mono text-[11px] text-muted-foreground">
|
<TableCell className="font-mono text-[11px] text-muted-foreground">
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
|
|||||||
<TableHead>{t("batchId")}</TableHead>
|
<TableHead>{t("batchId")}</TableHead>
|
||||||
<TableHead>{t("version", { version: "" }).replace(" v", "").trim()}</TableHead>
|
<TableHead>{t("version", { version: "" }).replace(" v", "").trim()}</TableHead>
|
||||||
<TableHead>{t("numberCount")}</TableHead>
|
<TableHead>{t("numberCount")}</TableHead>
|
||||||
<TableHead className="text-right">{t("actions")}</TableHead>
|
<TableHead className="text-center">{t("actions")}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -213,7 +213,7 @@ export function DrawReviewConsole({ drawId }: { drawId: string }) {
|
|||||||
<TableCell className="font-mono text-xs">{b.id}</TableCell>
|
<TableCell className="font-mono text-xs">{b.id}</TableCell>
|
||||||
<TableCell>v{b.result_version}</TableCell>
|
<TableCell>v{b.result_version}</TableCell>
|
||||||
<TableCell>{b.items.length}</TableCell>
|
<TableCell>{b.items.length}</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-center">
|
||||||
{canManageDraw ? (
|
{canManageDraw ? (
|
||||||
<Link
|
<Link
|
||||||
href={`/admin/draws/${drawId}/publish/${b.id}`}
|
href={`/admin/draws/${drawId}/publish/${b.id}`}
|
||||||
|
|||||||
@@ -395,10 +395,10 @@ export function DrawsIndexConsole() {
|
|||||||
<TableHead>{t("closeTime")}</TableHead>
|
<TableHead>{t("closeTime")}</TableHead>
|
||||||
<TableHead>{t("drawTime")}</TableHead>
|
<TableHead>{t("drawTime")}</TableHead>
|
||||||
<TableHead>{t("status")}</TableHead>
|
<TableHead>{t("status")}</TableHead>
|
||||||
<TableHead className="text-right">{t("betTotal")}</TableHead>
|
<TableHead className="text-center">{t("betTotal")}</TableHead>
|
||||||
<TableHead className="text-right">{t("payoutTotal")}</TableHead>
|
<TableHead className="text-center">{t("payoutTotal")}</TableHead>
|
||||||
<TableHead className="text-right">{t("profitLoss")}</TableHead>
|
<TableHead className="text-center">{t("profitLoss")}</TableHead>
|
||||||
<TableHead className="text-right">{t("actions")}</TableHead>
|
<TableHead className="text-center">{t("actions")}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -435,19 +435,19 @@ export function DrawsIndexConsole() {
|
|||||||
label={drawStatusLabel(row.status, t)}
|
label={drawStatusLabel(row.status, t)}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right text-xs tabular-nums">
|
<TableCell className="text-center text-xs tabular-nums">
|
||||||
{row.total_bet_minor != null
|
{row.total_bet_minor != null
|
||||||
? formatAdminMinorUnits(row.total_bet_minor, defaultCurrency)
|
? formatAdminMinorUnits(row.total_bet_minor, defaultCurrency)
|
||||||
: "—"}
|
: "—"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right text-xs tabular-nums">
|
<TableCell className="text-center text-xs tabular-nums">
|
||||||
{row.total_payout_minor != null
|
{row.total_payout_minor != null
|
||||||
? formatAdminMinorUnits(row.total_payout_minor, defaultCurrency)
|
? formatAdminMinorUnits(row.total_payout_minor, defaultCurrency)
|
||||||
: "—"}
|
: "—"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell
|
<TableCell
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-right text-xs tabular-nums",
|
"text-center text-xs tabular-nums",
|
||||||
(row.profit_loss_minor ?? 0) < 0 ? "text-destructive" : "text-emerald-600",
|
(row.profit_loss_minor ?? 0) < 0 ? "text-destructive" : "text-emerald-600",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -455,8 +455,8 @@ export function DrawsIndexConsole() {
|
|||||||
? formatAdminMinorUnits(row.profit_loss_minor, defaultCurrency)
|
? formatAdminMinorUnits(row.profit_loss_minor, defaultCurrency)
|
||||||
: "—"}
|
: "—"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-center">
|
||||||
<div className="flex flex-wrap items-center justify-end gap-1.5">
|
<div className="flex flex-wrap items-center justify-center gap-1.5">
|
||||||
<Link
|
<Link
|
||||||
href={`/admin/draws/${row.id}`}
|
href={`/admin/draws/${row.id}`}
|
||||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
|
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
|
||||||
|
|||||||
@@ -243,8 +243,8 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
|
|||||||
<TableHead className="w-14">{t("table.id", { ns: "common" })}</TableHead>
|
<TableHead className="w-14">{t("table.id", { ns: "common" })}</TableHead>
|
||||||
<TableHead className="w-[11rem]">{t("drawNo")}</TableHead>
|
<TableHead className="w-[11rem]">{t("drawNo")}</TableHead>
|
||||||
<TableHead className="w-28">{t("trigger")}</TableHead>
|
<TableHead className="w-28">{t("trigger")}</TableHead>
|
||||||
<TableHead className="w-32 text-right">{t("payoutAmount")}</TableHead>
|
<TableHead className="w-32 text-center">{t("payoutAmount")}</TableHead>
|
||||||
<TableHead className="w-24 text-right">{t("winnerCount")}</TableHead>
|
<TableHead className="w-24 text-center">{t("winnerCount")}</TableHead>
|
||||||
<TableHead className="w-[11rem]">{t("time")}</TableHead>
|
<TableHead className="w-[11rem]">{t("time")}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -261,10 +261,10 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
|
|||||||
<TableCell className="font-mono text-xs">{r.id}</TableCell>
|
<TableCell className="font-mono text-xs">{r.id}</TableCell>
|
||||||
<TableCell className="font-mono text-xs">{r.draw_no ?? "—"}</TableCell>
|
<TableCell className="font-mono text-xs">{r.draw_no ?? "—"}</TableCell>
|
||||||
<TableCell className="text-xs">{triggerTypeText(r.trigger_type)}</TableCell>
|
<TableCell className="text-xs">{triggerTypeText(r.trigger_type)}</TableCell>
|
||||||
<TableCell className="text-right font-mono text-xs tabular-nums">
|
<TableCell className="text-center font-mono text-xs tabular-nums">
|
||||||
{formatAdminMinorUnits(r.total_payout_amount, r.currency_code ?? "NPR")}
|
{formatAdminMinorUnits(r.total_payout_amount, r.currency_code ?? "NPR")}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right tabular-nums">{r.winner_count}</TableCell>
|
<TableCell className="text-center tabular-nums">{r.winner_count}</TableCell>
|
||||||
<TableCell className="text-xs text-muted-foreground whitespace-nowrap">
|
<TableCell className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
{formatDt(r.created_at)}
|
{formatDt(r.created_at)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -293,7 +293,7 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
|
|||||||
<TableHead className="w-[11rem]">{t("drawNo")}</TableHead>
|
<TableHead className="w-[11rem]">{t("drawNo")}</TableHead>
|
||||||
<TableHead className="w-[11rem]">{t("ticketNo")}</TableHead>
|
<TableHead className="w-[11rem]">{t("ticketNo")}</TableHead>
|
||||||
<TableHead>{t("player")}</TableHead>
|
<TableHead>{t("player")}</TableHead>
|
||||||
<TableHead className="w-32 text-right">{t("contributionAmount")}</TableHead>
|
<TableHead className="w-32 text-center">{t("contributionAmount")}</TableHead>
|
||||||
<TableHead className="w-[11rem]">{t("time")}</TableHead>
|
<TableHead className="w-[11rem]">{t("time")}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -311,7 +311,7 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
|
|||||||
<TableCell className="font-mono text-xs">{r.draw_no ?? "—"}</TableCell>
|
<TableCell className="font-mono text-xs">{r.draw_no ?? "—"}</TableCell>
|
||||||
<TableCell className="font-mono text-xs">{r.ticket_no ?? "—"}</TableCell>
|
<TableCell className="font-mono text-xs">{r.ticket_no ?? "—"}</TableCell>
|
||||||
<TableCell className="max-w-[12rem] truncate text-xs">{r.player_username ?? "—"}</TableCell>
|
<TableCell className="max-w-[12rem] truncate text-xs">{r.player_username ?? "—"}</TableCell>
|
||||||
<TableCell className="text-right font-mono text-xs tabular-nums">
|
<TableCell className="text-center font-mono text-xs tabular-nums">
|
||||||
{formatAdminMinorUnits(r.contribution_amount, r.currency_code ?? "NPR")}
|
{formatAdminMinorUnits(r.contribution_amount, r.currency_code ?? "NPR")}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-xs text-muted-foreground whitespace-nowrap">
|
<TableCell className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||||
|
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -16,8 +17,10 @@ import {
|
|||||||
} from "@/api/admin-player";
|
} from "@/api/admin-player";
|
||||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||||
|
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||||
|
import { ConfirmableSwitch } from "@/components/admin/confirmable-switch";
|
||||||
|
import { resolvePlayerStatusTone } from "@/lib/admin-status-tone";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -66,7 +69,8 @@ const PLAYER_STATUS_OPTIONS = [
|
|||||||
|
|
||||||
export function PlayersConsole(): React.ReactElement {
|
export function PlayersConsole(): React.ReactElement {
|
||||||
const { t } = useTranslation(["players", "common"]);
|
const { t } = useTranslation(["players", "common"]);
|
||||||
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
const { request: requestConfirm, ConfirmDialog, busy: confirmBusy } = useConfirmAction();
|
||||||
|
const formatDt = useAdminDateTimeFormatter();
|
||||||
const exportLabels = useExportLabels("players");
|
const exportLabels = useExportLabels("players");
|
||||||
const profile = useAdminProfile();
|
const profile = useAdminProfile();
|
||||||
useAdminCurrencyCatalog();
|
useAdminCurrencyCatalog();
|
||||||
@@ -338,8 +342,8 @@ export function PlayersConsole(): React.ReactElement {
|
|||||||
<TableHead>{t("username")}</TableHead>
|
<TableHead>{t("username")}</TableHead>
|
||||||
<TableHead>{t("nickname")}</TableHead>
|
<TableHead>{t("nickname")}</TableHead>
|
||||||
<TableHead className="whitespace-nowrap">{t("currency")}</TableHead>
|
<TableHead className="whitespace-nowrap">{t("currency")}</TableHead>
|
||||||
<TableHead className="whitespace-nowrap text-right">{t("balance")}</TableHead>
|
<TableHead className="whitespace-nowrap text-center">{t("balance")}</TableHead>
|
||||||
<TableHead className="whitespace-nowrap text-right">{t("available")}</TableHead>
|
<TableHead className="whitespace-nowrap text-center">{t("available")}</TableHead>
|
||||||
<TableHead className="w-20 whitespace-nowrap">{t("status")}</TableHead>
|
<TableHead className="w-20 whitespace-nowrap">{t("status")}</TableHead>
|
||||||
<TableHead className="whitespace-nowrap">{t("lastLogin")}</TableHead>
|
<TableHead className="whitespace-nowrap">{t("lastLogin")}</TableHead>
|
||||||
<TableHead className="min-w-[10rem]">{t("actions")}</TableHead>
|
<TableHead className="min-w-[10rem]">{t("actions")}</TableHead>
|
||||||
@@ -365,21 +369,28 @@ export function PlayersConsole(): React.ReactElement {
|
|||||||
<TableCell>{row.username ?? "—"}</TableCell>
|
<TableCell>{row.username ?? "—"}</TableCell>
|
||||||
<TableCell>{row.nickname ?? "—"}</TableCell>
|
<TableCell>{row.nickname ?? "—"}</TableCell>
|
||||||
<TableCell>{row.default_currency}</TableCell>
|
<TableCell>{row.default_currency}</TableCell>
|
||||||
<TableCell className="whitespace-nowrap text-right tabular-nums text-xs">
|
<TableCell className="whitespace-nowrap text-center tabular-nums text-xs">
|
||||||
{row.wallets.length > 0
|
{row.wallets.length > 0
|
||||||
? formatAdminMinorUnits(row.wallets[0].balance, row.wallets[0].currency_code)
|
? formatAdminMinorUnits(row.wallets[0].balance, row.wallets[0].currency_code)
|
||||||
: "—"}
|
: "—"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="whitespace-nowrap text-right tabular-nums text-xs">
|
<TableCell className="whitespace-nowrap text-center tabular-nums text-xs">
|
||||||
{row.wallets.length > 0
|
{row.wallets.length > 0
|
||||||
? formatAdminMinorUnits(row.wallets[0].available_balance, row.wallets[0].currency_code)
|
? formatAdminMinorUnits(row.wallets[0].available_balance, row.wallets[0].currency_code)
|
||||||
: "—"}
|
: "—"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{canFreezePlayers ? (
|
{row.status === 2 ? (
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Switch
|
<AdminStatusBadge status={row.status} tone={resolvePlayerStatusTone(row.status)}>
|
||||||
|
{playerStatusLabelT(row.status, t)}
|
||||||
|
</AdminStatusBadge>
|
||||||
|
</div>
|
||||||
|
) : canFreezePlayers ? (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<ConfirmableSwitch
|
||||||
checked={row.status === 0}
|
checked={row.status === 0}
|
||||||
|
confirmBusy={confirmBusy}
|
||||||
disabled={freezeBusyId === row.id}
|
disabled={freezeBusyId === row.id}
|
||||||
aria-label={t("status")}
|
aria-label={t("status")}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
@@ -407,15 +418,7 @@ export function PlayersConsole(): React.ReactElement {
|
|||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||||
{row.last_login_at
|
{row.last_login_at ? formatDt(row.last_login_at) : "—"}
|
||||||
? new Date(row.last_login_at).toLocaleString("zh-CN", {
|
|
||||||
year: "numeric",
|
|
||||||
month: "2-digit",
|
|
||||||
day: "2-digit",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
})
|
|
||||||
: "—"}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{canManagePlayers || canFreezePlayers ? (
|
{canManagePlayers || canFreezePlayers ? (
|
||||||
|
|||||||
@@ -312,20 +312,25 @@ function parsePositiveInteger(value: string): number | null {
|
|||||||
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null;
|
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveDraw(filters: ReportFilters): Promise<{ id: number; draw_no: string }> {
|
async function resolveDraw(
|
||||||
|
filters: ReportFilters,
|
||||||
|
t: (key: string, options?: { ns?: string; drawNo?: string }) => string,
|
||||||
|
): Promise<{ id: number; draw_no: string }> {
|
||||||
if (filters.drawId && filters.drawNo.trim()) {
|
if (filters.drawId && filters.drawNo.trim()) {
|
||||||
return { id: filters.drawId, draw_no: filters.drawNo.trim() };
|
return { id: filters.drawId, draw_no: filters.drawNo.trim() };
|
||||||
}
|
}
|
||||||
|
|
||||||
const drawNo = filters.drawNo.trim();
|
const drawNo = filters.drawNo.trim();
|
||||||
if (!drawNo) {
|
if (!drawNo) {
|
||||||
throw new LotteryApiBizError("请输入期号", -1, null);
|
throw new LotteryApiBizError(t("validation.drawNoRequired", { ns: "reports" }), -1, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await getAdminDraws({ draw_no: drawNo, page: 1, per_page: 1 });
|
const data = await getAdminDraws({ draw_no: drawNo, page: 1, per_page: 1 });
|
||||||
const matched = data.items.find((item) => item.draw_no === drawNo) ?? data.items[0];
|
const matched = data.items.find((item) => item.draw_no === drawNo) ?? data.items[0];
|
||||||
if (!matched) {
|
if (!matched) {
|
||||||
throw new LotteryApiBizError("未找到期号", -1, { drawNo });
|
throw new LotteryApiBizError(t("validation.drawNoNotFound", { ns: "reports", drawNo }), -1, {
|
||||||
|
drawNo,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return { id: matched.id, draw_no: matched.draw_no };
|
return { id: matched.id, draw_no: matched.draw_no };
|
||||||
}
|
}
|
||||||
@@ -459,7 +464,7 @@ export function ReportsConsole() {
|
|||||||
try {
|
try {
|
||||||
switch (selectedReport.key) {
|
switch (selectedReport.key) {
|
||||||
case "draw_profit": {
|
case "draw_profit": {
|
||||||
const draw = await resolveDraw(filters);
|
const draw = await resolveDraw(filters, t);
|
||||||
const summary = await getAdminDrawFinanceSummary(draw.id);
|
const summary = await getAdminDrawFinanceSummary(draw.id);
|
||||||
setResult({
|
setResult({
|
||||||
key: "draw_profit",
|
key: "draw_profit",
|
||||||
@@ -580,7 +585,7 @@ export function ReportsConsole() {
|
|||||||
if (!filters.number.trim()) {
|
if (!filters.number.trim()) {
|
||||||
throw new LotteryApiBizError(t("validation.drawNoNumberRequired"), -1, null);
|
throw new LotteryApiBizError(t("validation.drawNoNumberRequired"), -1, null);
|
||||||
}
|
}
|
||||||
const draw = await resolveDraw(filters);
|
const draw = await resolveDraw(filters, t);
|
||||||
const detail = await getAdminRiskPoolDetail(draw.id, filters.number.trim(), { page, per_page: perPage });
|
const detail = await getAdminRiskPoolDetail(draw.id, filters.number.trim(), { page, per_page: perPage });
|
||||||
const rows: ExportRow[] = [
|
const rows: ExportRow[] = [
|
||||||
{
|
{
|
||||||
@@ -627,7 +632,7 @@ export function ReportsConsole() {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "sold_out_number": {
|
case "sold_out_number": {
|
||||||
const draw = await resolveDraw(filters);
|
const draw = await resolveDraw(filters, t);
|
||||||
const payload = await getAdminRiskPools(draw.id, { page, per_page: perPage, sold_out_only: true, sort: "number_asc" });
|
const payload = await getAdminRiskPools(draw.id, { page, per_page: perPage, sold_out_only: true, sort: "number_asc" });
|
||||||
const rows = payload.items.map((item) => ({
|
const rows = payload.items.map((item) => ({
|
||||||
draw_id: payload.draw_id,
|
draw_id: payload.draw_id,
|
||||||
@@ -1022,22 +1027,22 @@ export function ReportsConsole() {
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell className="font-medium">{summary.draw_no}</TableCell>
|
<TableCell className="font-medium">{summary.draw_no}</TableCell>
|
||||||
<TableCell>{summary.draw_status}</TableCell>
|
<TableCell>{summary.draw_status}</TableCell>
|
||||||
<TableCell className="text-right">{summary.order_count}</TableCell>
|
<TableCell className="text-center">{summary.order_count}</TableCell>
|
||||||
<TableCell className="text-right">{summary.ticket_item_count}</TableCell>
|
<TableCell className="text-center">{summary.ticket_item_count}</TableCell>
|
||||||
<TableCell className="text-right">{formatPlainMoney(summary.total_bet_minor, summary.currency_code)}</TableCell>
|
<TableCell className="text-center">{formatPlainMoney(summary.total_bet_minor, summary.currency_code)}</TableCell>
|
||||||
<TableCell className="text-right">{formatPlainMoney(summary.total_payout_minor, summary.currency_code)}</TableCell>
|
<TableCell className="text-center">{formatPlainMoney(summary.total_payout_minor, summary.currency_code)}</TableCell>
|
||||||
<TableCell className="text-right">{formatPlainMoney(summary.approx_house_gross_minor, summary.currency_code)}</TableCell>
|
<TableCell className="text-center">{formatPlainMoney(summary.approx_house_gross_minor, summary.currency_code)}</TableCell>
|
||||||
<TableCell>{summary.settlement_batches.length}</TableCell>
|
<TableCell>{summary.settlement_batches.length}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
{summary.settlement_batches.map((batch) => (
|
{summary.settlement_batches.map((batch) => (
|
||||||
<TableRow key={batch.id} className="bg-muted/15">
|
<TableRow key={batch.id} className="bg-muted/15">
|
||||||
<TableCell>#{batch.id}</TableCell>
|
<TableCell>#{batch.id}</TableCell>
|
||||||
<TableCell>{batch.status}</TableCell>
|
<TableCell>{batch.status}</TableCell>
|
||||||
<TableCell className="text-right">{batch.total_ticket_count}</TableCell>
|
<TableCell className="text-center">{batch.total_ticket_count}</TableCell>
|
||||||
<TableCell className="text-right">{batch.total_win_count}</TableCell>
|
<TableCell className="text-center">{batch.total_win_count}</TableCell>
|
||||||
<TableCell className="text-right">-</TableCell>
|
<TableCell className="text-center">-</TableCell>
|
||||||
<TableCell className="text-right">{formatPlainMoney(batch.total_payout_amount, summary.currency_code)}</TableCell>
|
<TableCell className="text-center">{formatPlainMoney(batch.total_payout_amount, summary.currency_code)}</TableCell>
|
||||||
<TableCell className="text-right">{formatPlainMoney(batch.total_jackpot_payout_amount, summary.currency_code)}</TableCell>
|
<TableCell className="text-center">{formatPlainMoney(batch.total_jackpot_payout_amount, summary.currency_code)}</TableCell>
|
||||||
<TableCell>{formatTs(batch.finished_at)}</TableCell>
|
<TableCell>{formatTs(batch.finished_at)}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
@@ -1052,7 +1057,7 @@ export function ReportsConsole() {
|
|||||||
<TableCell>{optionText(item.username, item.nickname) || item.player_id}</TableCell>
|
<TableCell>{optionText(item.username, item.nickname) || item.player_id}</TableCell>
|
||||||
<TableCell>{item.direction}</TableCell>
|
<TableCell>{item.direction}</TableCell>
|
||||||
<TableCell>{item.status}</TableCell>
|
<TableCell>{item.status}</TableCell>
|
||||||
<TableCell className="text-right">{item.currency_code} {item.amount}</TableCell>
|
<TableCell className="text-center">{item.currency_code} {item.amount}</TableCell>
|
||||||
<TableCell>{item.external_ref_no || "-"}</TableCell>
|
<TableCell>{item.external_ref_no || "-"}</TableCell>
|
||||||
<TableCell>{item.fail_reason || "-"}</TableCell>
|
<TableCell>{item.fail_reason || "-"}</TableCell>
|
||||||
<TableCell>{formatTs(item.created_at)}</TableCell>
|
<TableCell>{formatTs(item.created_at)}</TableCell>
|
||||||
@@ -1066,9 +1071,9 @@ export function ReportsConsole() {
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell className="font-medium">{result.raw.pool.normalized_number}</TableCell>
|
<TableCell className="font-medium">{result.raw.pool.normalized_number}</TableCell>
|
||||||
<TableCell>{result.raw.draw_no}</TableCell>
|
<TableCell>{result.raw.draw_no}</TableCell>
|
||||||
<TableCell className="text-right">{formatPlainMoney(result.raw.pool.total_cap_amount, result.raw.currency_code)}</TableCell>
|
<TableCell className="text-center">{formatPlainMoney(result.raw.pool.total_cap_amount, result.raw.currency_code)}</TableCell>
|
||||||
<TableCell className="text-right">{formatPlainMoney(result.raw.pool.locked_amount, result.raw.currency_code)}</TableCell>
|
<TableCell className="text-center">{formatPlainMoney(result.raw.pool.locked_amount, result.raw.currency_code)}</TableCell>
|
||||||
<TableCell className="text-right">{formatPlainMoney(result.raw.pool.remaining_amount, result.raw.currency_code)}</TableCell>
|
<TableCell className="text-center">{formatPlainMoney(result.raw.pool.remaining_amount, result.raw.currency_code)}</TableCell>
|
||||||
<TableCell>{result.raw.pool.is_sold_out ? t("yes") : t("no")}</TableCell>
|
<TableCell>{result.raw.pool.is_sold_out ? t("yes") : t("no")}</TableCell>
|
||||||
<TableCell>{result.raw.pool.usage_ratio == null ? "-" : `${result.raw.pool.usage_ratio}%`}</TableCell>
|
<TableCell>{result.raw.pool.usage_ratio == null ? "-" : `${result.raw.pool.usage_ratio}%`}</TableCell>
|
||||||
<TableCell>v{result.raw.pool.version}</TableCell>
|
<TableCell>v{result.raw.pool.version}</TableCell>
|
||||||
@@ -1077,7 +1082,7 @@ export function ReportsConsole() {
|
|||||||
<TableRow key={item.id} className="bg-muted/15">
|
<TableRow key={item.id} className="bg-muted/15">
|
||||||
<TableCell className="font-mono text-xs">#{item.id}</TableCell>
|
<TableCell className="font-mono text-xs">#{item.id}</TableCell>
|
||||||
<TableCell>{item.action_type}</TableCell>
|
<TableCell>{item.action_type}</TableCell>
|
||||||
<TableCell className="text-right">{formatPlainMoney(item.amount, result.raw.currency_code)}</TableCell>
|
<TableCell className="text-center">{formatPlainMoney(item.amount, result.raw.currency_code)}</TableCell>
|
||||||
<TableCell>{playCodeLabel(item.play_code)}</TableCell>
|
<TableCell>{playCodeLabel(item.play_code)}</TableCell>
|
||||||
<TableCell>{item.ticket_no || "-"}</TableCell>
|
<TableCell>{item.ticket_no || "-"}</TableCell>
|
||||||
<TableCell>{item.player_id || "-"}</TableCell>
|
<TableCell>{item.player_id || "-"}</TableCell>
|
||||||
@@ -1094,9 +1099,9 @@ export function ReportsConsole() {
|
|||||||
<TableRow key={item.normalized_number}>
|
<TableRow key={item.normalized_number}>
|
||||||
<TableCell className="font-medium">{item.normalized_number}</TableCell>
|
<TableCell className="font-medium">{item.normalized_number}</TableCell>
|
||||||
<TableCell>{filters.drawNo}</TableCell>
|
<TableCell>{filters.drawNo}</TableCell>
|
||||||
<TableCell className="text-right">{formatPlainMoney(item.total_cap_amount, null)}</TableCell>
|
<TableCell className="text-center">{formatPlainMoney(item.total_cap_amount, null)}</TableCell>
|
||||||
<TableCell className="text-right">{formatPlainMoney(item.locked_amount, null)}</TableCell>
|
<TableCell className="text-center">{formatPlainMoney(item.locked_amount, null)}</TableCell>
|
||||||
<TableCell className="text-right">{formatPlainMoney(item.remaining_amount, null)}</TableCell>
|
<TableCell className="text-center">{formatPlainMoney(item.remaining_amount, null)}</TableCell>
|
||||||
<TableCell>{item.is_sold_out ? t("yes") : t("no")}</TableCell>
|
<TableCell>{item.is_sold_out ? t("yes") : t("no")}</TableCell>
|
||||||
<TableCell>{item.usage_ratio == null ? "-" : `${item.usage_ratio}%`}</TableCell>
|
<TableCell>{item.usage_ratio == null ? "-" : `${item.usage_ratio}%`}</TableCell>
|
||||||
<TableCell>v{item.version}</TableCell>
|
<TableCell>v{item.version}</TableCell>
|
||||||
@@ -1109,9 +1114,9 @@ export function ReportsConsole() {
|
|||||||
<TableRow key={item.business_date}>
|
<TableRow key={item.business_date}>
|
||||||
<TableCell className="font-medium">{item.business_date}</TableCell>
|
<TableCell className="font-medium">{item.business_date}</TableCell>
|
||||||
<TableCell>-</TableCell>
|
<TableCell>-</TableCell>
|
||||||
<TableCell className="text-right">{formatPlainMoney(item.total_bet_minor, "NPR")}</TableCell>
|
<TableCell className="text-center">{formatPlainMoney(item.total_bet_minor, "NPR")}</TableCell>
|
||||||
<TableCell className="text-right">{formatPlainMoney(item.total_payout_minor, "NPR")}</TableCell>
|
<TableCell className="text-center">{formatPlainMoney(item.total_payout_minor, "NPR")}</TableCell>
|
||||||
<TableCell className="text-right">{formatPlainMoney(item.approx_house_gross_minor, "NPR")}</TableCell>
|
<TableCell className="text-center">{formatPlainMoney(item.approx_house_gross_minor, "NPR")}</TableCell>
|
||||||
<TableCell>-</TableCell>
|
<TableCell>-</TableCell>
|
||||||
<TableCell>-</TableCell>
|
<TableCell>-</TableCell>
|
||||||
<TableCell>-</TableCell>
|
<TableCell>-</TableCell>
|
||||||
@@ -1124,9 +1129,9 @@ export function ReportsConsole() {
|
|||||||
<TableRow key={item.player_id}>
|
<TableRow key={item.player_id}>
|
||||||
<TableCell className="font-medium">{item.username}</TableCell>
|
<TableCell className="font-medium">{item.username}</TableCell>
|
||||||
<TableCell>ID {item.player_id}</TableCell>
|
<TableCell>ID {item.player_id}</TableCell>
|
||||||
<TableCell className="text-right">{formatPlainMoney(item.total_bet_minor, "NPR")}</TableCell>
|
<TableCell className="text-center">{formatPlainMoney(item.total_bet_minor, "NPR")}</TableCell>
|
||||||
<TableCell className="text-right">{formatPlainMoney(item.total_payout_minor, "NPR")}</TableCell>
|
<TableCell className="text-center">{formatPlainMoney(item.total_payout_minor, "NPR")}</TableCell>
|
||||||
<TableCell className="text-right">{formatPlainMoney(item.net_win_loss_minor, "NPR")}</TableCell>
|
<TableCell className="text-center">{formatPlainMoney(item.net_win_loss_minor, "NPR")}</TableCell>
|
||||||
<TableCell>-</TableCell>
|
<TableCell>-</TableCell>
|
||||||
<TableCell>-</TableCell>
|
<TableCell>-</TableCell>
|
||||||
<TableCell>-</TableCell>
|
<TableCell>-</TableCell>
|
||||||
@@ -1139,9 +1144,9 @@ export function ReportsConsole() {
|
|||||||
<TableRow key={`${item.play_code}-${item.dimension}`}>
|
<TableRow key={`${item.play_code}-${item.dimension}`}>
|
||||||
<TableCell className="font-medium">{playCodeLabel(item.play_code)}</TableCell>
|
<TableCell className="font-medium">{playCodeLabel(item.play_code)}</TableCell>
|
||||||
<TableCell>{item.dimension}D</TableCell>
|
<TableCell>{item.dimension}D</TableCell>
|
||||||
<TableCell className="text-right">{formatPlainMoney(item.total_bet_minor, "NPR")}</TableCell>
|
<TableCell className="text-center">{formatPlainMoney(item.total_bet_minor, "NPR")}</TableCell>
|
||||||
<TableCell className="text-right">{formatPlainMoney(item.total_payout_minor, "NPR")}</TableCell>
|
<TableCell className="text-center">{formatPlainMoney(item.total_payout_minor, "NPR")}</TableCell>
|
||||||
<TableCell className="text-right">{formatPlainMoney(item.approx_house_gross_minor, "NPR")}</TableCell>
|
<TableCell className="text-center">{formatPlainMoney(item.approx_house_gross_minor, "NPR")}</TableCell>
|
||||||
<TableCell>-</TableCell>
|
<TableCell>-</TableCell>
|
||||||
<TableCell>-</TableCell>
|
<TableCell>-</TableCell>
|
||||||
<TableCell>-</TableCell>
|
<TableCell>-</TableCell>
|
||||||
@@ -1154,8 +1159,8 @@ export function ReportsConsole() {
|
|||||||
<TableRow key={item.play_code}>
|
<TableRow key={item.play_code}>
|
||||||
<TableCell className="font-medium">{playCodeLabel(item.play_code)}</TableCell>
|
<TableCell className="font-medium">{playCodeLabel(item.play_code)}</TableCell>
|
||||||
<TableCell>{item.order_count}</TableCell>
|
<TableCell>{item.order_count}</TableCell>
|
||||||
<TableCell className="text-right">{formatPlainMoney(item.total_rebate_minor, "NPR")}</TableCell>
|
<TableCell className="text-center">{formatPlainMoney(item.total_rebate_minor, "NPR")}</TableCell>
|
||||||
<TableCell className="text-right">{item.ticket_item_count}</TableCell>
|
<TableCell className="text-center">{item.ticket_item_count}</TableCell>
|
||||||
<TableCell>-</TableCell>
|
<TableCell>-</TableCell>
|
||||||
<TableCell>-</TableCell>
|
<TableCell>-</TableCell>
|
||||||
<TableCell>-</TableCell>
|
<TableCell>-</TableCell>
|
||||||
@@ -1294,9 +1299,9 @@ export function ReportsConsole() {
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>{t("preview.columns.primary")}</TableHead>
|
<TableHead>{t("preview.columns.primary")}</TableHead>
|
||||||
<TableHead>{t("preview.columns.secondary")}</TableHead>
|
<TableHead>{t("preview.columns.secondary")}</TableHead>
|
||||||
<TableHead className="text-right">{t("preview.columns.metricA")}</TableHead>
|
<TableHead className="text-center">{t("preview.columns.metricA")}</TableHead>
|
||||||
<TableHead className="text-right">{t("preview.columns.metricB")}</TableHead>
|
<TableHead className="text-center">{t("preview.columns.metricB")}</TableHead>
|
||||||
<TableHead className="text-right">{t("preview.columns.metricC")}</TableHead>
|
<TableHead className="text-center">{t("preview.columns.metricC")}</TableHead>
|
||||||
<TableHead>{t("preview.columns.status")}</TableHead>
|
<TableHead>{t("preview.columns.status")}</TableHead>
|
||||||
<TableHead>{t("preview.columns.extra")}</TableHead>
|
<TableHead>{t("preview.columns.extra")}</TableHead>
|
||||||
<TableHead>{t("preview.columns.time")}</TableHead>
|
<TableHead>{t("preview.columns.time")}</TableHead>
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ export function RiskIndexConsole() {
|
|||||||
<TableHead>{t("drawNo")}</TableHead>
|
<TableHead>{t("drawNo")}</TableHead>
|
||||||
<TableHead>{t("status")}</TableHead>
|
<TableHead>{t("status")}</TableHead>
|
||||||
<TableHead>{t("closeTime")}</TableHead>
|
<TableHead>{t("closeTime")}</TableHead>
|
||||||
<TableHead className="text-right">{t("actions")}</TableHead>
|
<TableHead className="text-center">{t("actions")}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -204,7 +204,7 @@ export function RiskIndexConsole() {
|
|||||||
<TableCell className="text-sm text-muted-foreground">
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
{row.close_time ? formatDt(row.close_time) : "—"}
|
{row.close_time ? formatDt(row.close_time) : "—"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-center">
|
||||||
<Link
|
<Link
|
||||||
href={`/admin/draws/${row.id}/risk/occupancy`}
|
href={`/admin/draws/${row.id}/risk/occupancy`}
|
||||||
className={cn(buttonVariants({ variant: "secondary", size: "sm" }))}
|
className={cn(buttonVariants({ variant: "secondary", size: "sm" }))}
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
|
|||||||
<TableHead>{t("time")}</TableHead>
|
<TableHead>{t("time")}</TableHead>
|
||||||
<TableHead>{t("searchNumber")}</TableHead>
|
<TableHead>{t("searchNumber")}</TableHead>
|
||||||
<TableHead>{t("action")}</TableHead>
|
<TableHead>{t("action")}</TableHead>
|
||||||
<TableHead className="text-right">{t("amount")}</TableHead>
|
<TableHead className="text-center">{t("amount")}</TableHead>
|
||||||
<TableHead>{t("source")}</TableHead>
|
<TableHead>{t("source")}</TableHead>
|
||||||
<TableHead>{t("ticketNo")}</TableHead>
|
<TableHead>{t("ticketNo")}</TableHead>
|
||||||
<TableHead>{t("playCode")}</TableHead>
|
<TableHead>{t("playCode")}</TableHead>
|
||||||
@@ -184,7 +184,7 @@ export function RiskLockLogsConsole({ drawId }: { drawId: number }) {
|
|||||||
<TableCell className="text-sm">
|
<TableCell className="text-sm">
|
||||||
{riskActionTypeLabel(row.action_type, t)}
|
{riskActionTypeLabel(row.action_type, t)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right text-sm tabular-nums">
|
<TableCell className="text-center text-sm tabular-nums">
|
||||||
{formatAdminMinorUnits(row.amount, data?.currency_code ?? "NPR")}
|
{formatAdminMinorUnits(row.amount, data?.currency_code ?? "NPR")}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-xs text-muted-foreground">
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ export function RiskPoolDetailConsole({
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>{t("time")}</TableHead>
|
<TableHead>{t("time")}</TableHead>
|
||||||
<TableHead>{t("action")}</TableHead>
|
<TableHead>{t("action")}</TableHead>
|
||||||
<TableHead className="text-right">{t("amount")}</TableHead>
|
<TableHead className="text-center">{t("amount")}</TableHead>
|
||||||
<TableHead>{t("source")}</TableHead>
|
<TableHead>{t("source")}</TableHead>
|
||||||
<TableHead>{t("ticketNo")}</TableHead>
|
<TableHead>{t("ticketNo")}</TableHead>
|
||||||
<TableHead>{t("playCode")}</TableHead>
|
<TableHead>{t("playCode")}</TableHead>
|
||||||
@@ -176,7 +176,7 @@ export function RiskPoolDetailConsole({
|
|||||||
{row.created_at ? formatDt(row.created_at) : "—"}
|
{row.created_at ? formatDt(row.created_at) : "—"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm">{riskActionTypeLabel(row.action_type, t)}</TableCell>
|
<TableCell className="text-sm">{riskActionTypeLabel(row.action_type, t)}</TableCell>
|
||||||
<TableCell className="text-right text-sm tabular-nums">
|
<TableCell className="text-center text-sm tabular-nums">
|
||||||
{formatAdminMinorUnits(row.amount, currencyCode)}
|
{formatAdminMinorUnits(row.amount, currencyCode)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-xs text-muted-foreground">
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
|||||||
@@ -249,12 +249,12 @@ export function RiskPoolsConsole({
|
|||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>{t("searchNumber")}</TableHead>
|
<TableHead>{t("searchNumber")}</TableHead>
|
||||||
<TableHead className="text-right">{t("capAmount")}</TableHead>
|
<TableHead className="text-center">{t("capAmount")}</TableHead>
|
||||||
<TableHead className="text-right">{t("lockedAmount")}</TableHead>
|
<TableHead className="text-center">{t("lockedAmount")}</TableHead>
|
||||||
<TableHead className="text-right">{t("remainingAmount")}</TableHead>
|
<TableHead className="text-center">{t("remainingAmount")}</TableHead>
|
||||||
<TableHead className="text-right">{t("usageRatio")}</TableHead>
|
<TableHead className="text-center">{t("usageRatio")}</TableHead>
|
||||||
<TableHead>{t("poolStatus")}</TableHead>
|
<TableHead>{t("poolStatus")}</TableHead>
|
||||||
<TableHead className="text-right">{t("actions")}</TableHead>
|
<TableHead className="text-center">{t("actions")}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -275,16 +275,16 @@ export function RiskPoolsConsole({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<TableCell className="font-mono font-medium">{row.normalized_number}</TableCell>
|
<TableCell className="font-mono font-medium">{row.normalized_number}</TableCell>
|
||||||
<TableCell className="text-right text-sm tabular-nums">
|
<TableCell className="text-center text-sm tabular-nums">
|
||||||
{formatAdminMinorUnits(row.total_cap_amount, currencyCode)}
|
{formatAdminMinorUnits(row.total_cap_amount, currencyCode)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right text-sm tabular-nums">
|
<TableCell className="text-center text-sm tabular-nums">
|
||||||
{formatAdminMinorUnits(row.locked_amount, currencyCode)}
|
{formatAdminMinorUnits(row.locked_amount, currencyCode)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right text-sm tabular-nums">
|
<TableCell className="text-center text-sm tabular-nums">
|
||||||
{formatAdminMinorUnits(row.remaining_amount, currencyCode)}
|
{formatAdminMinorUnits(row.remaining_amount, currencyCode)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right text-sm tabular-nums">
|
<TableCell className="text-center text-sm tabular-nums">
|
||||||
{row.usage_ratio != null ? `${(row.usage_ratio * 100).toFixed(2)}%` : "—"}
|
{row.usage_ratio != null ? `${(row.usage_ratio * 100).toFixed(2)}%` : "—"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
@@ -301,8 +301,8 @@ export function RiskPoolsConsole({
|
|||||||
{row.is_sold_out ? t("soldOut") : highRisk ? t("warning") : t("normal")}
|
{row.is_sold_out ? t("soldOut") : highRisk ? t("warning") : t("normal")}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-center">
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-center gap-2">
|
||||||
{canManageRiskPools ? (
|
{canManageRiskPools ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
|
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
|
||||||
@@ -14,6 +14,7 @@ import { RulesPageShell } from "@/modules/rules/rules-page-shell";
|
|||||||
/** 赔率与回水:共用赔率版本线,单页上下分区。 */
|
/** 赔率与回水:共用赔率版本线,单页上下分区。 */
|
||||||
export function RulesOddsConfigScreen() {
|
export function RulesOddsConfigScreen() {
|
||||||
const { t } = useTranslation("config");
|
const { t } = useTranslation("config");
|
||||||
|
const [sharedVersionId, setSharedVersionId] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const scrollToRebate = () => {
|
const scrollToRebate = () => {
|
||||||
@@ -30,16 +31,20 @@ export function RulesOddsConfigScreen() {
|
|||||||
return (
|
return (
|
||||||
<RulesPageShell>
|
<RulesPageShell>
|
||||||
<AdminPermissionGate requiredAny={PRD_RULES_ODDS_ACCESS_ANY}>
|
<AdminPermissionGate requiredAny={PRD_RULES_ODDS_ACCESS_ANY}>
|
||||||
<ConfigDocPage
|
<ConfigDocPage title={t("nav.rulesOddsTitle")} contentClassName="space-y-8">
|
||||||
title={t("nav.rulesOddsTitle")}
|
<ConfigSection title={t("nav.items.odds")}>
|
||||||
description={t("nav.rulesOddsDescription")}
|
<OddsConfigDocScreen
|
||||||
contentClassName="space-y-10"
|
embedded
|
||||||
>
|
versionId={sharedVersionId}
|
||||||
<ConfigSection title={t("nav.items.odds")} description={t("odds.sectionHint")}>
|
onVersionIdChange={setSharedVersionId}
|
||||||
<OddsConfigDocScreen embedded />
|
/>
|
||||||
</ConfigSection>
|
</ConfigSection>
|
||||||
<ConfigSection id="rebate" title={t("nav.items.rebate")} description={t("rebate.sectionHint")}>
|
<ConfigSection id="rebate" title={t("nav.items.rebate")}>
|
||||||
<RebateConfigDocScreen embedded />
|
<RebateConfigDocScreen
|
||||||
|
embedded
|
||||||
|
versionId={sharedVersionId}
|
||||||
|
onVersionIdChange={setSharedVersionId}
|
||||||
|
/>
|
||||||
</ConfigSection>
|
</ConfigSection>
|
||||||
</ConfigDocPage>
|
</ConfigDocPage>
|
||||||
</AdminPermissionGate>
|
</AdminPermissionGate>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
putAdminCurrency,
|
putAdminCurrency,
|
||||||
} from "@/api/admin-currencies";
|
} from "@/api/admin-currencies";
|
||||||
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||||
|
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
@@ -250,22 +251,20 @@ export function CurrencySettingsPanel() {
|
|||||||
<TableCell>{row.name}</TableCell>
|
<TableCell>{row.name}</TableCell>
|
||||||
<TableCell>{row.decimal_places}</TableCell>
|
<TableCell>{row.decimal_places}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex justify-center">
|
<AdminStatusBadge status={row.is_enabled ? "enabled" : "disabled"}>
|
||||||
<Switch
|
{row.is_enabled
|
||||||
checked={row.is_enabled}
|
? t("system.states.enabled", { ns: "config" })
|
||||||
disabled
|
: t("system.states.disabled", { ns: "config" })}
|
||||||
aria-label={t("currencies.form.enabled", { ns: "config", code: row.code })}
|
</AdminStatusBadge>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex justify-center">
|
<AdminStatusBadge
|
||||||
<Switch
|
status={row.is_enabled && row.is_bettable ? "enabled" : "disabled"}
|
||||||
checked={row.is_enabled && row.is_bettable}
|
>
|
||||||
disabled
|
{row.is_enabled && row.is_bettable
|
||||||
aria-label={t("currencies.form.bettable", { ns: "config", code: row.code })}
|
? t("system.states.enabled", { ns: "config" })
|
||||||
/>
|
: t("system.states.disabled", { ns: "config" })}
|
||||||
</div>
|
</AdminStatusBadge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
<div className="flex items-center justify-center gap-2">
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
|||||||
@@ -338,8 +338,8 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
|
|||||||
<TableHead>{t("playCode")}</TableHead>
|
<TableHead>{t("playCode")}</TableHead>
|
||||||
<TableHead>{t("player")}</TableHead>
|
<TableHead>{t("player")}</TableHead>
|
||||||
<TableHead>{t("matchedTier")}</TableHead>
|
<TableHead>{t("matchedTier")}</TableHead>
|
||||||
<TableHead className="text-right">{t("regularPayout")}</TableHead>
|
<TableHead className="text-center">{t("regularPayout")}</TableHead>
|
||||||
<TableHead className="text-right">{t("jackpot")}</TableHead>
|
<TableHead className="text-center">{t("jackpot")}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -351,10 +351,10 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
|
|||||||
{r.player_username ?? r.site_player_id ?? r.player_id ?? "—"}
|
{r.player_username ?? r.site_player_id ?? r.player_id ?? "—"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-xs">{r.matched_prize_tier ?? "—"}</TableCell>
|
<TableCell className="text-xs">{r.matched_prize_tier ?? "—"}</TableCell>
|
||||||
<TableCell className="text-right font-mono text-xs tabular-nums">
|
<TableCell className="text-center font-mono text-xs tabular-nums">
|
||||||
{formatAdminMinorUnits(r.win_amount, r.currency_code ?? batchCurrency)}
|
{formatAdminMinorUnits(r.win_amount, r.currency_code ?? batchCurrency)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right font-mono text-xs tabular-nums">
|
<TableCell className="text-center font-mono text-xs tabular-nums">
|
||||||
{formatAdminMinorUnits(
|
{formatAdminMinorUnits(
|
||||||
r.jackpot_allocation_amount,
|
r.jackpot_allocation_amount,
|
||||||
r.currency_code ?? batchCurrency,
|
r.currency_code ?? batchCurrency,
|
||||||
|
|||||||
@@ -242,10 +242,10 @@ export function SettlementBatchesConsole() {
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>{t("table.id", { ns: "common" })}</TableHead>
|
<TableHead>{t("table.id", { ns: "common" })}</TableHead>
|
||||||
<TableHead>{t("drawNo")}</TableHead>
|
<TableHead>{t("drawNo")}</TableHead>
|
||||||
<TableHead className="text-right">{t("totalBet")}</TableHead>
|
<TableHead className="text-center">{t("totalBet")}</TableHead>
|
||||||
<TableHead className="text-right">{t("actualDeduct")}</TableHead>
|
<TableHead className="text-center">{t("actualDeduct")}</TableHead>
|
||||||
<TableHead className="text-right">{t("payoutTotal")}</TableHead>
|
<TableHead className="text-center">{t("payoutTotal")}</TableHead>
|
||||||
<TableHead className="text-right">{t("platformProfit")}</TableHead>
|
<TableHead className="text-center">{t("platformProfit")}</TableHead>
|
||||||
<TableHead>{t("reviewStatus")}</TableHead>
|
<TableHead>{t("reviewStatus")}</TableHead>
|
||||||
<TableHead>{t("status")}</TableHead>
|
<TableHead>{t("status")}</TableHead>
|
||||||
<TableHead />
|
<TableHead />
|
||||||
@@ -256,18 +256,18 @@ export function SettlementBatchesConsole() {
|
|||||||
<TableRow key={row.id}>
|
<TableRow key={row.id}>
|
||||||
<TableCell className="font-mono text-xs">{row.id}</TableCell>
|
<TableCell className="font-mono text-xs">{row.id}</TableCell>
|
||||||
<TableCell className="font-mono text-sm">{row.draw_no ?? "—"}</TableCell>
|
<TableCell className="font-mono text-sm">{row.draw_no ?? "—"}</TableCell>
|
||||||
<TableCell className="text-right font-mono text-xs tabular-nums">
|
<TableCell className="text-center font-mono text-xs tabular-nums">
|
||||||
{formatAdminMinorUnits(row.total_bet_amount, row.currency_code ?? "NPR")}
|
{formatAdminMinorUnits(row.total_bet_amount, row.currency_code ?? "NPR")}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right font-mono text-xs tabular-nums">
|
<TableCell className="text-center font-mono text-xs tabular-nums">
|
||||||
{formatAdminMinorUnits(row.total_actual_deduct, row.currency_code ?? "NPR")}
|
{formatAdminMinorUnits(row.total_actual_deduct, row.currency_code ?? "NPR")}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right font-mono text-xs tabular-nums">
|
<TableCell className="text-center font-mono text-xs tabular-nums">
|
||||||
{formatAdminMinorUnits(row.total_payout_amount, row.currency_code ?? "NPR")}
|
{formatAdminMinorUnits(row.total_payout_amount, row.currency_code ?? "NPR")}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell
|
<TableCell
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-right font-mono text-xs tabular-nums",
|
"text-center font-mono text-xs tabular-nums",
|
||||||
row.platform_profit < 0 ? "text-destructive" : "text-emerald-700",
|
row.platform_profit < 0 ? "text-destructive" : "text-emerald-700",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -288,7 +288,7 @@ export function SettlementBatchesConsole() {
|
|||||||
</AdminStatusBadge>
|
</AdminStatusBadge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex flex-wrap justify-end gap-1.5">
|
<div className="flex flex-wrap justify-center gap-1.5">
|
||||||
<Link
|
<Link
|
||||||
href={`/admin/settlement-batches/${row.id}/details`}
|
href={`/admin/settlement-batches/${row.id}/details`}
|
||||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "!border-border")}
|
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "!border-border")}
|
||||||
|
|||||||
@@ -308,11 +308,11 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
|||||||
<TableHead>{t("drawNo")}</TableHead>
|
<TableHead>{t("drawNo")}</TableHead>
|
||||||
<TableHead>{t("playCode")}</TableHead>
|
<TableHead>{t("playCode")}</TableHead>
|
||||||
<TableHead>{t("number")}</TableHead>
|
<TableHead>{t("number")}</TableHead>
|
||||||
<TableHead className="text-right">{t("betAmount")}</TableHead>
|
<TableHead className="text-center">{t("betAmount")}</TableHead>
|
||||||
<TableHead className="text-right">{t("actualDeduct")}</TableHead>
|
<TableHead className="text-center">{t("actualDeduct")}</TableHead>
|
||||||
<TableHead>{t("status")}</TableHead>
|
<TableHead>{t("status")}</TableHead>
|
||||||
<TableHead>{t("failReason")}</TableHead>
|
<TableHead>{t("failReason")}</TableHead>
|
||||||
<TableHead className="text-right">{t("winAmount")}</TableHead>
|
<TableHead className="text-center">{t("winAmount")}</TableHead>
|
||||||
<TableHead>{t("placedAt")}</TableHead>
|
<TableHead>{t("placedAt")}</TableHead>
|
||||||
<TableHead>{t("updatedAt")}</TableHead>
|
<TableHead>{t("updatedAt")}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -349,10 +349,10 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
|||||||
<TableCell className="font-mono text-xs">{row.draw_no ?? "—"}</TableCell>
|
<TableCell className="font-mono text-xs">{row.draw_no ?? "—"}</TableCell>
|
||||||
<TableCell className="text-xs">{playCodeLabel(row.play_code)}</TableCell>
|
<TableCell className="text-xs">{playCodeLabel(row.play_code)}</TableCell>
|
||||||
<TableCell className="font-mono text-xs">{row.original_number ?? "—"}</TableCell>
|
<TableCell className="font-mono text-xs">{row.original_number ?? "—"}</TableCell>
|
||||||
<TableCell className="text-right tabular-nums text-xs">
|
<TableCell className="text-center tabular-nums text-xs">
|
||||||
{row.total_bet_amount_formatted}
|
{row.total_bet_amount_formatted}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right tabular-nums text-xs">
|
<TableCell className="text-center tabular-nums text-xs">
|
||||||
{row.actual_deduct_amount_formatted}
|
{row.actual_deduct_amount_formatted}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-xs">
|
<TableCell className="text-xs">
|
||||||
@@ -363,7 +363,7 @@ export function PlayerTicketsConsole(): React.ReactElement {
|
|||||||
<TableCell className="max-w-[14rem] text-xs text-muted-foreground">
|
<TableCell className="max-w-[14rem] text-xs text-muted-foreground">
|
||||||
{row.fail_reason_text ?? row.fail_reason_code ?? "—"}
|
{row.fail_reason_text ?? row.fail_reason_code ?? "—"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right tabular-nums text-xs">{winLabel}</TableCell>
|
<TableCell className="text-center tabular-nums text-xs">{winLabel}</TableCell>
|
||||||
<TableCell className="text-xs">{formatTs(row.placed_at)}</TableCell>
|
<TableCell className="text-xs">{formatTs(row.placed_at)}</TableCell>
|
||||||
<TableCell className="text-xs">{formatTs(row.updated_at)}</TableCell>
|
<TableCell className="text-xs">{formatTs(row.updated_at)}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
Reference in New Issue
Block a user