894 lines
35 KiB
TypeScript
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>
|
|
);
|
|
}
|