"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([]); const [selectedId, setSelectedId] = useState(""); const [detail, setDetail] = useState(null); const [draftRows, setDraftRows] = useState([]); const [loadingList, setLoadingList] = useState(true); const [loadingDetail, setLoadingDetail] = useState(false); const [saving, setSaving] = useState(false); const [creatingDraftId, setCreatingDraftId] = useState(null); const [rollbackOpen, setRollbackOpen] = useState(false); const [rollbackTarget, setRollbackTarget] = useState(null); const [error, setError] = useState(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(); 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(); 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) { 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 {name || row.play_code}; } 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 ( } actions={ 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 ? ( {activeHead ? ( {t("play.activeVersion", { ns: "config", version: activeHead.version_no })} {activeHead.effective_at ? ` · ${formatDt(activeHead.effective_at)}` : ""} ) : null} {!isDraft ? ( {t("play.readOnlyHint", { ns: "config" })} ) : activeHead ? ( {t("versionToolbar.draftEditing", { ns: "config" })} ) : null} ) : null } /> } > {detail ? ( {!isDraft ? (
{t("play.readOnlyDraftHint", { ns: "config" })}
) : null}
{t("play.filters.keyword", { ns: "config" })} setKeyword(e.target.value)} placeholder={t("play.filters.keywordPlaceholder", { ns: "config" })} className="h-8" />
{t("play.filters.category", { ns: "config" })}
{t("play.filters.status", { ns: "config" })}
{isDraft ? (
{t("play.batchSwitchesTitle", { ns: "config" })}
{batchSwitchStates.map((group) => { const groupOn = group.allEnabled; const isPartial = group.total > 0 && group.enabledCount > 0 && group.enabledCount < group.total; return (

{group.label}

{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" })}

{ 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), }); }} />
); })}
) : null}
) : null} {error ?

{error}

: null} {loadingDetail ? ( ) : ( {t("play.table.playCode", { ns: "config" })} {t("play.table.category", { ns: "config" })} {t("play.table.status", { ns: "config" })} {t("play.table.displayName", { ns: "config" })} {t("play.table.order", { ns: "config" })} {t("play.table.minBet", { ns: "config" })} {t("play.table.maxBet", { ns: "config" })} {groupedRows.length === 0 ? ( {t("play.filters.empty", { ns: "config" })} ) : null} {groupedRows.map(([groupKey, rows]) => ( {categoryLabel(groupKey)} {t("play.filters.groupCount", { ns: "config", count: rows.length })} {rows.map((row) => ( {row.play_code} {row.category ? categoryLabel(row.category) : "—"} {isDraft ? (
{ 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 }); }, }); }} />
) : (
{row.is_enabled ? t("play.states.enabled", { ns: "config" }) : t("play.states.disabled", { ns: "config" })}
)}
{isDraft ? ( 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, }); }} /> ) : ( {renderDisplayNameReadonly(row)} )} {isDraft ? ( { const n = Number.parseInt(e.target.value, 10); if (Number.isFinite(n)) { updateConfigRow(row.play_code, { display_order: n }); } }} /> ) : ( {row.display_order} )} {isDraft ? ( updateConfigRow(row.play_code, { min_bet_amount: parseAdminMajorToMinor(e.target.value, amountCurrencyCode) ?? 0, }) } /> ) : ( {formatAdminMinorDecimal(row.min_bet_amount, amountCurrencyCode)} )} {isDraft ? ( updateConfigRow(row.play_code, { max_bet_amount: parseAdminMajorToMinor(e.target.value, amountCurrencyCode) ?? 0, }) } /> ) : ( {formatAdminMinorDecimal(row.max_bet_amount, amountCurrencyCode)} )}
))}
))}
)} {t("versionActions.rollbackDialog.title", { ns: "config" })} {t("versionActions.rollbackDialog.description", { ns: "config", version: rollbackTarget?.version_no ?? "—", })}
); }