"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 { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page"; import { ConfigVersionToolbarMeta, ConfigVersionToolbarMetaEmphasis, } from "@/modules/config/config-version-toolbar-meta"; import { updateAdminSetting } from "@/api/admin-settings"; import { useAsyncEffect } from "@/hooks/use-async-effect"; import { useTranslationRef } from "@/hooks/use-translation-ref"; import { loadApplyRebateToPayoutSetting, setCachedApplyRebateToPayoutSetting, } from "@/lib/admin-settlement-settings-cache"; import { ensureAdminPlayTypesLoaded } from "@/lib/admin-play-types"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { AdminLoadingState, AdminLoadingInline, AdminTableLoadingRow } 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 { useConfirmAction } from "@/hooks/use-confirm-action"; import { adminHasAnyPermission } from "@/lib/admin-permissions"; import { pickDefaultConfigVersionId } from "@/lib/config-version-auto-pick"; import type { OddsConfigWorkspace } from "@/modules/config/use-odds-config-workspace"; import { PRD_REBATE_MANAGE, PRD_WALLET_RECONCILE_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 { ConfigWorkflowSection } from "@/modules/config/config-workflow-section"; import { inferRebatePercentFromDimension, rateToPercentUi, } from "@/modules/config/doc/odds-rebate-rates"; import { PRIZE_SCOPE_ORDER } from "@/modules/config/doc/prize-scopes"; const APPLY_REBATE_TO_PAYOUT_KEY = "settlement.apply_rebate_to_payout"; function dimensionDistinctPrimaryScopePercents( dim: 2 | 3 | 4, rows: OddsItemRow[], typeList: AdminPlayTypeRow[], ): Set { const codes = typeList .filter((t) => (t.dimension ?? 2) === dim) .map((t) => t.play_code) .sort((a, b) => a.localeCompare(b)); const scope = PRIZE_SCOPE_ORDER[0]; const percents = new Set(); for (const code of codes) { const hit = rows.find((r) => r.play_code === code && r.prize_scope === scope); if (hit) { percents.add(rateToPercentUi(String(hit.rebate_rate))); } } return percents; } type RebateConfigDocScreenProps = { embedded?: boolean; /** 合并页第 3 步卡片 */ mergedSection?: boolean; workspace?: OddsConfigWorkspace; versionId?: string; onVersionIdChange?: (id: string) => void; }; export function RebateConfigDocScreen({ embedded = false, mergedSection = false, workspace, versionId: controlledVersionId, onVersionIdChange, }: RebateConfigDocScreenProps) { const { t } = useTranslation(["config", "common"]); const tRef = useTranslationRef(["config", "common"]); const { request: requestConfirm, ConfirmDialog } = useConfirmAction(); const profile = useAdminProfile(); const canManage = adminHasAnyPermission(profile?.permissions, [PRD_REBATE_MANAGE]); const canEditWinEnjoy = adminHasAnyPermission(profile?.permissions, [ PRD_REBATE_MANAGE, PRD_WALLET_RECONCILE_MANAGE, ]); const [applyRebateToPayout, setApplyRebateToPayout] = useState(false); const [winEnjoyLoading, setWinEnjoyLoading] = useState(true); const [winEnjoySaving, setWinEnjoySaving] = useState(false); const formatDt = useAdminDateTimeFormatter(); const [types, setTypes] = useState([]); const [listRows, setListRows] = useState([]); const [internalSelectedId, setInternalSelectedId] = useState(""); const selectedId = workspace?.selectedId ?? controlledVersionId ?? internalSelectedId; const setSelectedId = workspace?.setSelectedId ?? onVersionIdChange ?? setInternalSelectedId; const [detail, setDetail] = useState(null); const [draftRows, setDraftRows] = useState([]); const [loading, setLoading] = useState(true); const [loadingDetail, setLoadingDetail] = useState(false); const [saving, setSaving] = useState(false); const resolvedTypes = workspace?.types ?? types; const resolvedList = workspace?.list ?? listRows; const resolvedDetail = workspace?.detail ?? detail; const resolvedDraftRows = workspace?.draftRows ?? draftRows; const setResolvedDraftRows = workspace?.setDraftRows ?? setDraftRows; const resolvedLoading = workspace ? workspace.loadingList || workspace.loadingTypes : loading; const resolvedLoadingDetail = workspace?.loadingDetail ?? loadingDetail; const [p2, setP2] = useState("0"); const [p3, setP3] = useState("0"); const [p4, setP4] = useState("0"); const [rollbackOpen, setRollbackOpen] = useState(false); const [rollbackTarget, setRollbackTarget] = useState(null); const refreshTypes = useCallback(async () => { try { setTypes(await ensureAdminPlayTypesLoaded(getAdminPlayTypes)); } catch (e) { toast.error( e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }), ); setTypes([]); } }, []); const refreshList = useCallback(async () => { try { const d = await getAllConfigVersions(getOddsVersions); setListRows(d.items); } catch (e) { toast.error( e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }), ); setListRows([]); } }, []); useAsyncEffect(() => { if (workspace) { return; } void (async () => { setLoading(true); await Promise.all([refreshTypes(), refreshList()]); setLoading(false); })(); }, [workspace]); useAsyncEffect(() => { void (async () => { setWinEnjoyLoading(true); try { setApplyRebateToPayout(await loadApplyRebateToPayoutSetting()); } catch (e) { toast.error( e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }), ); } finally { setWinEnjoyLoading(false); } })(); }, []); useEffect(() => { if (!workspace) { return; } setP2(inferRebatePercentFromDimension(2, workspace.draftRows, workspace.types)); setP3(inferRebatePercentFromDimension(3, workspace.draftRows, workspace.types)); setP4(inferRebatePercentFromDimension(4, workspace.draftRows, workspace.types)); }, [workspace?.draftRows, workspace?.types, workspace]); async function handleWinEnjoyChange(checked: boolean): Promise { if (!canEditWinEnjoy) { return; } setWinEnjoySaving(true); try { await updateAdminSetting(APPLY_REBATE_TO_PAYOUT_KEY, checked); setCachedApplyRebateToPayoutSetting(checked); setApplyRebateToPayout(checked); toast.success(t("rebate.winEnjoy.saveSuccess", { ns: "config" })); } catch (e) { toast.error(e instanceof LotteryApiBizError ? e.message : t("rebate.winEnjoy.saveFailed", { ns: "config" })); } finally { setWinEnjoySaving(false); } } const loadDetail = useCallback(async (id: number) => { setLoadingDetail(true); try { const typeList = await ensureAdminPlayTypesLoaded(getAdminPlayTypes); setTypes(typeList); const d = await getOddsVersion(id); const rows = d.items.map((it) => ({ ...it })); setDetail(d); setDraftRows(rows); setP2(inferRebatePercentFromDimension(2, rows, typeList)); setP3(inferRebatePercentFromDimension(3, rows, typeList)); setP4(inferRebatePercentFromDimension(4, rows, typeList)); } catch (e) { toast.error( e instanceof LotteryApiBizError ? e.message : tRef.current("errors.loadFailed", { ns: "common" }), ); setDetail(null); setDraftRows([]); } finally { setLoadingDetail(false); } }, []); useEffect(() => { if (workspace) { return; } if (listRows.length === 0) { if (selectedId !== "") { queueMicrotask(() => { setSelectedId(""); setDetail(null); setDraftRows([]); }); } return; } if (selectedId !== "" && listRows.some((x) => String(x.id) === selectedId)) { return; } queueMicrotask(() => { const pickId = pickDefaultConfigVersionId(listRows); if (pickId) { setSelectedId(pickId); } }); }, [listRows, selectedId, setSelectedId, workspace]); useEffect(() => { if (workspace) { return; } if (selectedId === "") { return; } const id = Number(selectedId); if (!Number.isFinite(id)) { return; } queueMicrotask(() => { void loadDetail(id); }); }, [selectedId, loadDetail, workspace]); const typesByCode = useMemo(() => { const m = new Map(); for (const row of resolvedTypes) { m.set(row.play_code, row); } return m; }, [resolvedTypes]); const rebateBulkPercentsMixed = useMemo(() => { if (resolvedTypes.length === 0 || resolvedDraftRows.length === 0) { return false; } for (const dim of [2, 3, 4] as const) { if (dimensionDistinctPrimaryScopePercents(dim, resolvedDraftRows, resolvedTypes).size > 1) { return true; } } return false; }, [resolvedTypes, resolvedDraftRows]); const selectedVersionSummary = useMemo( () => resolvedList.find((x) => String(x.id) === selectedId) ?? null, [resolvedList, selectedId], ); const isSelectedDetail = resolvedDetail !== null && String(resolvedDetail.id) === selectedId; const selectedStatus = isSelectedDetail ? resolvedDetail.status : selectedVersionSummary?.status; const isDraft = selectedStatus === "draft"; const canEditDraft = isDraft && canManage; function applyDimensionPercentsToRows(rows: OddsItemRow[]): OddsItemRow[] { const r2 = Number.parseFloat(p2); const r3 = Number.parseFloat(p3); const r4 = Number.parseFloat(p4); const rate2 = Number.isFinite(r2) ? r2 / 100 : 0; const rate3 = Number.isFinite(r3) ? r3 / 100 : 0; const rate4 = Number.isFinite(r4) ? r4 / 100 : 0; return rows.map((row) => { const t = typesByCode.get(row.play_code); const dim = (t?.dimension ?? 2) as 2 | 3 | 4; const rate = dim === 4 ? rate4 : dim === 3 ? rate3 : rate2; return { ...row, rebate_rate: String(rate) }; }); } async function handleSave() { if (!resolvedDetail || !canEditDraft) { return; } setSaving(true); try { const nextRows = applyDimensionPercentsToRows(resolvedDraftRows); const payload = nextRows.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(resolvedDetail.id, payload); const rows = d.items.map((it) => ({ ...it })); if (workspace) { workspace.applyDetail(d); } else { setDetail(d); setDraftRows(rows); } setP2(inferRebatePercentFromDimension(2, rows, resolvedTypes)); setP3(inferRebatePercentFromDimension(3, rows, resolvedTypes)); setP4(inferRebatePercentFromDimension(4, rows, resolvedTypes)); toast.success(t("versionActions.saveDraft", { ns: "config" })); void (workspace?.refreshList() ?? refreshList()); } catch (e) { toast.error(e instanceof LotteryApiBizError ? e.message : t("versionActions.saveFailed", { ns: "config" })); } finally { setSaving(false); } } async function handlePublish() { if (!resolvedDetail || !canEditDraft) { return; } setSaving(true); try { const d = await publishOddsVersion(resolvedDetail.id); const rows = d.items.map((it) => ({ ...it })); if (workspace) { workspace.applyDetail(d); } else { setDetail(d); setDraftRows(rows); } setP2(inferRebatePercentFromDimension(2, rows, resolvedTypes)); setP3(inferRebatePercentFromDimension(3, rows, resolvedTypes)); setP4(inferRebatePercentFromDimension(4, rows, resolvedTypes)); toast.success(t("rebate.publishSuccess", { ns: "config" })); void (workspace?.refreshList() ?? refreshList()); setSelectedId(String(d.id)); } catch (e) { toast.error(e instanceof LotteryApiBizError ? e.message : t("rebate.publishFailed", { ns: "config" })); } finally { setSaving(false); } } async function handleNewDraft() { setSaving(true); try { const active = resolvedList.find((x) => x.status === "active"); const d = await postOddsVersion({ reason: `rebate draft ${new Date().toISOString()}`, clone_from_version_id: active?.id ?? null, }); toast.success(t("rebate.createDraftSuccess", { ns: "config", version: d.version_no })); await (workspace?.refreshList() ?? refreshList()); setSelectedId(String(d.id)); const rows = d.items.map((it) => ({ ...it })); if (workspace) { workspace.applyDetail(d); } else { setDetail(d); setDraftRows(rows); } setP2(inferRebatePercentFromDimension(2, rows, resolvedTypes)); setP3(inferRebatePercentFromDimension(3, rows, resolvedTypes)); setP4(inferRebatePercentFromDimension(4, rows, resolvedTypes)); } catch (e) { toast.error(e instanceof LotteryApiBizError ? e.message : t("rebate.createDraftFailed", { ns: "config" })); } finally { setSaving(false); } } const activeHead = resolvedList.find((x) => x.status === "active"); function requestRollback(row: ConfigVersionSummary) { setRollbackTarget(row); setRollbackOpen(true); } 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("versionActions.rollbackSuccess", { ns: "config", fromVersion: rollbackTarget.version_no, version: d.version_no, }), ); await (workspace?.refreshList() ?? refreshList()); setSelectedId(String(d.id)); const rows = d.items.map((it) => ({ ...it })); if (workspace) { workspace.applyDetail(d); } else { setDetail(d); setDraftRows(rows); } setP2(inferRebatePercentFromDimension(2, rows, resolvedTypes)); setP3(inferRebatePercentFromDimension(3, rows, resolvedTypes)); setP4(inferRebatePercentFromDimension(4, rows, resolvedTypes)); setRollbackOpen(false); setRollbackTarget(null); } catch (e) { toast.error(e instanceof LotteryApiBizError ? e.message : t("versionActions.rollbackFailed", { ns: "config" })); } finally { setSaving(false); } } async function handleDeleteVersion(row: ConfigVersionSummary) { try { await deleteOddsVersion(row.id); toast.success(t("versionSwitcher.delete", { ns: "config" })); await (workspace?.refreshList() ?? refreshList()); } catch (e) { toast.error(e instanceof LotteryApiBizError ? e.message : t("rebate.deleteFailed", { ns: "config" })); throw e; } } const toolbarBlock = embedded ? null : ( } actions={ void refreshList()} onNewDraft={() => void handleNewDraft()} onSaveDraft={() => void handleSave()} onPublish={() => requestConfirm({ title: t("rebate.publishDialog.title", { ns: "config" }), description: t("rebate.publishDialog.description", { ns: "config" }), confirmLabel: t("rebate.publishDialog.confirm", { ns: "config" }), confirmVariant: "destructive", onConfirm: () => handlePublish(), }) } /> } footer={ embedded || !resolvedDetail ? null : ( {t("rebate.editingVersion", { ns: "config", version: resolvedDetail.version_no, status: resolvedDetail.status === "draft" ? t("versionStatus.draft", { ns: "config" }) : resolvedDetail.status === "active" ? t("versionStatus.active", { ns: "config" }) : t("versionStatus.archived", { ns: "config" }), })} {!isDraft ? ( {t("rebate.readOnlyHint", { ns: "config" })} ) : ( {t("versionToolbar.draftEditing", { ns: "config" })} )} ) } /> ); const fieldsBlock = ( <> {rebateBulkPercentsMixed ? ( {t("rebate.dimensionRatesMixedHint", { ns: "config" })} ) : null}
{canEditDraft ? ( setP2(e.target.value)} /> ) : ( {p2} )}
{canEditDraft ? ( setP3(e.target.value)} /> ) : ( {p3} )}
{canEditDraft ? ( setP4(e.target.value)} /> ) : ( {p4} )}

{t("rebate.winEnjoy.label", { ns: "config" })}

{t("rebate.winEnjoy.description", { ns: "config" })}

void handleWinEnjoyChange(value)} />

{t("rebate.winEnjoy.hint", { ns: "config" })}

{!embedded ? (
{t("rebate.effectiveTime", { ns: "config" })} {activeHead?.effective_at ? formatDt(activeHead.effective_at) : "—"}
) : null} {resolvedLoading || resolvedLoadingDetail ? ( ) : null} ); const rollbackDialog = ( {t("versionActions.rollbackDialog.title", { ns: "config" })} {t("versionActions.rollbackDialog.description", { ns: "config", version: rollbackTarget?.version_no ?? "—", })} ); if (embedded && mergedSection) { return ( <> {fieldsBlock} {rollbackDialog} ); } if (embedded) { return (
{fieldsBlock} {rollbackDialog}
); } return ( {fieldsBlock} {rollbackDialog} ); }