refactor: 优化配置与奖池页面多语言编辑及管理端列表布局

This commit is contained in:
2026-05-22 16:55:34 +08:00
parent 2d4a23968e
commit 7d01e5c47e
12 changed files with 901 additions and 599 deletions

View File

@@ -147,7 +147,7 @@
} }
.admin-list-toolbar { .admin-list-toolbar {
@apply flex w-full flex-col gap-3 border-t border-border/60 pt-4 xl:flex-row xl:flex-wrap xl:items-center; @apply flex w-full flex-row flex-wrap items-center gap-3 border-t border-border/60 pt-4;
} }
.admin-list-field { .admin-list-field {
@@ -159,7 +159,7 @@
} }
.admin-list-actions { .admin-list-actions {
@apply flex shrink-0 flex-wrap items-center gap-2 xl:ml-auto xl:justify-end; @apply ml-auto flex shrink-0 flex-wrap items-center justify-end gap-2;
} }
.admin-table-shell { .admin-table-shell {

View File

@@ -94,10 +94,13 @@
"loadFailed": "Failed to load system settings", "loadFailed": "Failed to load system settings",
"saveSuccess": "System settings saved", "saveSuccess": "System settings saved",
"saveFailed": "Failed to save system settings", "saveFailed": "Failed to save system settings",
"frontendConfig": "Front-end configuration",
"fields": { "fields": {
"manualReview": "Require manual review for draw results", "manualReview": "Require manual review for draw results",
"cooldownMinutes": "Cooldown duration (minutes)", "cooldownMinutes": "Cooldown duration (minutes)",
"autoSettlement": "Run settlement automatically" "autoSettlement": "Run settlement automatically",
"playRulesHtml": "Play rules HTML (i18n)",
"playRulesHtmlDesc": "Rendered on the player play-rules page per locale. Leave empty to fall back to another language or the default empty state."
}, },
"hints": { "hints": {
"manualReview": "When enabled, RNG draw results enter pending review and must be published manually in admin.", "manualReview": "When enabled, RNG draw results enter pending review and must be published manually in admin.",
@@ -169,7 +172,8 @@
"jackpot": "Jackpot" "jackpot": "Jackpot"
}, },
"validation": { "validation": {
"minMaxInvalid": "{{playCode}}: min bet cannot exceed max bet" "minMaxInvalid": "{{playCode}}: min bet cannot exceed max bet",
"nameZhRequired": "Chinese display name is required"
}, },
"publishFailed": "Publish failed", "publishFailed": "Publish failed",
"createDraftSuccess": "Created draft v{{version}}", "createDraftSuccess": "Created draft v{{version}}",
@@ -186,7 +190,13 @@
"actions": { "actions": {
"enable": "Enable", "enable": "Enable",
"disable": "Disable", "disable": "Disable",
"ruleText": "Rule text" "ruleText": "Rule text",
"displayNames": "Display names"
},
"locales": {
"zh": "Chinese",
"en": "English",
"ne": "Nepali"
}, },
"table": { "table": {
"playCode": "Play code", "playCode": "Play code",
@@ -206,10 +216,15 @@
"aria": { "aria": {
"enablePlay": "Enable {{playCode}}" "enablePlay": "Enable {{playCode}}"
}, },
"nameDialog": {
"title": "Display names (i18n)",
"description": "Play {{playCode}}. Chinese is required; English and Nepali are optional. The player site picks the label by locale after publish.",
"apply": "Apply to draft",
"savedLocal": "Display names were saved into the local draft. Save the draft to persist them."
},
"ruleDialog": { "ruleDialog": {
"title": "Rule text (Chinese)", "title": "Rule text (i18n)",
"description": "Play {{playCode}}. Changes stay in the draft until you save and publish it.", "description": "Play {{playCode}}. Changes stay in the draft until you save and publish it.",
"fieldLabel": "rule_text_zh",
"apply": "Apply to draft" "apply": "Apply to draft"
} }
}, },

View File

@@ -94,10 +94,13 @@
"loadFailed": "प्रणाली सेटिङ लोड असफल भयो", "loadFailed": "प्रणाली सेटिङ लोड असफल भयो",
"saveSuccess": "प्रणाली सेटिङ सुरक्षित भयो", "saveSuccess": "प्रणाली सेटिङ सुरक्षित भयो",
"saveFailed": "प्रणाली सेटिङ सुरक्षित गर्न असफल", "saveFailed": "प्रणाली सेटिङ सुरक्षित गर्न असफल",
"frontendConfig": "फ्रन्ट-एन्ड कन्फिग",
"fields": { "fields": {
"manualReview": "ड्रअ परिणामका लागि म्यानुअल समीक्षा चाहिने", "manualReview": "ड्रअ परिणामका लागि म्यानुअल समीक्षा चाहिने",
"cooldownMinutes": "कूलडाउन अवधि (मिनेट)", "cooldownMinutes": "कूलडाउन अवधि (मिनेट)",
"autoSettlement": "सेटलमेन्ट स्वतः चलाउने" "autoSettlement": "सेटलमेन्ट स्वतः चलाउने",
"playRulesHtml": "खेल नियम HTML (बहुभाषी)",
"playRulesHtmlDesc": "खेलाडीको नियम पृष्ठमा भाषा अनुसार HTML देखिन्छ। खाली छोड्दा अर्को भाषा वा पूर्वनिर्धारित खाली सूचना देखिन्छ।"
}, },
"hints": { "hints": {
"manualReview": "सक्रिय हुँदा RNG ड्रअ परिणाम pending review मा जान्छ र एडमिनबाट म्यानुअल रूपमा प्रकाशित गर्नुपर्छ।", "manualReview": "सक्रिय हुँदा RNG ड्रअ परिणाम pending review मा जान्छ र एडमिनबाट म्यानुअल रूपमा प्रकाशित गर्नुपर्छ।",
@@ -169,7 +172,8 @@
"jackpot": "Jackpot" "jackpot": "Jackpot"
}, },
"validation": { "validation": {
"minMaxInvalid": "{{playCode}}: न्यूनतम बेट अधिकतम बेटभन्दा ठूलो हुन सक्दैन" "minMaxInvalid": "{{playCode}}: न्यूनतम बेट अधिकतम बेटभन्दा ठूलो हुन सक्दैन",
"nameZhRequired": "चिनियाँ प्रदर्शित नाम अनिवार्य छ"
}, },
"publishFailed": "प्रकाशन असफल भयो", "publishFailed": "प्रकाशन असफल भयो",
"createDraftSuccess": "ड्राफ्ट v{{version}} सिर्जना भयो", "createDraftSuccess": "ड्राफ्ट v{{version}} सिर्जना भयो",
@@ -186,7 +190,13 @@
"actions": { "actions": {
"enable": "सक्रिय", "enable": "सक्रिय",
"disable": "निष्क्रिय", "disable": "निष्क्रिय",
"ruleText": "नियम पाठ" "ruleText": "नियम पाठ",
"displayNames": "बहुभाषी नाम"
},
"locales": {
"zh": "चिनियाँ",
"en": "English",
"ne": "नेपाली"
}, },
"table": { "table": {
"playCode": "खेल कोड", "playCode": "खेल कोड",
@@ -206,10 +216,15 @@
"aria": { "aria": {
"enablePlay": "{{playCode}} सक्रिय गर्ने" "enablePlay": "{{playCode}} सक्रिय गर्ने"
}, },
"nameDialog": {
"title": "प्रदर्शित नाम (बहुभाषी)",
"description": "खेल {{playCode}}। चिनियाँ अनिवार्य; अंग्रेजी र नेपाली वैकल्पिक। प्रकाशनपछि खेलाडीको भाषा अनुसार देखिन्छ।",
"apply": "ड्राफ्टमा लागू गर्नुहोस्",
"savedLocal": "प्रदर्शित नाम स्थानीय ड्राफ्टमा सुरक्षित भयो। स्थायी बनाउन ड्राफ्ट सेभ गर्नुहोस्।"
},
"ruleDialog": { "ruleDialog": {
"title": "नियम पाठ (Chinese)", "title": "नियम पाठ (बहुभाषी)",
"description": "खेल {{playCode}}। परिवर्तनहरू सेभ र प्रकाशित नगरेसम्म ड्राफ्टमै रहन्छन्।", "description": "खेल {{playCode}}। परिवर्तनहरू सेभ र प्रकाशित नगरेसम्म ड्राफ्टमै रहन्छन्।",
"fieldLabel": "rule_text_zh",
"apply": "ड्राफ्टमा लागू गर्नुहोस्" "apply": "ड्राफ्टमा लागू गर्नुहोस्"
} }
}, },

View File

@@ -94,10 +94,13 @@
"loadFailed": "系统设置加载失败", "loadFailed": "系统设置加载失败",
"saveSuccess": "系统设置已保存", "saveSuccess": "系统设置已保存",
"saveFailed": "系统设置保存失败", "saveFailed": "系统设置保存失败",
"frontendConfig": "前端配置",
"fields": { "fields": {
"manualReview": "开奖结果必须人工审核", "manualReview": "开奖结果必须人工审核",
"cooldownMinutes": "冷静期时长(分钟)", "cooldownMinutes": "冷静期时长(分钟)",
"autoSettlement": "自动执行结算" "autoSettlement": "自动执行结算",
"playRulesHtml": "玩法规则 HTML多语言",
"playRulesHtmlDesc": "该内容将直接在玩家端的玩法规则页面作为 HTML 渲染。按语言分别配置;留空则回退其它语言或显示默认提示。"
}, },
"hints": { "hints": {
"manualReview": "开启后RNG 开奖结果会先进入待审核,必须由后台人工发布。", "manualReview": "开启后RNG 开奖结果会先进入待审核,必须由后台人工发布。",
@@ -169,7 +172,8 @@
"jackpot": "奖池" "jackpot": "奖池"
}, },
"validation": { "validation": {
"minMaxInvalid": "{{playCode}}:最小下注额不能大于最大下注额" "minMaxInvalid": "{{playCode}}:最小下注额不能大于最大下注额",
"nameZhRequired": "中文显示名称不能为空"
}, },
"publishFailed": "发布失败", "publishFailed": "发布失败",
"createDraftSuccess": "已创建草稿 v{{version}}", "createDraftSuccess": "已创建草稿 v{{version}}",
@@ -186,7 +190,13 @@
"actions": { "actions": {
"enable": "开启", "enable": "开启",
"disable": "关闭", "disable": "关闭",
"ruleText": "规则文案" "ruleText": "规则文案",
"displayNames": "多语言名称"
},
"locales": {
"zh": "中文",
"en": "English",
"ne": "नेपाली"
}, },
"table": { "table": {
"playCode": "玩法编码", "playCode": "玩法编码",
@@ -206,10 +216,15 @@
"aria": { "aria": {
"enablePlay": "切换 {{playCode}} 启用状态" "enablePlay": "切换 {{playCode}} 启用状态"
}, },
"nameDialog": {
"title": "显示名称(多语言)",
"description": "玩法 {{playCode}};中文必填,英文与尼泊尔语可选。保存草稿并发布后,前台按玩家语言展示。",
"apply": "应用到草稿",
"savedLocal": "显示名称已写入本地草稿,记得保存草稿后再发布。"
},
"ruleDialog": { "ruleDialog": {
"title": "规则文案(中文", "title": "规则文案(多语言",
"description": "玩法 {{playCode}};修改内容只会暂存到草稿,保存并发布后才会生效。", "description": "玩法 {{playCode}};修改内容只会暂存到草稿,保存并发布后才会生效。",
"fieldLabel": "中文规则文案",
"apply": "应用到草稿" "apply": "应用到草稿"
} }
}, },

View File

@@ -86,11 +86,13 @@ export function AuditLogsConsole(): React.ReactElement {
return ( return (
<Card className="admin-list-card w-full max-w-none"> <Card className="admin-list-card w-full max-w-none">
<CardHeader className="admin-list-header flex flex-col gap-5"> <CardHeader className="admin-list-header">
<CardTitle className="admin-list-title">{t("title")}</CardTitle> <CardTitle className="admin-list-title">{t("title")}</CardTitle>
<div className="grid gap-3 lg:grid-cols-2 xl:grid-cols-5"> </CardHeader>
<div className="flex min-w-0 items-center gap-2"> <CardContent className="admin-list-content">
<Label htmlFor="aud-operator-id" className="shrink-0 whitespace-nowrap"> <div className="admin-list-toolbar">
<div className="admin-list-field">
<Label htmlFor="aud-operator-id" className="sm:shrink-0">
{t("operator")} {t("operator")}
</Label> </Label>
<Input <Input
@@ -98,12 +100,12 @@ export function AuditLogsConsole(): React.ReactElement {
value={operatorId} value={operatorId}
onChange={(e) => setOperatorId(e.target.value)} onChange={(e) => setOperatorId(e.target.value)}
placeholder={t("operatorIdPlaceholder")} placeholder={t("operatorIdPlaceholder")}
className="w-full" className="w-full sm:w-36"
inputMode="numeric" inputMode="numeric"
/> />
</div> </div>
<div className="flex min-w-0 items-center gap-2"> <div className="admin-list-field">
<Label htmlFor="aud-mod" className="shrink-0 whitespace-nowrap"> <Label htmlFor="aud-mod" className="sm:shrink-0">
{t("moduleCode")} {t("moduleCode")}
</Label> </Label>
<Input <Input
@@ -111,11 +113,11 @@ export function AuditLogsConsole(): React.ReactElement {
value={moduleCode} value={moduleCode}
onChange={(e) => setModuleCode(e.target.value)} onChange={(e) => setModuleCode(e.target.value)}
placeholder={t("exactMatch")} placeholder={t("exactMatch")}
className="w-full" className="w-full sm:w-40"
/> />
</div> </div>
<div className="flex min-w-0 items-center gap-2"> <div className="admin-list-field">
<Label htmlFor="aud-act" className="shrink-0 whitespace-nowrap"> <Label htmlFor="aud-act" className="sm:shrink-0">
{t("actionCode")} {t("actionCode")}
</Label> </Label>
<Input <Input
@@ -123,11 +125,11 @@ export function AuditLogsConsole(): React.ReactElement {
value={actionCode} value={actionCode}
onChange={(e) => setActionCode(e.target.value)} onChange={(e) => setActionCode(e.target.value)}
placeholder={t("exactMatch")} placeholder={t("exactMatch")}
className="w-full" className="w-full sm:w-40"
/> />
</div> </div>
<div className="flex min-w-0 items-center gap-2"> <div className="admin-list-field">
<Label htmlFor="aud-op" className="shrink-0 whitespace-nowrap"> <Label htmlFor="aud-op" className="sm:shrink-0">
{t("operatorType")} {t("operatorType")}
</Label> </Label>
<Input <Input
@@ -135,66 +137,68 @@ export function AuditLogsConsole(): React.ReactElement {
value={operatorType} value={operatorType}
onChange={(e) => setOperatorType(e.target.value)} onChange={(e) => setOperatorType(e.target.value)}
placeholder={t("operatorTypePlaceholder")} placeholder={t("operatorTypePlaceholder")}
className="w-full" className="w-full sm:w-40"
/> />
</div> </div>
<div className="min-w-0 xl:col-span-2"> <div className="admin-list-field">
<AdminDateRangeField <Label htmlFor="aud-date-range" className="sm:shrink-0">
id="aud-date-range" {t("time")}
label={t("time")} </Label>
from={startDate} <div className="min-w-0 w-full sm:w-56">
to={endDate} <AdminDateRangeField
onRangeChange={(range) => { id="aud-date-range"
setStartDate(range.from); from={startDate}
setEndDate(range.to); to={endDate}
onRangeChange={(range) => {
setStartDate(range.from);
setEndDate(range.to);
}}
/>
</div>
</div>
<div className="admin-list-actions">
<AdminTableExportButton
tableId="audit-logs-table"
filename={exportLabels.filename}
sheetName={exportLabels.sheetName}
/>
<Button
type="button"
onClick={() => {
setAppliedOperatorId(operatorId);
setAppliedModule(moduleCode);
setAppliedAction(actionCode);
setAppliedOpType(operatorType);
setAppliedStartDate(startDate);
setAppliedEndDate(endDate);
setPage(1);
}} }}
/> >
{t("actions.search", { ns: "common" })}
</Button>
<Button
type="button"
variant="secondary"
onClick={() => {
setOperatorId("");
setModuleCode("");
setActionCode("");
setOperatorType("");
setStartDate("");
setEndDate("");
setAppliedOperatorId("");
setAppliedModule("");
setAppliedAction("");
setAppliedOpType("");
setAppliedStartDate("");
setAppliedEndDate("");
setPage(1);
}}
>
{t("actions.reset", { ns: "common" })}
</Button>
</div> </div>
</div> </div>
<div className="flex flex-wrap justify-end gap-2">
<AdminTableExportButton
tableId="audit-logs-table"
filename={exportLabels.filename}
sheetName={exportLabels.sheetName}
/>
<Button
type="button"
onClick={() => {
setAppliedOperatorId(operatorId);
setAppliedModule(moduleCode);
setAppliedAction(actionCode);
setAppliedOpType(operatorType);
setAppliedStartDate(startDate);
setAppliedEndDate(endDate);
setPage(1);
}}
>
{t("actions.search", { ns: "common" })}
</Button>
<Button
type="button"
variant="secondary"
onClick={() => {
setOperatorId("");
setModuleCode("");
setActionCode("");
setOperatorType("");
setStartDate("");
setEndDate("");
setAppliedOperatorId("");
setAppliedModule("");
setAppliedAction("");
setAppliedOpType("");
setAppliedStartDate("");
setAppliedEndDate("");
setPage(1);
}}
>
{t("actions.reset", { ns: "common" })}
</Button>
</div>
</CardHeader>
<CardContent className="admin-list-content">
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null} {err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
{loading && !data ? ( {loading && !data ? (
<p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p> <p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p>
@@ -202,7 +206,7 @@ export function AuditLogsConsole(): React.ReactElement {
{data ? ( {data ? (
<> <>
<div className="rounded-md border"> <div className="admin-table-shell">
<Table id="audit-logs-table"> <Table id="audit-logs-table">
<TableHeader> <TableHeader>
<TableRow> <TableRow>

View File

@@ -37,6 +37,7 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge"; import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value"; import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
import { ConfigVersionActions } from "@/modules/config/config-version-actions"; import { ConfigVersionActions } from "@/modules/config/config-version-actions";
@@ -146,9 +147,16 @@ export function PlayConfigDocScreen() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const detailRequestSeq = useRef(0); const detailRequestSeq = useRef(0);
const [nameDialogOpen, setNameDialogOpen] = useState(false);
const [namePlayCode, setNamePlayCode] = useState<string | null>(null);
const [nameDraftZh, setNameDraftZh] = useState("");
const [nameDraftEn, setNameDraftEn] = useState("");
const [nameDraftNe, setNameDraftNe] = useState("");
const [ruleDialogOpen, setRuleDialogOpen] = useState(false); const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
const [rulePlayCode, setRulePlayCode] = useState<string | null>(null); const [rulePlayCode, setRulePlayCode] = useState<string | null>(null);
const [ruleDraftZh, setRuleDraftZh] = useState(""); const [ruleDraftZh, setRuleDraftZh] = useState("");
const [ruleDraftEn, setRuleDraftEn] = useState("");
const [ruleDraftNe, setRuleDraftNe] = useState("");
const refreshList = useCallback(async () => { const refreshList = useCallback(async () => {
setLoadingList(true); setLoadingList(true);
@@ -348,23 +356,80 @@ export function PlayConfigDocScreen() {
} }
} }
function openNameEditor(play_code: string) {
const item = draftRows.find((row) => row.play_code === play_code);
setNamePlayCode(play_code);
setNameDraftZh(item?.display_name_zh ?? item?.play_code ?? "");
setNameDraftEn(item?.display_name_en ?? "");
setNameDraftNe(item?.display_name_ne ?? "");
setNameDialogOpen(true);
}
function saveNameDraft() {
if (!namePlayCode) {
return;
}
const zh = nameDraftZh.trim();
if (!zh) {
toast.error(t("play.validation.nameZhRequired", { ns: "config" }));
return;
}
updateConfigRow(namePlayCode, {
display_name_zh: zh,
display_name_en: nameDraftEn.trim() || null,
display_name_ne: nameDraftNe.trim() || null,
});
setNameDialogOpen(false);
setNamePlayCode(null);
toast.message(t("play.nameDialog.savedLocal", { ns: "config" }));
}
function openRuleEditor(play_code: string) { function openRuleEditor(play_code: string) {
const item = draftRows.find((row) => row.play_code === play_code); const item = draftRows.find((row) => row.play_code === play_code);
setRulePlayCode(play_code); setRulePlayCode(play_code);
setRuleDraftZh(item?.rule_text_zh ?? ""); setRuleDraftZh(item?.rule_text_zh ?? "");
setRuleDraftEn(item?.rule_text_en ?? "");
setRuleDraftNe(item?.rule_text_ne ?? "");
setRuleDialogOpen(true); setRuleDialogOpen(true);
} }
function saveRuleZh() { function saveRuleDraft() {
if (!rulePlayCode) { if (!rulePlayCode) {
return; return;
} }
updateConfigRow(rulePlayCode, { rule_text_zh: ruleDraftZh.trim() || null }); updateConfigRow(rulePlayCode, {
rule_text_zh: ruleDraftZh.trim() || null,
rule_text_en: ruleDraftEn.trim() || null,
rule_text_ne: ruleDraftNe.trim() || null,
});
setRuleDialogOpen(false); setRuleDialogOpen(false);
setRulePlayCode(null); setRulePlayCode(null);
toast.message(t("play.ruleSavedLocal", { ns: "config" })); toast.message(t("play.ruleSavedLocal", { ns: "config" }));
} }
function renderDisplayNameReadonly(row: PlayConfigItemRow) {
const lines = [
{ label: t("play.locales.zh", { ns: "config" }), value: row.display_name_zh },
{ label: t("play.locales.en", { ns: "config" }), value: row.display_name_en },
{ label: t("play.locales.ne", { ns: "config" }), value: row.display_name_ne },
].filter((line) => line.value?.trim());
if (lines.length === 0) {
return <span></span>;
}
return (
<div className="space-y-0.5 text-center text-sm">
{lines.map((line) => (
<p key={line.label}>
<span className="text-muted-foreground text-xs">{line.label}: </span>
{line.value}
</p>
))}
</div>
);
}
const activeHead = list.find((x) => x.status === "active"); const activeHead = list.find((x) => x.status === "active");
async function handleDeleteVersion(row: ConfigVersionSummary) { async function handleDeleteVersion(row: ConfigVersionSummary) {
@@ -511,18 +576,24 @@ export function PlayConfigDocScreen() {
</TableCell> </TableCell>
<TableCell> <TableCell>
{isDraft ? ( {isDraft ? (
<Input <div className="flex flex-col items-center gap-1.5">
className="h-8 text-center text-sm" <p className="max-w-[10rem] truncate text-sm font-medium">
value={row.display_name_zh ?? ""} {row.display_name_zh ?? row.play_code}
disabled={saving} </p>
onChange={(e) => { <Button
const next = e.target.value === "" ? null : e.target.value; type="button"
updateConfigRow(row.play_code, { display_name_zh: next }); variant="outline"
}} size="sm"
/> className="h-7 text-xs"
disabled={saving}
onClick={() => openNameEditor(row.play_code)}
>
{t("play.actions.displayNames", { ns: "config" })}
</Button>
</div>
) : ( ) : (
<ConfigReadonlyValue className="justify-center"> <ConfigReadonlyValue className="justify-center">
{row.display_name_zh ?? "—"} {renderDisplayNameReadonly(row)}
</ConfigReadonlyValue> </ConfigReadonlyValue>
)} )}
</TableCell> </TableCell>
@@ -609,6 +680,51 @@ export function PlayConfigDocScreen() {
</Table> </Table>
)} )}
<Dialog open={nameDialogOpen} onOpenChange={setNameDialogOpen}>
<DialogContent showCloseButton className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{t("play.nameDialog.title", { ns: "config" })}</DialogTitle>
<DialogDescription>
{t("play.nameDialog.description", { ns: "config", playCode: namePlayCode ?? "—" })}
</DialogDescription>
</DialogHeader>
<div className="grid gap-3">
<div className="grid gap-1.5">
<Label htmlFor="name-zh">{t("play.locales.zh", { ns: "config" })}</Label>
<Input
id="name-zh"
value={nameDraftZh}
onChange={(e) => setNameDraftZh(e.target.value)}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="name-en">{t("play.locales.en", { ns: "config" })}</Label>
<Input
id="name-en"
value={nameDraftEn}
onChange={(e) => setNameDraftEn(e.target.value)}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="name-ne">{t("play.locales.ne", { ns: "config" })}</Label>
<Input
id="name-ne"
value={nameDraftNe}
onChange={(e) => setNameDraftNe(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setNameDialogOpen(false)}>
{t("actions.cancel", { ns: "adminUsers" })}
</Button>
<Button type="button" onClick={saveNameDraft}>
{t("play.nameDialog.apply", { ns: "config" })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={ruleDialogOpen} onOpenChange={setRuleDialogOpen}> <Dialog open={ruleDialogOpen} onOpenChange={setRuleDialogOpen}>
<DialogContent showCloseButton className="sm:max-w-lg"> <DialogContent showCloseButton className="sm:max-w-lg">
<DialogHeader> <DialogHeader>
@@ -617,20 +733,42 @@ export function PlayConfigDocScreen() {
{t("play.ruleDialog.description", { ns: "config", playCode: rulePlayCode ?? "—" })} {t("play.ruleDialog.description", { ns: "config", playCode: rulePlayCode ?? "—" })}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-2"> <Tabs defaultValue="zh" className="w-full">
<Label htmlFor="rule-zh">{t("play.ruleDialog.fieldLabel", { ns: "config" })}</Label> <TabsList className="w-full">
<textarea <TabsTrigger value="zh">{t("play.locales.zh", { ns: "config" })}</TabsTrigger>
id="rule-zh" <TabsTrigger value="en">{t("play.locales.en", { ns: "config" })}</TabsTrigger>
className="border-input bg-background ring-ring/24 focus-visible:ring-[3px] min-h-[140px] w-full rounded-lg border px-3 py-2 text-sm outline-none" <TabsTrigger value="ne">{t("play.locales.ne", { ns: "config" })}</TabsTrigger>
value={ruleDraftZh} </TabsList>
onChange={(e) => setRuleDraftZh(e.target.value)} <TabsContent value="zh" className="mt-3">
/> <textarea
</div> id="rule-zh"
className="border-input bg-background ring-ring/24 focus-visible:ring-[3px] min-h-[140px] w-full rounded-lg border px-3 py-2 text-sm outline-none"
value={ruleDraftZh}
onChange={(e) => setRuleDraftZh(e.target.value)}
/>
</TabsContent>
<TabsContent value="en" className="mt-3">
<textarea
id="rule-en"
className="border-input bg-background ring-ring/24 focus-visible:ring-[3px] min-h-[140px] w-full rounded-lg border px-3 py-2 text-sm outline-none"
value={ruleDraftEn}
onChange={(e) => setRuleDraftEn(e.target.value)}
/>
</TabsContent>
<TabsContent value="ne" className="mt-3">
<textarea
id="rule-ne"
className="border-input bg-background ring-ring/24 focus-visible:ring-[3px] min-h-[140px] w-full rounded-lg border px-3 py-2 text-sm outline-none"
value={ruleDraftNe}
onChange={(e) => setRuleDraftNe(e.target.value)}
/>
</TabsContent>
</Tabs>
<DialogFooter> <DialogFooter>
<Button type="button" variant="outline" onClick={() => setRuleDialogOpen(false)}> <Button type="button" variant="outline" onClick={() => setRuleDialogOpen(false)}>
{t("actions.cancel", { ns: "adminUsers" })} {t("actions.cancel", { ns: "adminUsers" })}
</Button> </Button>
<Button type="button" onClick={saveRuleZh}> <Button type="button" onClick={saveRuleDraft}>
{t("play.ruleDialog.apply", { ns: "config" })} {t("play.ruleDialog.apply", { ns: "config" })}
</Button> </Button>
</DialogFooter> </DialogFooter>

View File

@@ -3,14 +3,10 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ConfigDocPage } from "@/modules/config/config-doc-page";
import { ConfigSection } from "@/modules/config/config-section";
import { JackpotPoolsConsole } from "@/modules/jackpot/jackpot-pools-console"; import { JackpotPoolsConsole } from "@/modules/jackpot/jackpot-pools-console";
import { JackpotRecordsConsole } from "@/modules/jackpot/jackpot-records-console"; import { JackpotRecordsConsole } from "@/modules/jackpot/jackpot-records-console";
/** /** 奖池单页:池参数 + 流水记录,避免 ConfigDocPage / 内层 Card 重复套娃。 */
* 奖池:仅保留「侧栏 + 运营配置顶栏」两层导航;池参数与流水在同一页用分区展示。
*/
export function JackpotConfigScreen() { export function JackpotConfigScreen() {
const { t } = useTranslation("jackpot"); const { t } = useTranslation("jackpot");
@@ -27,13 +23,20 @@ export function JackpotConfigScreen() {
}, []); }, []);
return ( return (
<ConfigDocPage title={t("configTitle")}> <div className="flex flex-col gap-10">
<ConfigSection title={t("poolsSectionTitle")}> <section className="space-y-4">
<h2 className="border-b border-border/60 pb-3 text-base font-semibold text-foreground">
{t("poolsSectionTitle")}
</h2>
<JackpotPoolsConsole embedded /> <JackpotPoolsConsole embedded />
</ConfigSection> </section>
<ConfigSection id="jackpot-records" title={t("recordsSectionTitle")}>
<section id="jackpot-records" className="scroll-mt-24 space-y-4">
<h2 className="border-b border-border/60 pb-3 text-base font-semibold text-foreground">
{t("recordsSectionTitle")}
</h2>
<JackpotRecordsConsole embedded /> <JackpotRecordsConsole embedded />
</ConfigSection> </section>
</ConfigDocPage> </div>
); );
} }

View File

@@ -150,149 +150,158 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
} }
}; };
const body = ( const poolList = (
<Card className={embedded ? "border-border/60 shadow-none" : undefined}> <div className={embedded ? "space-y-4" : "space-y-8"}>
{!embedded ? ( {loading ? <p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p> : null}
<CardHeader> {!loading && items.length === 0 ? (
<CardTitle className="text-base">{t("configTitle")}</CardTitle> <p className="text-muted-foreground text-sm">{t("noPoolData")}</p>
</CardHeader>
) : null} ) : null}
<CardContent className="space-y-8"> {items.map((p) => {
{loading ? <p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p> : null} const d = drafts[p.id] ?? toDraft(p);
{!loading && items.length === 0 ? ( return (
<p className="text-muted-foreground text-sm">{t("noPoolData")}</p> <div
) : null} key={p.id}
{items.map((p) => { className="space-y-4 rounded-xl border border-border/60 bg-muted/10 p-4"
const d = drafts[p.id] ?? toDraft(p); >
return ( <h3 className="font-mono text-sm font-semibold">{p.currency_code}</h3>
<div key={p.id} className="space-y-4 rounded-lg border border-border p-4"> <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<h3 className="font-mono text-sm font-semibold">{p.currency_code}</h3> <div className="space-y-1.5">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <Label htmlFor={`amt-${p.id}`}>{t("currentAmount")}</Label>
<div className="space-y-1.5"> <Input
<Label htmlFor={`amt-${p.id}`}>{t("currentAmount")}</Label> id={`amt-${p.id}`}
<Input className="font-mono"
id={`amt-${p.id}`} value={d.current_amount}
className="font-mono" onChange={(e) => updateDraft(p.id, { current_amount: e.target.value })}
value={d.current_amount} />
onChange={(e) => updateDraft(p.id, { current_amount: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`cr-${p.id}`}>{t("contributionRate")}</Label>
<Input
id={`cr-${p.id}`}
className="font-mono"
value={d.contribution_rate}
onChange={(e) => updateDraft(p.id, { contribution_rate: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`th-${p.id}`}>{t("triggerThreshold")}</Label>
<Input
id={`th-${p.id}`}
className="font-mono"
value={d.trigger_threshold}
onChange={(e) => updateDraft(p.id, { trigger_threshold: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`pr-${p.id}`}>{t("payoutRate")}</Label>
<Input
id={`pr-${p.id}`}
className="font-mono"
value={d.payout_rate}
onChange={(e) => updateDraft(p.id, { payout_rate: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`gap-${p.id}`}>{t("forceTriggerGap")}</Label>
<Input
id={`gap-${p.id}`}
className="font-mono"
value={d.force_trigger_draw_gap}
onChange={(e) => updateDraft(p.id, { force_trigger_draw_gap: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`min-${p.id}`}>{t("minBetAmount")}</Label>
<Input
id={`min-${p.id}`}
className="font-mono"
value={d.min_bet_amount}
onChange={(e) => updateDraft(p.id, { min_bet_amount: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`combo-${p.id}`}>{t("comboTriggerPlays")}</Label>
<Input
id={`combo-${p.id}`}
className="font-mono"
value={d.combo_trigger_play_codes}
placeholder="straight,ibox"
onChange={(e) => updateDraft(p.id, { combo_trigger_play_codes: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label>{t("status")}</Label>
<Select
value={d.status}
onValueChange={(v) => updateDraft(p.id, { status: v ?? "0" })}
>
<SelectTrigger>
<SelectValue>{d.status === "1" ? t("enabled") : t("disabled")}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="0">{t("disabled")}</SelectItem>
<SelectItem value="1">{t("enabled")}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex justify-end">
<Button type="button" disabled={savingId === p.id} onClick={() => void save(p)}>
{savingId === p.id ? t("saving") : t("save")}
</Button>
</div>
<div className="rounded-md border border-amber-200 bg-amber-50 p-3">
<div className="grid gap-3 sm:grid-cols-[1fr_1fr_auto] sm:items-end">
<div className="space-y-1.5">
<Label htmlFor={`burst-draw-${p.id}`}>{t("manualBurstDrawId")}</Label>
<Input
id={`burst-draw-${p.id}`}
className="font-mono"
value={d.manual_burst_draw_id}
onChange={(e) => updateDraft(p.id, { manual_burst_draw_id: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`burst-amount-${p.id}`}>{t("manualBurstAmount")}</Label>
<Input
id={`burst-amount-${p.id}`}
className="font-mono"
value={d.manual_burst_amount}
onChange={(e) => updateDraft(p.id, { manual_burst_amount: e.target.value })}
/>
</div>
<Button
type="button"
variant="destructive"
disabled={burstingId === p.id}
onClick={() => void manualBurst(p)}
>
{burstingId === p.id ? t("processing") : t("manualBurst")}
</Button>
</div>
</div>
</div> </div>
); <div className="space-y-1.5">
})} <Label htmlFor={`cr-${p.id}`}>{t("contributionRate")}</Label>
</CardContent> <Input
</Card> id={`cr-${p.id}`}
className="font-mono"
value={d.contribution_rate}
onChange={(e) => updateDraft(p.id, { contribution_rate: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`th-${p.id}`}>{t("triggerThreshold")}</Label>
<Input
id={`th-${p.id}`}
className="font-mono"
value={d.trigger_threshold}
onChange={(e) => updateDraft(p.id, { trigger_threshold: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`pr-${p.id}`}>{t("payoutRate")}</Label>
<Input
id={`pr-${p.id}`}
className="font-mono"
value={d.payout_rate}
onChange={(e) => updateDraft(p.id, { payout_rate: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`gap-${p.id}`}>{t("forceTriggerGap")}</Label>
<Input
id={`gap-${p.id}`}
className="font-mono"
value={d.force_trigger_draw_gap}
onChange={(e) => updateDraft(p.id, { force_trigger_draw_gap: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`min-${p.id}`}>{t("minBetAmount")}</Label>
<Input
id={`min-${p.id}`}
className="font-mono"
value={d.min_bet_amount}
onChange={(e) => updateDraft(p.id, { min_bet_amount: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`combo-${p.id}`}>{t("comboTriggerPlays")}</Label>
<Input
id={`combo-${p.id}`}
className="font-mono"
value={d.combo_trigger_play_codes}
placeholder="straight,ibox"
onChange={(e) => updateDraft(p.id, { combo_trigger_play_codes: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`status-${p.id}`}>{t("status")}</Label>
<Select
value={d.status}
onValueChange={(v) => updateDraft(p.id, { status: v ?? "0" })}
>
<SelectTrigger id={`status-${p.id}`} className="w-full">
<SelectValue>{d.status === "1" ? t("enabled") : t("disabled")}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="0">{t("disabled")}</SelectItem>
<SelectItem value="1">{t("enabled")}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex justify-end border-t border-border/60 pt-3">
<Button type="button" disabled={savingId === p.id} onClick={() => void save(p)}>
{savingId === p.id ? t("saving") : t("save")}
</Button>
</div>
<div className="rounded-lg border border-amber-200/80 bg-amber-50/80 p-4 dark:border-amber-900/50 dark:bg-amber-950/30">
<p className="mb-3 text-xs font-medium text-amber-900 dark:text-amber-200">
{t("manualBurst")}
</p>
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-end">
<div className="min-w-0 flex-1 space-y-1.5 sm:max-w-xs">
<Label htmlFor={`burst-draw-${p.id}`}>{t("manualBurstDrawId")}</Label>
<Input
id={`burst-draw-${p.id}`}
className="font-mono"
value={d.manual_burst_draw_id}
onChange={(e) => updateDraft(p.id, { manual_burst_draw_id: e.target.value })}
/>
</div>
<div className="min-w-0 flex-1 space-y-1.5 sm:max-w-xs">
<Label htmlFor={`burst-amount-${p.id}`}>{t("manualBurstAmount")}</Label>
<Input
id={`burst-amount-${p.id}`}
className="font-mono"
value={d.manual_burst_amount}
onChange={(e) => updateDraft(p.id, { manual_burst_amount: e.target.value })}
/>
</div>
<Button
type="button"
variant="destructive"
className="shrink-0 sm:ml-auto"
disabled={burstingId === p.id}
onClick={() => void manualBurst(p)}
>
{burstingId === p.id ? t("processing") : t("manualBurst")}
</Button>
</div>
</div>
</div>
);
})}
</div>
); );
if (embedded) { if (embedded) {
return body; return poolList;
} }
return <ModuleScaffold>{body}</ModuleScaffold>; return (
<ModuleScaffold>
<Card>
<CardHeader>
<CardTitle className="text-base">{t("configTitle")}</CardTitle>
</CardHeader>
<CardContent>{poolList}</CardContent>
</Card>
</ModuleScaffold>
);
} }

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import type React from "react";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -32,6 +33,51 @@ type JackpotRecordsConsoleProps = {
embedded?: boolean; embedded?: boolean;
}; };
/** 表格在 admin-table-shell 内时去掉 Table 组件自带的第二层边框 */
const TABLE_IN_SHELL_CLASS =
"[&_[data-slot=table-container]]:rounded-none [&_[data-slot=table-container]]:border-0 [&_[data-slot=table-container]]:bg-transparent [&_[data-slot=table-container]]:shadow-none";
function JackpotRecordTableSection({
title,
tableId,
exportFilename,
exportSheetName,
loading,
hasData,
children,
footer,
}: {
title: string;
tableId: string;
exportFilename: string;
exportSheetName: string;
loading: boolean;
hasData: boolean;
children: React.ReactNode;
footer: React.ReactNode;
}) {
const { t } = useTranslation("common");
return (
<div className="admin-table-shell">
<div className="admin-table-toolbar flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
<AdminTableExportButton
tableId={tableId}
filename={exportFilename}
sheetName={exportSheetName}
/>
</div>
{loading && !hasData ? (
<p className="px-4 py-6 text-sm text-muted-foreground">{t("states.loading")}</p>
) : (
<div className={TABLE_IN_SHELL_CLASS}>{children}</div>
)}
{footer ? <div className="px-4 pb-4">{footer}</div> : null}
</div>
);
}
export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsoleProps) { export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsoleProps) {
const { t } = useTranslation(["jackpot", "common"]); const { t } = useTranslation(["jackpot", "common"]);
const payoutExport = useExportLabels("jackpotPayouts"); const payoutExport = useExportLabels("jackpotPayouts");
@@ -147,155 +193,152 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
</Card> </Card>
); );
const payoutHeader = embedded ? ( const payoutFooter = payouts ? (
<p className="mb-3 text-sm font-semibold">{t("payoutRecords")}</p> <AdminListPaginationFooter
) : ( selectId="jk-payout-per"
<CardHeader> total={payouts.meta.total}
<CardTitle className="text-base">{t("payoutRecords")}</CardTitle> page={payouts.meta.current_page}
</CardHeader> lastPage={payouts.meta.last_page}
perPage={payouts.meta.per_page}
loading={loadingP}
onPerPageChange={(n) => {
setPPer(n);
setPPage(1);
}}
onPageChange={setPPage}
/>
) : null;
const contributionFooter = contribs ? (
<AdminListPaginationFooter
selectId="jk-contrib-per"
total={contribs.meta.total}
page={contribs.meta.current_page}
lastPage={contribs.meta.last_page}
perPage={contribs.meta.per_page}
loading={loadingC}
onPerPageChange={(n) => {
setCPer(n);
setCPage(1);
}}
onPageChange={setCPage}
/>
) : null;
const payoutTable = (
<JackpotRecordTableSection
title={t("payoutRecords")}
tableId="jackpot-payout-table"
exportFilename={payoutExport.filename}
exportSheetName={payoutExport.sheetName}
loading={loadingP}
hasData={payouts != null}
footer={payoutFooter}
>
<Table id="jackpot-payout-table" className="table-fixed">
<TableHeader>
<TableRow>
<TableHead className="w-14">{t("table.id", { ns: "common" })}</TableHead>
<TableHead className="w-[11rem]">{t("drawNo")}</TableHead>
<TableHead className="w-28">{t("trigger")}</TableHead>
<TableHead className="w-32 text-right">{t("payoutAmount")}</TableHead>
<TableHead className="w-24 text-right">{t("winnerCount")}</TableHead>
<TableHead className="w-[11rem]">{t("time")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(payouts?.items ?? []).length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-muted-foreground">
{t("states.noData", { ns: "common" })}
</TableCell>
</TableRow>
) : (
(payouts?.items ?? []).map((r) => (
<TableRow key={r.id}>
<TableCell className="font-mono text-xs">{r.id}</TableCell>
<TableCell className="font-mono text-xs">{r.draw_no ?? "—"}</TableCell>
<TableCell className="text-xs">{triggerTypeText(r.trigger_type)}</TableCell>
<TableCell className="text-right font-mono text-xs tabular-nums">
{formatAdminMinorUnits(r.total_payout_amount, r.currency_code ?? "NPR")}
</TableCell>
<TableCell className="text-right tabular-nums">{r.winner_count}</TableCell>
<TableCell className="text-xs text-muted-foreground whitespace-nowrap">
{formatDt(r.created_at)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</JackpotRecordTableSection>
); );
const contributionHeader = embedded ? ( const contributionTable = (
<p className="mb-3 text-sm font-semibold">{t("contributionRecords")}</p> <JackpotRecordTableSection
) : ( title={t("contributionRecords")}
<CardHeader> tableId="jackpot-contribution-table"
<CardTitle className="text-base">{t("contributionRecords")}</CardTitle> exportFilename={contributionExport.filename}
</CardHeader> exportSheetName={contributionExport.sheetName}
loading={loadingC}
hasData={contribs != null}
footer={contributionFooter}
>
<Table id="jackpot-contribution-table" className="table-fixed">
<TableHeader>
<TableRow>
<TableHead className="w-14">{t("table.id", { ns: "common" })}</TableHead>
<TableHead className="w-[11rem]">{t("drawNo")}</TableHead>
<TableHead className="w-[11rem]">{t("ticketNo")}</TableHead>
<TableHead>{t("player")}</TableHead>
<TableHead className="w-32 text-right">{t("contributionAmount")}</TableHead>
<TableHead className="w-[11rem]">{t("time")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(contribs?.items ?? []).length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-muted-foreground">
{t("states.noData", { ns: "common" })}
</TableCell>
</TableRow>
) : (
(contribs?.items ?? []).map((r) => (
<TableRow key={r.id}>
<TableCell className="font-mono text-xs">{r.id}</TableCell>
<TableCell className="font-mono text-xs">{r.draw_no ?? "—"}</TableCell>
<TableCell className="font-mono text-xs">{r.ticket_no ?? "—"}</TableCell>
<TableCell className="max-w-[12rem] truncate text-xs">{r.player_username ?? "—"}</TableCell>
<TableCell className="text-right font-mono text-xs tabular-nums">
{formatAdminMinorUnits(r.contribution_amount, r.currency_code ?? "NPR")}
</TableCell>
<TableCell className="text-xs text-muted-foreground whitespace-nowrap">
{formatDt(r.created_at)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</JackpotRecordTableSection>
); );
const content = ( const content = (
<> <>
{filterBlock} {filterBlock}
{err ? <p className="text-destructive text-sm">{err}</p> : null}
{err ? <p className="text-destructive mb-4 text-sm">{err}</p> : null} {embedded ? (
<div className="space-y-8">
<Card className={embedded ? "mb-6 border-border/60 shadow-none" : "mb-8"}> {payoutTable}
{!embedded ? payoutHeader : null} {contributionTable}
<CardContent className={embedded ? "p-0" : undefined}> </div>
{embedded ? payoutHeader : null} ) : (
{loadingP && !payouts ? ( <div className="space-y-8">
<p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p> {payoutTable}
) : ( {contributionTable}
<> </div>
<div className="admin-table-toolbar"> )}
<AdminTableExportButton
tableId="jackpot-payout-table"
filename={payoutExport.filename}
sheetName={payoutExport.sheetName}
/>
</div>
<Table id="jackpot-payout-table">
<TableHeader>
<TableRow>
<TableHead>{t("table.id", { ns: "common" })}</TableHead>
<TableHead>{t("drawNo")}</TableHead>
<TableHead>{t("trigger")}</TableHead>
<TableHead className="text-right">{t("payoutAmount")}</TableHead>
<TableHead className="text-right">{t("winnerCount")}</TableHead>
<TableHead>{t("time")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(payouts?.items ?? []).map((r) => (
<TableRow key={r.id}>
<TableCell className="font-mono text-xs">{r.id}</TableCell>
<TableCell className="font-mono text-xs">{r.draw_no ?? "—"}</TableCell>
<TableCell className="text-xs">{triggerTypeText(r.trigger_type)}</TableCell>
<TableCell className="text-right font-mono text-xs tabular-nums">
{formatAdminMinorUnits(r.total_payout_amount, r.currency_code ?? "NPR")}
</TableCell>
<TableCell className="text-right tabular-nums">{r.winner_count}</TableCell>
<TableCell className="text-xs text-muted-foreground whitespace-nowrap">
{formatDt(r.created_at)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</>
)}
{payouts ? (
<AdminListPaginationFooter
selectId="jk-payout-per"
total={payouts.meta.total}
page={payouts.meta.current_page}
lastPage={payouts.meta.last_page}
perPage={payouts.meta.per_page}
loading={loadingP}
onPerPageChange={(n) => {
setPPer(n);
setPPage(1);
}}
onPageChange={setPPage}
/>
) : null}
</CardContent>
</Card>
<Card className={embedded ? "border-border/60 shadow-none" : undefined}>
{!embedded ? contributionHeader : null}
<CardContent className={embedded ? "p-0" : undefined}>
{embedded ? contributionHeader : null}
{loadingC && !contribs ? (
<p className="text-muted-foreground text-sm">{t("states.loading", { ns: "common" })}</p>
) : (
<>
<div className="admin-table-toolbar">
<AdminTableExportButton
tableId="jackpot-contribution-table"
filename={contributionExport.filename}
sheetName={contributionExport.sheetName}
/>
</div>
<Table id="jackpot-contribution-table">
<TableHeader>
<TableRow>
<TableHead>{t("table.id", { ns: "common" })}</TableHead>
<TableHead>{t("drawNo")}</TableHead>
<TableHead>{t("ticketNo")}</TableHead>
<TableHead>{t("player")}</TableHead>
<TableHead className="text-right">{t("contributionAmount")}</TableHead>
<TableHead>{t("time")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(contribs?.items ?? []).map((r) => (
<TableRow key={r.id}>
<TableCell className="font-mono text-xs">{r.id}</TableCell>
<TableCell className="font-mono text-xs">{r.draw_no ?? "—"}</TableCell>
<TableCell className="font-mono text-xs">{r.ticket_no ?? "—"}</TableCell>
<TableCell className="max-w-[10rem] truncate text-xs">
{r.player_username ?? "—"}
</TableCell>
<TableCell className="text-right font-mono text-xs tabular-nums">
{formatAdminMinorUnits(r.contribution_amount, r.currency_code ?? "NPR")}
</TableCell>
<TableCell className="text-xs text-muted-foreground whitespace-nowrap">
{formatDt(r.created_at)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</>
)}
{contribs ? (
<AdminListPaginationFooter
selectId="jk-contrib-per"
total={contribs.meta.total}
page={contribs.meta.current_page}
lastPage={contribs.meta.last_page}
perPage={contribs.meta.per_page}
loading={loadingC}
onPerPageChange={(n) => {
setCPer(n);
setCPage(1);
}}
onPageChange={setCPage}
/>
) : null}
</CardContent>
</Card>
</> </>
); );

View File

@@ -11,6 +11,7 @@ import {
postAdminCurrency, postAdminCurrency,
putAdminCurrency, putAdminCurrency,
} from "@/api/admin-currencies"; } from "@/api/admin-currencies";
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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -205,12 +206,7 @@ export function CurrencySettingsPanel() {
return ( return (
<Card className="admin-list-card"> <Card className="admin-list-card">
<CardHeader className="admin-list-header flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <CardHeader className="admin-list-header flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1"> <CardTitle className="admin-list-title">{t("currencies.title", { ns: "config" })}</CardTitle>
<CardTitle className="admin-list-title">{t("currencies.title", { ns: "config" })}</CardTitle>
<p className="text-sm text-muted-foreground">
{t("currencies.description", { ns: "config" })}
</p>
</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<AdminTableExportButton tableId="admin-currencies-table" filename={exportLabels.filename} <AdminTableExportButton tableId="admin-currencies-table" filename={exportLabels.filename}
sheetName={exportLabels.sheetName} /> sheetName={exportLabels.sheetName} />
@@ -249,8 +245,22 @@ export function CurrencySettingsPanel() {
<TableCell className="font-mono">{row.code}</TableCell> <TableCell className="font-mono">{row.code}</TableCell>
<TableCell>{row.name}</TableCell> <TableCell>{row.name}</TableCell>
<TableCell>{row.decimal_places}</TableCell> <TableCell>{row.decimal_places}</TableCell>
<TableCell>{row.is_enabled ? t("system.states.enabled", { ns: "config" }) : t("system.states.disabled", { ns: "config" })}</TableCell> <TableCell>
<TableCell>{row.is_bettable ? t("system.states.enabled", { ns: "config" }) : t("system.states.disabled", { ns: "config" })}</TableCell> <AdminStatusBadge status={row.is_enabled ? "enabled" : "disabled"}>
{row.is_enabled
? t("system.states.enabled", { ns: "config" })
: t("system.states.disabled", { ns: "config" })}
</AdminStatusBadge>
</TableCell>
<TableCell>
<AdminStatusBadge
status={row.is_enabled && row.is_bettable ? "enabled" : "disabled"}
>
{row.is_enabled && row.is_bettable
? t("system.states.enabled", { ns: "config" })
: t("system.states.disabled", { ns: "config" })}
</AdminStatusBadge>
</TableCell>
<TableCell className="text-center"> <TableCell className="text-center">
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">
<Button variant="outline" size="sm" onClick={() => openEdit(row)}> <Button variant="outline" size="sm" onClick={() => openEdit(row)}>

View File

@@ -10,10 +10,10 @@ import {
} from "@/api/admin-settings"; } from "@/api/admin-settings";
import { WalletConfigDocScreen } from "@/modules/config/doc/wallet-config-doc-screen"; import { WalletConfigDocScreen } from "@/modules/config/doc/wallet-config-doc-screen";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
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 { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { LotteryApiBizError } from "@/types/api/errors"; import { LotteryApiBizError } from "@/types/api/errors";
const DRAW_GROUP = "draw"; const DRAW_GROUP = "draw";
@@ -28,13 +28,18 @@ const DRAW_KEYS = {
const FRONTEND_GROUP = "frontend"; const FRONTEND_GROUP = "frontend";
const FRONTEND_KEYS = { const FRONTEND_KEYS = {
PLAY_RULES_HTML: "frontend.play_rules_html", PLAY_RULES_HTML: "frontend.play_rules_html",
PLAY_RULES_HTML_ZH: "frontend.play_rules_html_zh",
PLAY_RULES_HTML_EN: "frontend.play_rules_html_en",
PLAY_RULES_HTML_NE: "frontend.play_rules_html_ne",
} as const; } as const;
interface RuntimeDraft { interface RuntimeDraft {
requireManualReview: boolean; requireManualReview: boolean;
cooldownMinutes: string; cooldownMinutes: string;
autoSettlement: boolean; autoSettlement: boolean;
playRulesHtml: string; playRulesHtmlZh: string;
playRulesHtmlEn: string;
playRulesHtmlNe: string;
} }
function BinaryChoice({ function BinaryChoice({
@@ -76,19 +81,56 @@ function BinaryChoice({
); );
} }
function SaveActions({
dirty,
loading,
saving,
onSave,
onDiscard,
saveLabel,
savingLabel,
discardLabel,
}: {
dirty: boolean;
loading: boolean;
saving: boolean;
onSave: () => void;
onDiscard: () => void;
saveLabel: string;
savingLabel: string;
discardLabel: string;
}) {
return (
<div className="flex flex-wrap items-center gap-3 pt-2">
<Button type="button" onClick={onSave} disabled={!dirty || loading || saving}>
{saving ? savingLabel : saveLabel}
</Button>
{dirty ? (
<Button type="button" variant="outline" onClick={onDiscard}>
{discardLabel}
</Button>
) : null}
</div>
);
}
export function SystemSettingsScreen() { export function SystemSettingsScreen() {
const { t } = useTranslation(["common", "config", "adminUsers"]); const { t } = useTranslation(["common", "config", "adminUsers"]);
const [draft, setDraft] = useState<RuntimeDraft>({ const [draft, setDraft] = useState<RuntimeDraft>({
requireManualReview: false, requireManualReview: false,
cooldownMinutes: "15", cooldownMinutes: "15",
autoSettlement: true, autoSettlement: true,
playRulesHtml: "", playRulesHtmlZh: "",
playRulesHtmlEn: "",
playRulesHtmlNe: "",
}); });
const [saved, setSaved] = useState<RuntimeDraft>({ const [saved, setSaved] = useState<RuntimeDraft>({
requireManualReview: false, requireManualReview: false,
cooldownMinutes: "15", cooldownMinutes: "15",
autoSettlement: true, autoSettlement: true,
playRulesHtml: "", playRulesHtmlZh: "",
playRulesHtmlEn: "",
playRulesHtmlNe: "",
}); });
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -108,11 +150,14 @@ export function SystemSettingsScreen() {
kv[item.key] = item.value; kv[item.key] = item.value;
} }
const legacyHtml = String(kv[FRONTEND_KEYS.PLAY_RULES_HTML] ?? "");
const nextDraft: RuntimeDraft = { const nextDraft: RuntimeDraft = {
requireManualReview: Boolean(kv[DRAW_KEYS.REQUIRE_MANUAL_REVIEW] ?? false), requireManualReview: Boolean(kv[DRAW_KEYS.REQUIRE_MANUAL_REVIEW] ?? false),
cooldownMinutes: String(kv[DRAW_KEYS.COOLDOWN_MINUTES] ?? 15), cooldownMinutes: String(kv[DRAW_KEYS.COOLDOWN_MINUTES] ?? 15),
autoSettlement: Boolean(kv[DRAW_KEYS.AUTO_SETTLEMENT] ?? true), autoSettlement: Boolean(kv[DRAW_KEYS.AUTO_SETTLEMENT] ?? true),
playRulesHtml: String(kv[FRONTEND_KEYS.PLAY_RULES_HTML] ?? ""), playRulesHtmlZh: String(kv[FRONTEND_KEYS.PLAY_RULES_HTML_ZH] ?? legacyHtml),
playRulesHtmlEn: String(kv[FRONTEND_KEYS.PLAY_RULES_HTML_EN] ?? ""),
playRulesHtmlNe: String(kv[FRONTEND_KEYS.PLAY_RULES_HTML_NE] ?? ""),
}; };
setDraft(nextDraft); setDraft(nextDraft);
setSaved(nextDraft); setSaved(nextDraft);
@@ -144,7 +189,10 @@ export function SystemSettingsScreen() {
Math.max(0, Number.parseInt(draft.cooldownMinutes || "0", 10) || 0), Math.max(0, Number.parseInt(draft.cooldownMinutes || "0", 10) || 0),
); );
await updateAdminSetting(DRAW_KEYS.AUTO_SETTLEMENT, draft.autoSettlement); await updateAdminSetting(DRAW_KEYS.AUTO_SETTLEMENT, draft.autoSettlement);
await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML, draft.playRulesHtml); await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML_ZH, draft.playRulesHtmlZh);
await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML_EN, draft.playRulesHtmlEn);
await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML_NE, draft.playRulesHtmlNe);
await updateAdminSetting(FRONTEND_KEYS.PLAY_RULES_HTML, draft.playRulesHtmlZh);
toast.success(t("system.saveSuccess", { ns: "config" })); toast.success(t("system.saveSuccess", { ns: "config" }));
setSaved(draft); setSaved(draft);
setDirty(false); setDirty(false);
@@ -157,144 +205,135 @@ export function SystemSettingsScreen() {
} }
}; };
const saveLabel = t("actions.save", { ns: "adminUsers" });
const savingLabel = t("saving", { ns: "adminUsers" });
const discardLabel = t("system.discard", { ns: "config" });
return ( return (
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6"> <div className="flex flex-col gap-10">
<Card> <section className="space-y-4">
<CardHeader className="space-y-3"> <h2 className="border-b border-border/60 pb-3 text-base font-semibold text-foreground">
<div className="space-y-1"> {t("system.title", { ns: "config" })}
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground"> </h2>
{t("nav.settings", { ns: "common", defaultValue: "System Settings" })}
</p> <div className="space-y-5">
<CardTitle className="text-2xl"> <div className="flex flex-wrap items-center justify-between gap-3">
{t("system.runtimeTitle", { ns: "config" })} <Label className="text-sm font-medium">{t("system.fields.manualReview", { ns: "config" })}</Label>
</CardTitle> <BinaryChoice
active={draft.requireManualReview}
disabled={loading || saving}
onChange={(value) => updateDraft("requireManualReview", value)}
leftLabel={t("system.states.disabled", { ns: "config" })}
rightLabel={t("system.states.enabled", { ns: "config" })}
/>
</div> </div>
</CardHeader>
<CardContent className="space-y-8"> <div className="h-px bg-border/60" />
<section className="space-y-4">
<div className="flex flex-wrap items-end justify-between gap-3">
<div className="space-y-1">
<h3 className="text-base font-semibold">{t("system.title", { ns: "config" })}</h3>
</div>
</div>
<div className="space-y-5 rounded-2xl border border-border/60 bg-muted/10 px-4 py-4"> <div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center justify-between gap-3"> <Label className="text-sm font-medium">{t("system.fields.autoSettlement", { ns: "config" })}</Label>
<div className="space-y-1"> <BinaryChoice
<Label className="text-sm font-medium">{t("system.fields.manualReview", { ns: "config" })}</Label> active={draft.autoSettlement}
</div> disabled={loading || saving}
<BinaryChoice onChange={(value) => updateDraft("autoSettlement", value)}
active={draft.requireManualReview} leftLabel={t("system.states.disabled", { ns: "config" })}
disabled={loading || saving} rightLabel={t("system.states.enabled", { ns: "config" })}
onChange={(value) => updateDraft("requireManualReview", value)} />
leftLabel={t("system.states.disabled", { ns: "config" })} </div>
rightLabel={t("system.states.enabled", { ns: "config" })}
/>
</div>
<div className="h-px bg-border/60" /> <div className="h-px bg-border/60" />
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="grid max-w-xs gap-2">
<div className="space-y-1"> <Label htmlFor="cooldown-minutes" className="text-sm font-medium">
<Label className="text-sm font-medium">{t("system.fields.autoSettlement", { ns: "config" })}</Label> {t("system.fields.cooldownMinutes", { ns: "config" })}
</div> </Label>
<BinaryChoice <Input
active={draft.autoSettlement} id="cooldown-minutes"
disabled={loading || saving} type="number"
onChange={(value) => updateDraft("autoSettlement", value)} min="0"
leftLabel={t("system.states.disabled", { ns: "config" })} step="1"
rightLabel={t("system.states.enabled", { ns: "config" })} value={draft.cooldownMinutes}
/> onChange={(e) => updateDraft("cooldownMinutes", e.target.value)}
</div> disabled={loading || saving}
/>
</div>
</div>
<div className="h-px bg-border/60" /> </section>
<div className="grid gap-2"> <section className="space-y-4">
<Label htmlFor="cooldown-minutes" className="text-sm font-medium"> <h2 className="border-b border-border/60 pb-3 text-base font-semibold text-foreground">
{t("system.fields.cooldownMinutes", { ns: "config" })} {t("wallet.title", { ns: "config" })}
</Label> </h2>
<Input <WalletConfigDocScreen embedded />
id="cooldown-minutes" </section>
type="number"
min="0"
step="1"
value={draft.cooldownMinutes}
onChange={(e) => updateDraft("cooldownMinutes", e.target.value)}
disabled={loading || saving}
className="max-w-[240px]"
/>
</div>
<div className="flex items-center gap-4 pt-2"> <section className="space-y-4">
<Button onClick={() => void handleSave()} disabled={!dirty || loading || saving}> <h2 className="border-b border-border/60 pb-3 text-base font-semibold text-foreground">
{saving ? t("saving", { ns: "adminUsers" }) : t("actions.save", { ns: "adminUsers" })} {t("system.frontendConfig", { ns: "config" })}
</Button> </h2>
{dirty && (
<Button
variant="outline"
onClick={() => {
setDraft(saved);
setDirty(false);
}}
>
{t("system.discard", { ns: "config" })}
</Button>
)}
</div>
</div>
</section>
<section className="space-y-4 border-t border-border/60 pt-6"> <div className="grid gap-2">
<div className="space-y-1"> <Label className="text-sm font-medium">
<h3 className="text-base font-semibold">{t("system.frontendConfig", { ns: "config", defaultValue: "前端配置" })}</h3> {t("system.fields.playRulesHtml", { ns: "config" })}
</div> </Label>
<p className="text-xs text-muted-foreground">
<div className="space-y-5 rounded-2xl border border-border/60 bg-muted/10 px-4 py-4"> {t("system.fields.playRulesHtmlDesc", { ns: "config" })}
<div className="grid gap-2"> </p>
<Label htmlFor="play-rules-html" className="text-sm font-medium"> <Tabs defaultValue="zh" className="w-full">
{t("system.fields.playRulesHtml", { ns: "config", defaultValue: "玩法规则 HTML 内容" })} <TabsList className="w-full max-w-md">
</Label> <TabsTrigger value="zh">{t("play.locales.zh", { ns: "config" })}</TabsTrigger>
<p className="text-xs text-muted-foreground"> <TabsTrigger value="en">{t("play.locales.en", { ns: "config" })}</TabsTrigger>
{t("system.fields.playRulesHtmlDesc", { ns: "config", defaultValue: "该内容将直接在玩家端的玩法规则页面作为 HTML 渲染。留空则显示前端默认提示。" })} <TabsTrigger value="ne">{t("play.locales.ne", { ns: "config" })}</TabsTrigger>
</p> </TabsList>
<Textarea <TabsContent value="zh" className="mt-3">
id="play-rules-html" <Textarea
value={draft.playRulesHtml} id="play-rules-html-zh"
onChange={(e) => updateDraft("playRulesHtml", e.target.value)} value={draft.playRulesHtmlZh}
disabled={loading || saving} onChange={(e) => updateDraft("playRulesHtmlZh", e.target.value)}
className="font-mono text-xs min-h-[200px]" disabled={loading || saving}
placeholder="<div>...</div>" className="min-h-[200px] font-mono text-xs"
/> placeholder="<div>...</div>"
</div> />
</TabsContent>
<TabsContent value="en" className="mt-3">
<Textarea
id="play-rules-html-en"
value={draft.playRulesHtmlEn}
onChange={(e) => updateDraft("playRulesHtmlEn", e.target.value)}
disabled={loading || saving}
className="min-h-[200px] font-mono text-xs"
placeholder="<div>...</div>"
/>
</TabsContent>
<TabsContent value="ne" className="mt-3">
<Textarea
id="play-rules-html-ne"
value={draft.playRulesHtmlNe}
onChange={(e) => updateDraft("playRulesHtmlNe", e.target.value)}
disabled={loading || saving}
className="min-h-[200px] font-mono text-xs"
placeholder="<div>...</div>"
/>
</TabsContent>
</Tabs>
</div>
<div className="flex items-center gap-4 pt-2"> </section>
<Button onClick={() => void handleSave()} disabled={!dirty || loading || saving}>
{saving ? t("saving", { ns: "adminUsers" }) : t("actions.save", { ns: "adminUsers" })}
</Button>
{dirty && (
<Button
variant="outline"
onClick={() => {
setDraft(saved);
setDirty(false);
}}
>
{t("system.discard", { ns: "config" })}
</Button>
)}
</div>
</div>
</section>
<section className="space-y-4 border-t border-border/60 pt-6"> <SaveActions
<div className="space-y-1"> dirty={dirty}
<h3 className="text-base font-semibold">{t("wallet.title", { ns: "config" })}</h3> loading={loading}
</div> saving={saving}
<WalletConfigDocScreen embedded /> onSave={() => void handleSave()}
</section> onDiscard={() => {
</CardContent> setDraft(saved);
</Card> setDirty(false);
}}
saveLabel={saveLabel}
savingLabel={savingLabel}
discardLabel={discardLabel}
/>
</div> </div>
); );
} }

View File

@@ -168,9 +168,11 @@ export function PlayerTicketsConsole(): React.ReactElement {
<CardTitle className="admin-list-title">{t("playerTicketQuery")}</CardTitle> <CardTitle className="admin-list-title">{t("playerTicketQuery")}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="admin-list-content"> <CardContent className="admin-list-content">
<div className="grid gap-3 lg:grid-cols-2 xl:grid-cols-4"> <div className="admin-list-toolbar">
<div className="grid gap-1.5"> <div className="admin-list-field min-w-[12rem] flex-1 sm:max-w-md">
<Label htmlFor="pt-player">{t("playerId")}</Label> <Label htmlFor="pt-player" className="sm:shrink-0">
{t("playerId")}
</Label>
<Input <Input
id="pt-player" id="pt-player"
className="font-mono" className="font-mono"
@@ -181,18 +183,22 @@ export function PlayerTicketsConsole(): React.ReactElement {
} }
/> />
</div> </div>
<div className="grid gap-1.5"> <div className="admin-list-field">
<Label htmlFor="pt-draw">{t("drawNoOptional")}</Label> <Label htmlFor="pt-draw" className="sm:shrink-0">
{t("drawNoOptional")}
</Label>
<Input <Input
id="pt-draw" id="pt-draw"
className="font-mono text-sm" className="font-mono text-sm sm:w-44"
placeholder={t("drawNoPlaceholder")} placeholder={t("drawNoPlaceholder")}
value={draft.drawNo} value={draft.drawNo}
onChange={(e) => setDraft((current) => ({ ...current, drawNo: e.target.value }))} onChange={(e) => setDraft((current) => ({ ...current, drawNo: e.target.value }))}
/> />
</div> </div>
<div className="grid gap-1.5"> <div className="admin-list-field min-w-[12rem] flex-1 sm:max-w-md">
<Label htmlFor="pt-number">{t("numberKeyword")}</Label> <Label htmlFor="pt-number" className="sm:shrink-0">
{t("numberKeyword")}
</Label>
<Input <Input
id="pt-number" id="pt-number"
className="font-mono text-sm" className="font-mono text-sm"
@@ -203,62 +209,67 @@ export function PlayerTicketsConsole(): React.ReactElement {
} }
/> />
</div> </div>
<div className="grid gap-1.5"> <div className="admin-list-field">
<AdminDateRangeField <Label htmlFor="pt-date-range" className="sm:shrink-0">
id="pt-date-range" {t("placedDateRange")}
label={t("placedDateRange")} </Label>
from={draft.startDate} <div className="min-w-0 w-full sm:w-56">
to={draft.endDate} <AdminDateRangeField
onRangeChange={(range) => id="pt-date-range"
setDraft((current) => ({ from={draft.startDate}
...current, to={draft.endDate}
startDate: range.from, onRangeChange={(range) =>
endDate: range.to, setDraft((current) => ({
})) ...current,
} startDate: range.from,
/> endDate: range.to,
}))
}
/>
</div>
</div> </div>
</div> <div className="admin-list-field">
<Label htmlFor="pt-status" className="sm:shrink-0">
<div className="grid gap-2"> {t("statusFilterLabel")}
<div className="flex items-center justify-between gap-3"> </Label>
<span className="text-sm font-medium leading-none">{t("statusFilterLabel")}</span> <DropdownMenu>
<span className="text-muted-foreground text-xs">{t("statusHint")}</span> <DropdownMenuTrigger
id="pt-status"
title={t("statusHint")}
className="inline-flex h-8 w-full min-w-0 items-center justify-between rounded-md border border-border bg-card px-3 text-left text-sm font-normal shadow-sm outline-none transition-all hover:bg-accent focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 sm:w-44"
>
<span className="truncate">{ticketStatusSummary(draft.statuses, t)}</span>
<ChevronDown className="size-4 shrink-0 opacity-60" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
{TICKET_STATUS_OPTIONS.map((status) => (
<DropdownMenuCheckboxItem
key={status}
checked={draft.statuses.includes(status)}
onCheckedChange={(checked) => toggleStatus(status, checked === true)}
>
{ticketStatusText(status, t)}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
<DropdownMenu> <div className="admin-list-actions">
<DropdownMenuTrigger className="inline-flex h-11 w-full items-center justify-between rounded-md border border-border bg-card px-4 text-left text-sm font-normal text-primary shadow-sm outline-none transition-all hover:bg-accent hover:text-primary focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50"> <AdminTableExportButton
<span className="truncate">{ticketStatusSummary(draft.statuses, t)}</span> tableId="tickets-table"
<ChevronDown className="size-4 shrink-0 opacity-60" /> filename={exportLabels.filename}
</DropdownMenuTrigger>
<DropdownMenuContent className="w-[min(28rem,calc(100vw-2rem))]">
{TICKET_STATUS_OPTIONS.map((status) => (
<DropdownMenuCheckboxItem
key={status}
checked={draft.statuses.includes(status)}
onCheckedChange={(checked) => toggleStatus(status, checked === true)}
>
{ticketStatusText(status, t)}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex flex-wrap gap-2">
<AdminTableExportButton
tableId="tickets-table"
filename={exportLabels.filename}
sheetName={exportLabels.sheetName} sheetName={exportLabels.sheetName}
/> />
<Button type="button" size="sm" onClick={() => runSearch()}> <Button type="button" size="sm" onClick={() => runSearch()}>
{t("query")} {t("query")}
</Button> </Button>
<Button type="button" size="sm" variant="outline" onClick={() => resetFilters()}> <Button type="button" size="sm" variant="outline" onClick={() => resetFilters()}>
{t("resetFilters")} {t("resetFilters")}
</Button> </Button>
<Button type="button" size="sm" variant="secondary" onClick={() => void load()}> <Button type="button" size="sm" variant="secondary" onClick={() => void load()}>
{t("refreshCurrentPage")} {t("refreshCurrentPage")}
</Button> </Button>
</div>
</div> </div>
{applied.playerQuery || applied.drawNo || applied.numberKeyword || applied.startDate || applied.endDate || applied.statuses.length > 0 ? ( {applied.playerQuery || applied.drawNo || applied.numberKeyword || applied.startDate || applied.endDate || applied.statuses.length > 0 ? (