Files
lotteryAdmin/src/modules/config/doc/play-config-doc-screen.tsx

894 lines
35 KiB
TypeScript

"use client";
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
deletePlayConfigVersion,
getAllConfigVersions,
getPlayConfigVersion,
getPlayConfigVersions,
postPlayConfigVersion,
publishPlayConfigVersion,
putPlayConfigItems,
} from "@/api/admin-config";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { ConfigChipGroup } from "@/modules/config/config-chip-group";
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 { ConfirmableSwitch } from "@/components/admin/confirmable-switch";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { ConfigReadonlyValue } from "@/modules/config/config-readonly-value";
import { ConfigVersionActions } from "@/modules/config/config-version-actions";
import { ConfigVersionSwitcher } from "@/modules/config/config-version-switcher";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { formatAdminMinorDecimal, parseAdminMajorToMinor } from "@/lib/money";
import { PRD_PLAY_SWITCH_MANAGE } from "@/lib/admin-prd";
import { useAdminProfile } from "@/stores/admin-session";
import { LotteryApiBizError } from "@/types/api/errors";
import type {
ConfigVersionSummary,
PlayConfigItemRow,
PlayConfigVersionDetail,
} from "@/types/api/admin-config";
type PlayConfigSaveItemPayload = {
play_code: string;
category: string;
dimension: number | null;
bet_mode: string | null;
display_name: string;
is_enabled: boolean;
min_bet_amount: number;
max_bet_amount: number;
display_order: number;
supports_multi_number: boolean;
reserved_rule_json: unknown;
rule_text_zh: string | null;
rule_text_en: string | null;
rule_text_ne: string | null;
extra_config_json: unknown;
};
type PlayBatchSwitchGroup = {
key: string;
match: (row: PlayConfigItemRow) => boolean;
};
const PLAY_BATCH_SWITCH_GROUPS: PlayBatchSwitchGroup[] = [
{
key: "d2",
match: (row) => row.dimension === 2,
},
{
key: "d3",
match: (row) => row.dimension === 3,
},
{
key: "d4",
match: (row) => row.dimension === 4,
},
{
key: "big-small",
match: (row) => row.play_code === "big" || row.play_code === "small",
},
{
key: "position",
match: (row) => row.category === "position",
},
{
key: "box",
match: (row) => row.category === "box",
},
{
key: "jackpot",
match: (row) => row.category === "jackpot" || row.play_code.includes("jackpot"),
},
];
/** Save payload for play-config drafts. Persist the current draft snapshot directly. */
function buildPlayConfigSavePayload(
draftRows: PlayConfigItemRow[],
): PlayConfigSaveItemPayload[] {
return [...draftRows]
.sort((a, b) => a.display_order - b.display_order || a.play_code.localeCompare(b.play_code))
.map((row) => ({
play_code: row.play_code,
category: row.category ?? "",
dimension: row.dimension,
bet_mode: row.bet_mode,
display_name: row.display_name ?? row.play_code,
is_enabled: row.is_enabled,
min_bet_amount: row.min_bet_amount,
max_bet_amount: row.max_bet_amount,
display_order: row.display_order,
supports_multi_number: row.supports_multi_number,
reserved_rule_json: row.reserved_rule_json,
rule_text_zh: row.rule_text_zh,
rule_text_en: row.rule_text_en,
rule_text_ne: row.rule_text_ne,
extra_config_json: row.extra_config_json,
}));
}
export function PlayConfigDocScreen() {
const { t } = useTranslation(["config", "adminUsers", "common"]);
const tRef = useTranslationRef(["config", "common"]);
const { request: requestConfirm, ConfirmDialog, busy: confirmBusy } = useConfirmAction();
const profile = useAdminProfile();
const canManage = adminHasAnyPermission(profile?.permissions, [PRD_PLAY_SWITCH_MANAGE]);
const formatDt = useAdminDateTimeFormatter();
const [list, setList] = useState<ConfigVersionSummary[]>([]);
const [selectedId, setSelectedId] = useState("");
const [detail, setDetail] = useState<PlayConfigVersionDetail | null>(null);
const [draftRows, setDraftRows] = useState<PlayConfigItemRow[]>([]);
const [loadingList, setLoadingList] = useState(true);
const [loadingDetail, setLoadingDetail] = useState(false);
const [saving, setSaving] = useState(false);
const [creatingDraftId, setCreatingDraftId] = useState<string | null>(null);
const [rollbackOpen, setRollbackOpen] = useState(false);
const [rollbackTarget, setRollbackTarget] = useState<ConfigVersionSummary | null>(null);
const [error, setError] = useState<string | null>(null);
const [keyword, setKeyword] = useState("");
const [statusFilter, setStatusFilter] = useState<"all" | "enabled" | "disabled">("all");
const [categoryFilter, setCategoryFilter] = useState("all");
const detailRequestSeq = useRef(0);
const refreshList = useCallback(async () => {
setLoadingList(true);
setError(null);
try {
const d = await getAllConfigVersions(getPlayConfigVersions);
setList(d.items);
setCreatingDraftId((draftId) =>
draftId !== null && d.items.some((x) => String(x.id) === draftId) ? null : draftId,
);
} catch (e) {
const msg =
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" });
setError(msg);
setList([]);
} finally {
setLoadingList(false);
}
}, []);
useAsyncEffect(() => {
void refreshList();
}, []);
const loadDetail = useCallback(async (id: number) => {
const requestSeq = detailRequestSeq.current + 1;
detailRequestSeq.current = requestSeq;
setLoadingDetail(true);
setDetail(null);
setDraftRows([]);
try {
const d = await getPlayConfigVersion(id);
if (detailRequestSeq.current !== requestSeq) {
return;
}
setDetail(d);
setDraftRows(d.items.map((it) => ({ ...it })));
} catch (e) {
if (detailRequestSeq.current !== requestSeq) {
return;
}
toast.error(
e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }),
);
setDetail(null);
setDraftRows([]);
} finally {
if (detailRequestSeq.current === requestSeq) {
setLoadingDetail(false);
}
}
}, []);
useEffect(() => {
if (list.length === 0) {
if (selectedId !== "") {
queueMicrotask(() => {
setSelectedId("");
setDetail(null);
setDraftRows([]);
});
}
return;
}
if (selectedId !== "" && list.some((x) => String(x.id) === selectedId)) {
return;
}
if (creatingDraftId !== null && selectedId === creatingDraftId) {
return;
}
queueMicrotask(() => {
const active = list.find((x) => x.status === "active");
const drafts = list.filter((x) => x.status === "draft").sort((a, b) => b.id - a.id);
const pick = active ?? drafts[0] ?? [...list].sort((a, b) => b.id - a.id)[0];
if (pick) {
setSelectedId(String(pick.id));
}
});
}, [list, selectedId, creatingDraftId]);
useEffect(() => {
if (selectedId === "") {
return;
}
const id = Number(selectedId);
if (!Number.isFinite(id)) {
return;
}
queueMicrotask(() => {
void loadDetail(id);
});
}, [selectedId, loadDetail]);
const selectedVersionSummary = useMemo(
() => list.find((x) => String(x.id) === selectedId) ?? null,
[list, selectedId],
);
const isSelectedDetail = detail !== null && String(detail.id) === selectedId;
const selectedStatus = isSelectedDetail ? detail.status : selectedVersionSummary?.status;
const isDraft = selectedStatus === "draft";
const amountCurrencyCode = "NPR";
const orderedRows = useMemo(
() =>
[...draftRows].sort(
(a, b) => a.display_order - b.display_order || a.play_code.localeCompare(b.play_code),
),
[draftRows],
);
const categoryOptions = useMemo(() => {
const seen = new Set<string>();
return orderedRows
.map((row) => row.category?.trim() || "")
.filter((value) => {
if (!value || seen.has(value)) {
return false;
}
seen.add(value);
return true;
});
}, [orderedRows]);
const filteredRows = useMemo(() => {
const normalizedKeyword = keyword.trim().toLowerCase();
return orderedRows.filter((row) => {
const normalizedCategory = row.category?.trim() || "uncategorized";
const matchesKeyword =
normalizedKeyword === "" ||
row.play_code.toLowerCase().includes(normalizedKeyword) ||
(row.display_name ?? "").toLowerCase().includes(normalizedKeyword) ||
(row.category ?? "").toLowerCase().includes(normalizedKeyword);
const matchesStatus =
statusFilter === "all" ||
(statusFilter === "enabled" && row.is_enabled) ||
(statusFilter === "disabled" && !row.is_enabled);
const matchesCategory = categoryFilter === "all" || normalizedCategory === categoryFilter;
return matchesKeyword && matchesStatus && matchesCategory;
});
}, [categoryFilter, keyword, orderedRows, statusFilter]);
const groupedRows = useMemo(() => {
const groups = new Map<string, PlayConfigItemRow[]>();
for (const row of filteredRows) {
const groupKey = row.category?.trim() || "uncategorized";
const current = groups.get(groupKey);
if (current) {
current.push(row);
} else {
groups.set(groupKey, [row]);
}
}
return Array.from(groups.entries());
}, [filteredRows]);
function categoryLabel(categoryKey: string): string {
if (categoryKey === "uncategorized") {
return t("play.filters.uncategorized", { ns: "config" });
}
const mapped = t(`play.categories.${categoryKey}`, { ns: "config" });
return mapped === `play.categories.${categoryKey}` ? categoryKey : mapped;
}
function updateConfigRow(playCode: string, patch: Partial<PlayConfigItemRow>) {
setDraftRows((prev) => prev.map((r) => (r.play_code === playCode ? { ...r, ...patch } : r)));
}
function applyBatchSwitch(group: PlayBatchSwitchGroup, enabled: boolean) {
setDraftRows((prev) =>
prev.map((row) => (group.match(row) ? { ...row, is_enabled: enabled } : row)),
);
}
const batchSwitchStates = useMemo(
() =>
PLAY_BATCH_SWITCH_GROUPS.map((group) => {
const rows = draftRows.filter(group.match);
const enabledCount = rows.filter((row) => row.is_enabled).length;
return {
...group,
label: t(`play.batchGroups.${group.key}`, { ns: "config", defaultValue: group.key }),
total: rows.length,
enabledCount,
allEnabled: rows.length > 0 && enabledCount === rows.length,
};
}),
[draftRows, t],
);
async function handleSaveDraft() {
if (!detail || !isDraft) {
return;
}
const payload = buildPlayConfigSavePayload(draftRows);
for (const r of payload) {
if (r.min_bet_amount > r.max_bet_amount) {
toast.error(t("play.validation.minMaxInvalid", { ns: "config", playCode: r.play_code }));
return;
}
}
setSaving(true);
try {
const d = await putPlayConfigItems(detail.id, payload);
setDetail(d);
setDraftRows(d.items.map((it) => ({ ...it })));
toast.success(t("versionActions.saveDraft", { ns: "config" }));
void refreshList();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("versionActions.saveFailed", { ns: "config" }));
} finally {
setSaving(false);
}
}
async function handlePublish() {
if (!detail || !isDraft) {
return;
}
setSaving(true);
try {
const d = await publishPlayConfigVersion(detail.id);
setDetail(d);
setDraftRows(d.items.map((it) => ({ ...it })));
toast.success(t("versionActions.publishCurrent", { ns: "config" }));
void refreshList();
setSelectedId(String(d.id));
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("play.publishFailed", { ns: "config" }));
} finally {
setSaving(false);
}
}
async function handleNewDraft() {
setSaving(true);
try {
const active = list.find((x) => x.status === "active");
const d = await postPlayConfigVersion({
reason: `draft ${new Date().toISOString()}`,
clone_from_version_id: active?.id ?? null,
});
toast.success(t("play.createDraftSuccess", { ns: "config", version: d.version_no }));
setCreatingDraftId(String(d.id));
setSelectedId(String(d.id));
setDetail(d);
setDraftRows(d.items.map((it) => ({ ...it })));
void refreshList();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("play.createDraftFailed", { ns: "config" }));
} finally {
setSaving(false);
}
}
function renderDisplayNameReadonly(row: PlayConfigItemRow) {
const name = row.display_name?.trim();
return <span>{name || row.play_code}</span>;
}
const activeHead = list.find((x) => x.status === "active");
async function handleDeleteVersion(row: ConfigVersionSummary) {
try {
await deletePlayConfigVersion(row.id);
toast.success(t("versionSwitcher.delete", { ns: "config" }));
await refreshList();
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("play.deleteFailed", { ns: "config" }));
throw e;
}
}
function requestRollback(row: ConfigVersionSummary) {
setRollbackTarget(row);
setRollbackOpen(true);
}
async function handleRollback() {
if (!rollbackTarget) {
return;
}
setSaving(true);
try {
const d = await postPlayConfigVersion({
reason: `rollback from v${rollbackTarget.version_no}`,
clone_from_version_id: rollbackTarget.id,
});
toast.success(
t("versionActions.rollbackSuccess", {
ns: "config",
fromVersion: rollbackTarget.version_no,
version: d.version_no,
}),
);
await refreshList();
setSelectedId(String(d.id));
setDetail(d);
setDraftRows(d.items.map((it) => ({ ...it })));
setRollbackOpen(false);
setRollbackTarget(null);
} catch (e) {
toast.error(e instanceof LotteryApiBizError ? e.message : t("versionActions.rollbackFailed", { ns: "config" }));
} finally {
setSaving(false);
}
}
return (
<ConfigDocPage
title={t("nav.items.plays", { ns: "config" })}
toolbar={
<ConfigDocToolbar
switcher={
<ConfigVersionSwitcher
versions={list}
selectedId={selectedId}
onSelectedIdChange={setSelectedId}
loading={loadingList}
sheetTitle={`${t("nav.items.plays", { ns: "config" })} ${t("versionSwitcher.sheetTitle", { ns: "config" })}`}
onDeleteVersion={handleDeleteVersion}
onRollbackVersion={requestRollback}
rollbackBusy={saving}
/>
}
actions={
<ConfigVersionActions
isDraft={isDraft}
canManage={canManage}
loadingList={loadingList}
loadingDetail={loadingDetail}
saving={saving}
onRefresh={() => void refreshList()}
onNewDraft={() => void handleNewDraft()}
onSaveDraft={() => void handleSaveDraft()}
onPublish={() =>
requestConfirm({
title: t("play.publishDialog.title", { ns: "config" }),
description: t("play.publishDialog.description", { ns: "config" }),
confirmLabel: t("play.publishDialog.confirm", { ns: "config" }),
confirmVariant: "destructive",
onConfirm: () => handlePublish(),
})
}
/>
}
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
}
/>
}
>
{detail ? (
<ConfigSection
title={t("play.filters.sectionTitle", { ns: "config" })}
description={isDraft ? t("play.filters.sectionDescription", { ns: "config" }) : undefined}
>
{!isDraft ? (
<div className="rounded-md border border-amber-200 bg-amber-50/70 px-3 py-2 text-xs text-amber-950">
{t("play.readOnlyDraftHint", { ns: "config" })}
</div>
) : null}
<div className="flex flex-col gap-3 lg:flex-row lg:items-end">
<div className="flex flex-1 flex-col gap-3 md:flex-row md:flex-wrap md:items-end">
<div className="flex min-w-0 flex-col gap-1.5 md:w-[320px]">
<span className="text-sm font-medium">{t("play.filters.keyword", { ns: "config" })}</span>
<Input
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder={t("play.filters.keywordPlaceholder", { ns: "config" })}
className="h-8"
/>
</div>
<div className="flex flex-col gap-1.5 md:w-[140px]">
<span className="text-sm font-medium">{t("play.filters.category", { ns: "config" })}</span>
<Select value={categoryFilter} onValueChange={(value) => setCategoryFilter(value ?? "all")}>
<SelectTrigger className="h-8">
<SelectValue>
{categoryFilter === "all"
? t("play.filters.allCategories", { ns: "config" })
: categoryLabel(categoryFilter)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("play.filters.allCategories", { ns: "config" })}</SelectItem>
{categoryOptions.map((category) => (
<SelectItem key={category} value={category}>
{categoryLabel(category)}
</SelectItem>
))}
<SelectItem value="uncategorized">
{t("play.filters.uncategorized", { ns: "config" })}
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5 md:w-[140px]">
<span className="text-sm font-medium">{t("play.filters.status", { ns: "config" })}</span>
<Select
value={statusFilter}
onValueChange={(value) => setStatusFilter(value as "all" | "enabled" | "disabled")}
>
<SelectTrigger className="h-8">
<SelectValue>
{statusFilter === "all"
? t("play.filters.allStatuses", { ns: "config" })
: statusFilter === "enabled"
? t("play.states.enabled", { ns: "config" })
: t("play.states.disabled", { ns: "config" })}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("play.filters.allStatuses", { ns: "config" })}</SelectItem>
<SelectItem value="enabled">{t("play.states.enabled", { ns: "config" })}</SelectItem>
<SelectItem value="disabled">{t("play.states.disabled", { ns: "config" })}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-end justify-start lg:flex-none">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
setKeyword("");
setCategoryFilter("all");
setStatusFilter("all");
}}
>
{t("play.filters.reset", { ns: "config" })}
</Button>
</div>
</div>
{isDraft ? (
<div className="space-y-2 border-t border-border/60 pt-3">
<div className="text-xs font-medium text-muted-foreground">
{t("play.batchSwitchesTitle", { ns: "config" })}
</div>
<ConfigChipGroup>
{batchSwitchStates.map((group) => {
const groupOn = group.allEnabled;
const isPartial =
group.total > 0 && group.enabledCount > 0 && group.enabledCount < group.total;
return (
<div
key={group.key}
className="flex items-center justify-between gap-3 rounded-lg border border-border/60 bg-card px-3 py-2"
>
<div className="min-w-0">
<p className="text-sm font-medium text-foreground">{group.label}</p>
<p className="text-xs text-muted-foreground">
{group.total > 0
? isPartial
? t("play.batchPartialEnabled", {
ns: "config",
enabledCount: group.enabledCount,
total: group.total,
})
: t("play.batchEnabledCount", {
ns: "config",
enabledCount: group.enabledCount,
total: group.total,
})
: t("play.noPlayTypes", { ns: "config" })}
</p>
</div>
<div className="flex shrink-0 items-center justify-center">
<Checkbox
checked={groupOn}
indeterminate={isPartial}
disabled={saving || group.total === 0 || confirmBusy}
aria-label={t("play.aria.batchGroupSwitch", {
ns: "config",
group: group.label,
})}
onCheckedChange={(checked) => {
const enable = checked === true;
const action = enable
? t("play.batchSwitchEnable", { ns: "config" })
: t("play.batchSwitchDisable", { ns: "config" });
requestConfirm({
title: t("play.batchSwitchConfirmTitle", { ns: "config", action }),
description: t("play.batchSwitchConfirmDescription", {
ns: "config",
action,
group: group.label,
count: group.total,
}),
confirmVariant: enable ? "default" : "destructive",
onConfirm: () => applyBatchSwitch(group, enable),
});
}}
/>
</div>
</div>
);
})}
</ConfigChipGroup>
</div>
) : null}
</ConfigSection>
) : null}
{error ? <p className="text-sm text-destructive">{error}</p> : null}
{loadingDetail ? (
<AdminLoadingState minHeight="6rem" className="py-6" />
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="text-center">{t("play.table.playCode", { ns: "config" })}</TableHead>
<TableHead className="w-[100px] text-center">{t("play.table.category", { ns: "config" })}</TableHead>
<TableHead className="w-[88px] text-center">{t("play.table.status", { ns: "config" })}</TableHead>
<TableHead className="w-36 text-center">{t("play.table.displayName", { ns: "config" })}</TableHead>
<TableHead className="w-24 text-center">{t("play.table.order", { ns: "config" })}</TableHead>
<TableHead className="w-[110px] text-center">{t("play.table.minBet", { ns: "config" })}</TableHead>
<TableHead className="w-[110px] text-center">{t("play.table.maxBet", { ns: "config" })}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{groupedRows.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="py-8 text-center text-sm text-muted-foreground">
{t("play.filters.empty", { ns: "config" })}
</TableCell>
</TableRow>
) : null}
{groupedRows.map(([groupKey, rows]) => (
<Fragment key={groupKey}>
<TableRow className="bg-muted/30">
<TableCell colSpan={7} className="py-2 text-sm font-medium text-foreground">
{categoryLabel(groupKey)}
<span className="ml-2 text-xs font-normal text-muted-foreground">
{t("play.filters.groupCount", { ns: "config", count: rows.length })}
</span>
</TableCell>
</TableRow>
{rows.map((row) => (
<TableRow key={row.play_code}>
<TableCell className="text-center font-mono text-sm">{row.play_code}</TableCell>
<TableCell className="text-center text-muted-foreground text-sm">
{row.category ? categoryLabel(row.category) : "—"}
</TableCell>
<TableCell className="text-center">
{isDraft ? (
<div className="flex justify-center">
<ConfirmableSwitch
checked={row.is_enabled}
confirmBusy={confirmBusy}
disabled={saving}
aria-label={t("play.aria.enablePlay", { ns: "config", playCode: row.play_code })}
onCheckedChange={(enabled) => {
const action = enabled
? t("play.toggleEnable", { ns: "config" })
: t("play.toggleDisable", { ns: "config" });
requestConfirm({
title: t("play.toggleConfirmTitle", {
ns: "config",
action,
playCode: row.play_code,
}),
description: t("play.toggleConfirmDescription", { ns: "config" }),
confirmVariant: enabled ? "default" : "destructive",
onConfirm: () => {
updateConfigRow(row.play_code, { is_enabled: enabled });
},
});
}}
/>
</div>
) : (
<div className="flex justify-center">
<AdminStatusBadge status={row.is_enabled ? "enabled" : "disabled"}>
{row.is_enabled
? t("play.states.enabled", { ns: "config" })
: t("play.states.disabled", { ns: "config" })}
</AdminStatusBadge>
</div>
)}
</TableCell>
<TableCell className="w-36 text-center">
{isDraft ? (
<Input
type="text"
className="mx-auto h-8 w-full max-w-[9rem] text-center text-sm"
disabled={saving}
value={row.display_name ?? ""}
placeholder={row.play_code}
onChange={(e) =>
updateConfigRow(row.play_code, { display_name: e.target.value })
}
onBlur={(e) => {
const trimmed = e.target.value.trim();
updateConfigRow(row.play_code, {
display_name: trimmed || row.play_code,
});
}}
/>
) : (
<ConfigReadonlyValue className="justify-center">
{renderDisplayNameReadonly(row)}
</ConfigReadonlyValue>
)}
</TableCell>
<TableCell className="w-24 text-center">
{isDraft ? (
<Input
type="text"
inputMode="numeric"
className="mx-auto h-8 w-16 font-mono tabular-nums text-center"
value={row.display_order}
disabled={saving}
placeholder={t("play.placeholders.displayOrder", { ns: "config" })}
onChange={(e) => {
const n = Number.parseInt(e.target.value, 10);
if (Number.isFinite(n)) {
updateConfigRow(row.play_code, { display_order: n });
}
}}
/>
) : (
<ConfigReadonlyValue mono className="justify-center">
{row.display_order}
</ConfigReadonlyValue>
)}
</TableCell>
<TableCell className="text-center">
{isDraft ? (
<Input
type="text"
inputMode="decimal"
className="mx-auto h-8 w-24 text-center font-mono tabular-nums"
disabled={saving}
value={formatAdminMinorDecimal(row.min_bet_amount, amountCurrencyCode)}
placeholder={t("play.placeholders.minBetAmount", { ns: "config" })}
onChange={(e) =>
updateConfigRow(row.play_code, {
min_bet_amount:
parseAdminMajorToMinor(e.target.value, amountCurrencyCode) ?? 0,
})
}
/>
) : (
<ConfigReadonlyValue mono className="justify-center">
{formatAdminMinorDecimal(row.min_bet_amount, amountCurrencyCode)}
</ConfigReadonlyValue>
)}
</TableCell>
<TableCell className="text-center">
{isDraft ? (
<Input
type="text"
inputMode="decimal"
className="mx-auto h-8 w-24 text-center font-mono tabular-nums"
disabled={saving}
value={formatAdminMinorDecimal(row.max_bet_amount, amountCurrencyCode)}
placeholder={t("play.placeholders.maxBetAmount", { ns: "config" })}
onChange={(e) =>
updateConfigRow(row.play_code, {
max_bet_amount:
parseAdminMajorToMinor(e.target.value, amountCurrencyCode) ?? 0,
})
}
/>
) : (
<ConfigReadonlyValue mono className="justify-center">
{formatAdminMinorDecimal(row.max_bet_amount, amountCurrencyCode)}
</ConfigReadonlyValue>
)}
</TableCell>
</TableRow>
))}
</Fragment>
))}
</TableBody>
</Table>
)}
<Dialog open={rollbackOpen} onOpenChange={setRollbackOpen}>
<DialogContent showCloseButton className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("versionActions.rollbackDialog.title", { ns: "config" })}</DialogTitle>
<DialogDescription>
{t("versionActions.rollbackDialog.description", {
ns: "config",
version: rollbackTarget?.version_no ?? "—",
})}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setRollbackOpen(false)}>
{t("actions.cancel", { ns: "adminUsers" })}
</Button>
<Button type="button" onClick={() => void handleRollback()} disabled={!rollbackTarget || saving}>
{t("versionActions.rollbackDialog.confirm", { ns: "config" })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ConfirmDialog />
</ConfigDocPage>
);
}