"use client"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { deleteOddsVersion, getAdminPlayTypes, getAllConfigVersions, getOddsVersion, getOddsVersions, postOddsVersion, publishOddsVersion, putOddsItems, } from "@/api/admin-config"; import { Button } from "@/components/ui/button"; import { ConfigChip, ConfigChipGroup } from "@/modules/config/config-chip-group"; import { ConfigContextBanner, ConfigContextEmphasis } from "@/modules/config/config-context-banner"; import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; 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 { resolveAdminPlayTypeDisplayName } from "@/lib/admin-play-types"; import { adminHasAnyPermission } from "@/lib/admin-permissions"; import { PRD_ODDS_MANAGE, PRD_REBATE_MANAGE } from "@/lib/admin-prd"; import { useAdminProfile } from "@/stores/admin-session"; import { LotteryApiBizError } from "@/types/api/errors"; import type { AdminPlayTypeRow, ConfigVersionSummary, OddsItemRow, OddsVersionDetail, } from "@/types/api/admin-config"; import { PRIZE_SCOPE_MULTIPLIER_HINT, PRIZE_SCOPE_ORDER, prizeScopeLabel, type PrizeScopeCode, } from "@/modules/config/doc/prize-scopes"; type CatTab = "all" | "d4" | "d3" | "d2"; function oddsMultiplierLabel(oddsValue: number): string { return (oddsValue / 10000).toFixed(4); } function parseOddsMultiplierInput(raw: string): number { const n = Number.parseFloat(raw); if (!Number.isFinite(n) || n < 0) { return 0; } const scaled = Math.round(n * 10000); return Number.isSafeInteger(scaled) ? scaled : 0; } function filterTypes(tab: CatTab, types: AdminPlayTypeRow[]): AdminPlayTypeRow[] { if (tab === "all") { return types; } const dim = tab === "d4" ? 4 : tab === "d3" ? 3 : 2; return types.filter((t) => t.dimension === dim); } type OddsConfigDocScreenProps = { /** 嵌入「赔率与回水」合并页时去掉外层 ConfigDocPage */ embedded?: boolean; }; export function OddsConfigDocScreen({ embedded = false }: OddsConfigDocScreenProps) { const { t, i18n } = useTranslation(["config", "adminUsers", "common"]); const profile = useAdminProfile(); const canManage = adminHasAnyPermission(profile?.permissions, [PRD_ODDS_MANAGE, PRD_REBATE_MANAGE]); const formatDt = useAdminDateTimeFormatter(); const [types, setTypes] = useState([]); const [list, setList] = useState([]); const [selectedId, setSelectedId] = useState(""); const [detail, setDetail] = useState(null); const [draftRows, setDraftRows] = useState([]); const [loadingTypes, setLoadingTypes] = useState(true); const [loadingList, setLoadingList] = useState(true); const [loadingDetail, setLoadingDetail] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [catTab, setCatTab] = useState("all"); /** User-selected play type. Empty means none selected yet and falls back to the first item in the category. */ const [playCode, setPlayCode] = useState(""); const [rollbackOpen, setRollbackOpen] = useState(false); const [rollbackTarget, setRollbackTarget] = useState(null); const [publishConfirmOpen, setPublishConfirmOpen] = useState(false); const [activeCompareRows, setActiveCompareRows] = useState([]); const refreshTypes = useCallback(async () => { setLoadingTypes(true); try { const d = await getAdminPlayTypes(); setTypes(d.items); } catch (e) { toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" })); setTypes([]); } finally { setLoadingTypes(false); } }, [t]); const refreshList = useCallback(async () => { setLoadingList(true); setError(null); try { const d = await getAllConfigVersions(getOddsVersions); setList(d.items); } catch (e) { const msg = e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" }); setError(msg); setList([]); } finally { setLoadingList(false); } }, [t]); useEffect(() => { queueMicrotask(() => { void refreshTypes(); void refreshList(); }); }, [refreshTypes, refreshList]); const loadDetail = useCallback(async (id: number) => { setLoadingDetail(true); try { const d = await getOddsVersion(id); setDetail(d); setDraftRows(d.items.map((it) => ({ ...it }))); } catch (e) { toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed", { ns: "common" })); setDetail(null); setDraftRows([]); } finally { setLoadingDetail(false); } }, [t]); useEffect(() => { if (list.length === 0 || selectedId !== "") { return; } queueMicrotask(() => { const drafts = list.filter((x) => x.status === "draft").sort((a, b) => b.id - a.id); const active = list.find((x) => x.status === "active"); const pick = drafts[0] ?? active ?? [...list].sort((a, b) => b.id - a.id)[0]; if (pick) { setSelectedId(String(pick.id)); } }); }, [list, selectedId]); useEffect(() => { if (selectedId === "") { return; } const id = Number(selectedId); if (!Number.isFinite(id)) { return; } queueMicrotask(() => { void loadDetail(id); }); }, [selectedId, loadDetail]); const sortedTypes = useMemo( () => [...types].sort((a, b) => a.sort_order - b.sort_order || a.play_code.localeCompare(b.play_code)), [types], ); const filteredTypes = useMemo(() => filterTypes(catTab, sortedTypes), [catTab, sortedTypes]); const resolvedPlayCode = useMemo(() => { if (filteredTypes.length === 0) { return ""; } if (playCode && filteredTypes.some((t) => t.play_code === playCode)) { return playCode; } return filteredTypes[0].play_code; }, [filteredTypes, playCode]); 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 canEditDraft = isDraft && canManage; const scopeRows = useMemo(() => { const rows: Partial> = {}; if (!resolvedPlayCode) { return rows; } for (const scope of PRIZE_SCOPE_ORDER) { const hit = draftRows.find((r) => r.play_code === resolvedPlayCode && r.prize_scope === scope); if (hit) { rows[scope] = hit; } } return rows; }, [draftRows, resolvedPlayCode]); const rebatePercentUi = useMemo(() => { const first = PRIZE_SCOPE_ORDER.map((s) => scopeRows[s]).find(Boolean); if (!first) { return "0"; } const n = Number.parseFloat(String(first.rebate_rate)); if (!Number.isFinite(n)) { return "0"; } return String(Math.round(n * 10000) / 100); }, [scopeRows]); function rowIndex(play_code: string, prize_scope: string): number { return draftRows.findIndex((r) => r.play_code === play_code && r.prize_scope === prize_scope); } function updateOddsRow(idx: number, patch: Partial) { setDraftRows((prev) => prev.map((r, i) => (i === idx ? { ...r, ...patch } : r))); } function updateOddsForScope(scope: PrizeScopeCode, patch: Partial) { const idx = rowIndex(resolvedPlayCode, scope); if (idx >= 0) { updateOddsRow(idx, patch); } } function setRebateForPlayPercent(percentStr: string) { const p = Number.parseFloat(percentStr); const rate = Number.isFinite(p) ? p / 100 : 0; setDraftRows((prev) => prev.map((r) => r.play_code === resolvedPlayCode ? { ...r, rebate_rate: String(rate) } : r, ), ); } async function handleSave() { if (!detail || !canEditDraft) { return; } setSaving(true); try { const payload = draftRows.map((r) => ({ play_code: r.play_code, prize_scope: r.prize_scope, odds_value: r.odds_value, rebate_rate: Number.parseFloat(String(r.rebate_rate)) || 0, commission_rate: Number.parseFloat(String(r.commission_rate)) || 0, currency_code: r.currency_code, extra_config_json: r.extra_config_json, })); const d = await putOddsItems(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("wallet.saveFailed", { ns: "config" })); } finally { setSaving(false); } } async function handlePublish() { if (!detail || !canEditDraft) { return; } setSaving(true); try { const d = await publishOddsVersion(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("odds.publishFailed", { ns: "config" })); } finally { setSaving(false); } } async function requestPublishConfirm() { if (!detail || !canEditDraft) { return; } const active = list.find((x) => x.status === "active"); if (active && active.id !== detail.id) { try { const d = await getOddsVersion(active.id); setActiveCompareRows(d.items); } catch { setActiveCompareRows([]); } } else { setActiveCompareRows([]); } setPublishConfirmOpen(true); } async function handleNewDraft() { setSaving(true); try { const active = list.find((x) => x.status === "active"); const d = await postOddsVersion({ reason: `draft ${new Date().toISOString()}`, clone_from_version_id: active?.id ?? null, }); toast.success(t("odds.createDraftSuccess", { ns: "config", version: d.version_no })); await refreshList(); setSelectedId(String(d.id)); setDetail(d); setDraftRows(d.items.map((it) => ({ ...it }))); } catch (e) { toast.error(e instanceof LotteryApiBizError ? e.message : t("odds.createDraftFailed", { ns: "config" })); } finally { setSaving(false); } } async function handleRollback() { if (!rollbackTarget) { return; } setSaving(true); try { const d = await postOddsVersion({ reason: `rollback from v${rollbackTarget.version_no}`, clone_from_version_id: rollbackTarget.id, }); toast.success( t("odds.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("odds.rollbackFailed", { ns: "config" })); } finally { setSaving(false); } } const activeHead = list.find((x) => x.status === "active"); async function handleDeleteVersion(row: ConfigVersionSummary) { try { await deleteOddsVersion(row.id); toast.success(t("versionSwitcher.delete", { ns: "config" })); await refreshList(); } catch (e) { toast.error(e instanceof LotteryApiBizError ? e.message : t("odds.deleteFailed", { ns: "config" })); throw e; } } function requestRollback(row: ConfigVersionSummary) { setRollbackTarget(row); setRollbackOpen(true); } const publishDiffRows = useMemo(() => { if (!detail) { return []; } const selectedPlay = resolvedPlayCode; return PRIZE_SCOPE_ORDER.map((scope) => { const next = draftRows.find((r) => r.play_code === selectedPlay && r.prize_scope === scope); const old = activeCompareRows.find((r) => r.play_code === selectedPlay && r.prize_scope === scope); return { scope, label: prizeScopeLabel(scope, t), oldValue: old?.odds_value ?? null, newValue: next?.odds_value ?? null, }; }); }, [activeCompareRows, detail, draftRows, resolvedPlayCode, t, i18n.language]); const catTabs: { id: CatTab; label: string }[] = [ { id: "all", label: t("odds.tabs.all", { ns: "config" }) }, { id: "d4", label: "4D" }, { id: "d3", label: "3D" }, { id: "d2", label: "2D" }, ]; const filtersBlock = (
{catTabs.map((tab) => ( setCatTab(tab.id)} > {tab.label} ))} {filteredTypes.length === 0 ? ( {t("odds.noPlayTypes", { ns: "config" })} ) : ( filteredTypes.map((type) => ( setPlayCode(type.play_code)} > {resolveAdminPlayTypeDisplayName(type.play_code, i18n.language, type)} )) )}
); const toolbarBlock = ( } actions={ void refreshList()} onNewDraft={() => void handleNewDraft()} onSaveDraft={() => void handleSave()} onPublish={() => void requestPublishConfirm()} /> } /> ); const contextBlock = embedded || !detail ? null : ( {t("odds.activeVersionPrefix", { ns: "config" })} {activeHead ? ( <> v{activeHead.version_no} {activeHead.effective_at ? ` · ${formatDt(activeHead.effective_at)}` : ""} ) : ( "—" )} {!isDraft ? ( <> {" "} — {t("odds.readOnlyHint", { ns: "config" })} ) : null} ); const mainBlock = ( <> {error ?

{error}

: null} {loadingDetail || loadingTypes ? (

{t("odds.loadingDetails", { ns: "config" })}

) : resolvedPlayCode ? (
{PRIZE_SCOPE_ORDER.map((scope) => { const row = scopeRows[scope]; const hint = embedded ? null : PRIZE_SCOPE_MULTIPLIER_HINT[scope]; const idx = row ? rowIndex(resolvedPlayCode, scope) : -1; return (
{row && idx >= 0 ? (
{canEditDraft ? ( updateOddsForScope(scope, { odds_value: parseOddsMultiplierInput(e.target.value), }) } /> ) : ( {oddsMultiplierLabel(row.odds_value)} )} {!embedded ? ( {t("odds.multiplier", { ns: "config", value: oddsMultiplierLabel(row.odds_value), currency: row.currency_code, })} ) : null}
) : (

{t("odds.missingScopeRow", { ns: "config", scope })}

)}
); })}
{canEditDraft ? ( setRebateForPlayPercent(e.target.value)} /> ) : ( {rebatePercentUi} )} {!embedded ? (

{t("odds.rebateRateHint", { ns: "config" })}

) : null}
) : null} ); const dialogs = ( <> {t("odds.rollbackDialog.title", { ns: "config" })} {t("odds.rollbackDialog.description", { ns: "config", version: rollbackTarget?.version_no ?? "—" })} {t("odds.publishDialog.title", { ns: "config" })} {t("odds.publishDialog.description", { ns: "config" })}
{t("odds.publishDialog.columns.prizeScope", { ns: "config" })} {t("odds.publishDialog.columns.currentActive", { ns: "config" })} {t("odds.publishDialog.columns.afterPublish", { ns: "config" })}
{publishDiffRows.map((row) => (
{row.label} {row.oldValue === null ? "—" : oddsMultiplierLabel(row.oldValue)} {row.newValue === null ? "—" : oddsMultiplierLabel(row.newValue)}
))}
); if (embedded) { return (
{filtersBlock} {toolbarBlock} {contextBlock} {mainBlock} {dialogs}
); } return ( {mainBlock} {dialogs} ); }