diff --git a/src/api/admin-jackpot.ts b/src/api/admin-jackpot.ts index 7e01393..3a05884 100644 --- a/src/api/admin-jackpot.ts +++ b/src/api/admin-jackpot.ts @@ -4,6 +4,8 @@ import { API_V1_PREFIX } from "./paths"; import type { AdminJackpotContributionsData, + AdminJackpotPoolAdjustmentsData, + AdminJackpotPoolAdjustResult, AdminJackpotPoolsData, AdminJackpotPayoutLogsData, AdminJackpotPoolRow, @@ -16,7 +18,6 @@ export async function getAdminJackpotPools(): Promise { } export type AdminJackpotPoolUpdateBody = Partial<{ - current_amount: number; contribution_rate: number; trigger_threshold: number; payout_rate: number; @@ -33,6 +34,22 @@ export async function putAdminJackpotPool( return adminRequest.put(`${A}/jackpot/pools/${poolId}`, body); } +export async function postAdminJackpotPoolAdjustment( + poolId: number, + body: { amount_delta: number; reason: string }, +): Promise { + return adminRequest.post(`${A}/jackpot/pools/${poolId}/adjustments`, body); +} + +export async function getAdminJackpotPoolAdjustments( + poolId: number, + q: { page?: number; per_page?: number } = {}, +): Promise { + return adminRequest.get(`${A}/jackpot/pools/${poolId}/adjustments`, { + params: q, + }); +} + export async function postAdminJackpotManualBurst( poolId: number, body: { draw_id: number }, diff --git a/src/api/admin-wallet.ts b/src/api/admin-wallet.ts index df458ee..cad492b 100644 --- a/src/api/admin-wallet.ts +++ b/src/api/admin-wallet.ts @@ -89,3 +89,13 @@ export async function manuallyProcessTransferOrder( remark ? { remark } : {}, ); } + +export async function completeTransferInCredit( + transferNo: string, + remark?: string, +): Promise { + return adminRequest.post( + `${A}/wallet/transfer-orders/${transferNo}/complete-credit`, + remark ? { remark } : {}, + ); +} diff --git a/src/api/index.ts b/src/api/index.ts index b1ddd6a..f87fe1e 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -4,9 +4,12 @@ export { getAdminRiskPools } from "@/api/admin-risk"; export { getAdminCaptcha, getAdminMe, postAdminLogin } from "@/api/admin-auth"; export { getAdminPing } from "@/api/admin-ping"; export { + completeTransferInCredit, getAdminPlayerWallets, getAdminTransferOrders, getAdminWalletTransactions, + manuallyProcessTransferOrder, + reverseTransferOrder, } from "@/api/admin-wallet"; export { getAdminReconcileJobItems, diff --git a/src/i18n/locales/en/config.json b/src/i18n/locales/en/config.json index f19b5a3..4aa7c28 100644 --- a/src/i18n/locales/en/config.json +++ b/src/i18n/locales/en/config.json @@ -313,6 +313,7 @@ }, "rebate": { "sectionHint": "Rebate rates are stored in the odds version; select or create an odds draft in the section above first.", + "lazyLoadHint": "Scroll down to the rebate section to load", "embeddedVersionHint": "Rebate shares the odds version line—switch versions in the Odds section above.", "sheetDescription": "Rebate is stored in the odds draft version and shares the same version set as odds.", "publishLabel": "Publish", diff --git a/src/i18n/locales/en/jackpot.json b/src/i18n/locales/en/jackpot.json index 1fb4789..7ebba25 100644 --- a/src/i18n/locales/en/jackpot.json +++ b/src/i18n/locales/en/jackpot.json @@ -14,6 +14,21 @@ "noPoolData": "No pool data", "displayBalance": "Display balance {{amount}}", "currentAmount": "Current pool balance (minor unit)", + "balanceAdjustmentTitle": "Balance adjustment", + "balanceAdjustmentHint": "A reason is required; each change is recorded in the adjustment ledger. Balance cannot be edited via Save.", + "adjustmentDirection": "Direction", + "adjustmentIncrease": "Increase", + "adjustmentDecrease": "Decrease", + "adjustmentAmount": "Amount (major units)", + "adjustmentReason": "Reason (required)", + "submitAdjustment": "Submit adjustment", + "adjustmentSuccess": "Pool balance adjusted", + "adjustmentFailed": "Adjustment failed", + "adjustmentAmountInvalid": "Enter a valid adjustment amount", + "adjustmentReasonRequired": "Reason must be at least 3 characters", + "confirmAdjustmentTitle": "Confirm pool balance adjustment?", + "confirmAdjustmentDescription": "This writes a ledger entry and updates the pool balance. Verify amount and reason.", + "recentAdjustments": "Recent adjustments", "contributionRate": "Contribution rate 0-1", "triggerThreshold": "Burst threshold (minor unit)", "payoutRate": "Burst payout rate 0-1", diff --git a/src/i18n/locales/en/wallet.json b/src/i18n/locales/en/wallet.json index 7c6621b..db1e642 100644 --- a/src/i18n/locales/en/wallet.json +++ b/src/i18n/locales/en/wallet.json @@ -41,15 +41,20 @@ "requestTime": "Requested at", "finishedTime": "Finished at", "actions": "Actions", + "actionsMenuAriaLabel": "Transfer order actions", "reverse": "Reverse", + "completeCredit": "Complete credit", "manualProcess": "Manual process", "processing": "Processing…", "reverseSuccess": "Reversed successfully", + "completeCreditSuccess": "Transfer-in credited successfully", "manualProcessSuccess": "Manually processed successfully", "actionFailed": "Action failed", "confirm": { "reverseTitle": "Confirm reverse transfer?", "reverseDescription": "Reverse order {{transferNo}}. This may affect player wallet balance.", + "completeCreditTitle": "Confirm complete transfer-in credit?", + "completeCreditDescription": "When the main site has already debited, credit lottery wallet for order {{transferNo}} and mark it successful.", "manualProcessTitle": "Confirm manual process?", "manualProcessDescription": "Mark order {{transferNo}} as manually processed without automatic wallet adjustment." }, diff --git a/src/i18n/locales/ne/config.json b/src/i18n/locales/ne/config.json index 7442fef..db5c4ae 100644 --- a/src/i18n/locales/ne/config.json +++ b/src/i18n/locales/ne/config.json @@ -313,6 +313,7 @@ }, "rebate": { "sectionHint": "रिबेट दर अड्स संस्करणमा लेखिन्छ; पहिले माथिको «बाधा» खण्डमा ड्राफ्ट छान्नुहोस्।", + "lazyLoadHint": "रिबेट खण्डमा स्क्रोल गर्दा लोड हुन्छ", "embeddedVersionHint": "रिबेट माथिको बाधा संस्करण लाइन साझा गर्छ—संस्करण त्यहीँबाट बदल्नुहोस्।", "sheetDescription": "रिबेट अड्स ड्राफ्ट संस्करणमा राखिन्छ र अड्ससँग एउटै संस्करण सेट साझा गर्छ।", "publishLabel": "प्रकाशन", diff --git a/src/i18n/locales/ne/jackpot.json b/src/i18n/locales/ne/jackpot.json index 557c9ab..3f5ee86 100644 --- a/src/i18n/locales/ne/jackpot.json +++ b/src/i18n/locales/ne/jackpot.json @@ -14,6 +14,21 @@ "noPoolData": "पूल डाटा छैन", "displayBalance": "प्रदर्शित ब्यालेन्स {{amount}}", "currentAmount": "हालको पूल ब्यालेन्स (सानो एकाइ)", + "balanceAdjustmentTitle": "ब्यालेन्स समायोजन", + "balanceAdjustmentHint": "कारण अनिवार्य; प्रत्येक परिवर्तन समायोजन लेजरमा लेखिन्छ। Save बाट सिधै ब्यालेन्स मिलाउन मिल्दैन।", + "adjustmentDirection": "दिशा", + "adjustmentIncrease": "बढाउनु", + "adjustmentDecrease": "घटाउनु", + "adjustmentAmount": "समायोजन रकम (मुख्य एकाइ)", + "adjustmentReason": "कारण (अनिवार्य)", + "submitAdjustment": "समायोजन पेश गर्नुहोस्", + "adjustmentSuccess": "पूल ब्यालेन्स समायोजन भयो", + "adjustmentFailed": "समायोजन असफल", + "adjustmentAmountInvalid": "मान्य समायोजन रकम लेख्नुहोस्", + "adjustmentReasonRequired": "कारण कम्तीमा ३ अक्षर", + "confirmAdjustmentTitle": "पूल ब्यालेन्स समायोजन पक्का गर्ने?", + "confirmAdjustmentDescription": "यसले लेजर प्रविष्टि लेख्छ र पूल ब्यालेन्स अद्यावधिक गर्छ। रकम र कारण जाँच गर्नुहोस्।", + "recentAdjustments": "भर्खरका समायोजन", "contributionRate": "योगदान अनुपात 0-1", "triggerThreshold": "बर्स्ट थ्रेसहोल्ड (सानो एकाइ)", "payoutRate": "बर्स्ट भुक्तानी अनुपात 0-1", diff --git a/src/i18n/locales/ne/wallet.json b/src/i18n/locales/ne/wallet.json index 2c0ac75..e20033a 100644 --- a/src/i18n/locales/ne/wallet.json +++ b/src/i18n/locales/ne/wallet.json @@ -41,12 +41,23 @@ "requestTime": "अनुरोध समय", "finishedTime": "समाप्त समय", "actions": "कार्य", + "actionsMenuAriaLabel": "ट्रान्सफर अर्डर कार्य मेनु", "reverse": "रिभर्स", + "completeCredit": "क्रेडिट पूरा गर्नुहोस्", "manualProcess": "म्यानुअल प्रक्रिया", "processing": "प्रक्रियामा…", "reverseSuccess": "रिभर्स सफल भयो", + "completeCreditSuccess": "ट्रान्सफर-इन क्रेडिट सफल भयो", "manualProcessSuccess": "म्यानुअल प्रक्रिया सफल भयो", "actionFailed": "कार्य असफल भयो", + "confirm": { + "reverseTitle": "ट्रान्सफर रिभर्स पुष्टि गर्ने?", + "reverseDescription": "अर्डर {{transferNo}} रिभर्स गर्नेछ, खेलाडी वालेट प्रभावित हुन सक्छ।", + "completeCreditTitle": "ट्रान्सफर-इन क्रेडिट पूरा गर्ने?", + "completeCreditDescription": "मुख्य साइटले पहिले नै कटौती गरेको छ भने, अर्डर {{transferNo}} को लागि लटरी वालेटमा क्रेडिट गरी सफल चिन्ह लगाउँछ।", + "manualProcessTitle": "म्यानुअल प्रक्रिया पुष्टि?", + "manualProcessDescription": "अर्डर {{transferNo}} म्यानुअल प्रक्रिया भएको चिन्ह लगाउँछ, वालेट स्वचालित मिलाउँदैन।" + }, "txnNo": "कारोबार नं.", "bizType": "व्यवसाय प्रकार", "type": "प्रकार", diff --git a/src/i18n/locales/zh/config.json b/src/i18n/locales/zh/config.json index f53990c..093cfae 100644 --- a/src/i18n/locales/zh/config.json +++ b/src/i18n/locales/zh/config.json @@ -313,6 +313,7 @@ }, "rebate": { "sectionHint": "回水比例写入赔率版本;请先在上方选择或创建赔率草稿。", + "lazyLoadHint": "向下滚动至回水区域后加载", "embeddedVersionHint": "回水与上方赔率共用版本线,请在「赔率」区块切换版本。", "sheetDescription": "回水配置存放在赔率草稿版本中,与赔率共用同一套版本记录。", "publishLabel": "发布", diff --git a/src/i18n/locales/zh/jackpot.json b/src/i18n/locales/zh/jackpot.json index c1a42cd..703c9eb 100644 --- a/src/i18n/locales/zh/jackpot.json +++ b/src/i18n/locales/zh/jackpot.json @@ -14,6 +14,21 @@ "noPoolData": "暂无奖池数据", "displayBalance": "展示余额 {{amount}}", "currentAmount": "当前池余额(最小单位)", + "balanceAdjustmentTitle": "余额调整", + "balanceAdjustmentHint": "须填写原因并写入调整流水;不可在「保存」中直接改余额。", + "adjustmentDirection": "方向", + "adjustmentIncrease": "增加", + "adjustmentDecrease": "减少", + "adjustmentAmount": "调整金额(主币单位)", + "adjustmentReason": "调整原因(必填)", + "submitAdjustment": "提交余额调整", + "adjustmentSuccess": "余额调整已入账", + "adjustmentFailed": "余额调整失败", + "adjustmentAmountInvalid": "请填写有效的调整金额", + "adjustmentReasonRequired": "调整原因至少 3 个字符", + "confirmAdjustmentTitle": "确认提交奖池余额调整?", + "confirmAdjustmentDescription": "将写入调整流水并更新当前池余额,请确认金额与原因无误。", + "recentAdjustments": "最近调整记录", "contributionRate": "蓄水比例 0–1", "triggerThreshold": "爆池阈值(最小单位)", "payoutRate": "爆池派彩比例 0–1", @@ -26,7 +41,7 @@ "saving": "保存中…", "save": "保存", "confirmSavePoolTitle": "确认保存奖池配置?", - "confirmSavePoolDescription": "将更新蓄水比例、阈值、派彩比例等参数,可能影响后续 Jackpot 行为。", + "confirmSavePoolDescription": "将更新蓄水比例、阈值、派彩比例等参数(不含池余额);余额请使用「余额调整」。", "manualBurstDrawId": "手动爆池期号 ID", "manualBurstHint": "仅超级管理员可在紧急情况下触发;须该期已开奖结算且存在头奖中奖注单,按当前「爆池派彩比例」释放并派彩入账。", "manualBurstConfirmTitle": "确认手动爆池?", diff --git a/src/i18n/locales/zh/wallet.json b/src/i18n/locales/zh/wallet.json index c9709f9..67f57b2 100644 --- a/src/i18n/locales/zh/wallet.json +++ b/src/i18n/locales/zh/wallet.json @@ -41,15 +41,20 @@ "requestTime": "请求时间", "finishedTime": "完成时间", "actions": "操作", + "actionsMenuAriaLabel": "转账单操作菜单", "reverse": "冲正", + "completeCredit": "补完成入账", "manualProcess": "人工处理", "processing": "处理中…", "reverseSuccess": "冲正成功", + "completeCreditSuccess": "补入账成功", "manualProcessSuccess": "人工处理成功", "actionFailed": "操作失败", "confirm": { "reverseTitle": "确认冲正转账单?", "reverseDescription": "将对单号 {{transferNo}} 执行冲正,可能影响玩家钱包余额。", + "completeCreditTitle": "确认补完成转入入账?", + "completeCreditDescription": "主站已扣款时,将为单号 {{transferNo}} 在彩票钱包补记转入并标记成功。", "manualProcessTitle": "确认人工处理?", "manualProcessDescription": "将标记单号 {{transferNo}} 为已人工处理,不会自动调整钱包。" }, diff --git a/src/modules/config/doc/odds-config-doc-screen.tsx b/src/modules/config/doc/odds-config-doc-screen.tsx index 755eb54..14ab694 100644 --- a/src/modules/config/doc/odds-config-doc-screen.tsx +++ b/src/modules/config/doc/odds-config-doc-screen.tsx @@ -42,6 +42,7 @@ import { PRD_ODDS_MANAGE, PRD_REBATE_MANAGE } from "@/lib/admin-prd"; import { useAdminProfile } from "@/stores/admin-session"; import { LotteryApiBizError } from "@/types/api/errors"; import { pickDefaultConfigVersionId } from "@/lib/config-version-auto-pick"; +import type { OddsConfigWorkspace } from "@/modules/config/use-odds-config-workspace"; import type { AdminPlayTypeRow, ConfigVersionSummary, @@ -82,13 +83,16 @@ function filterTypes(tab: CatTab, types: AdminPlayTypeRow[]): AdminPlayTypeRow[] type OddsConfigDocScreenProps = { /** 嵌入「赔率与回水」合并页时去掉外层 ConfigDocPage */ embedded?: boolean; - /** 与回水分区共用版本选择(合并页) */ + /** 合并页共享数据层(避免与回水区块重复拉取版本详情) */ + workspace?: OddsConfigWorkspace; + /** 与回水分区共用版本选择(无 workspace 时) */ versionId?: string; onVersionIdChange?: (id: string) => void; }; export function OddsConfigDocScreen({ embedded = false, + workspace, versionId: controlledVersionId, onVersionIdChange, }: OddsConfigDocScreenProps) { @@ -99,8 +103,8 @@ export function OddsConfigDocScreen({ const [types, setTypes] = useState([]); const [list, setList] = useState([]); const [internalSelectedId, setInternalSelectedId] = useState(""); - const selectedId = controlledVersionId ?? internalSelectedId; - const setSelectedId = onVersionIdChange ?? setInternalSelectedId; + const selectedId = workspace?.selectedId ?? controlledVersionId ?? internalSelectedId; + const setSelectedId = workspace?.setSelectedId ?? onVersionIdChange ?? setInternalSelectedId; const [detail, setDetail] = useState(null); const [draftRows, setDraftRows] = useState([]); const [loadingTypes, setLoadingTypes] = useState(true); @@ -109,6 +113,16 @@ export function OddsConfigDocScreen({ const [saving, setSaving] = useState(false); const [error, setError] = useState(null); + const resolvedTypes = workspace?.types ?? types; + const resolvedList = workspace?.list ?? list; + const resolvedDetail = workspace?.detail ?? detail; + const resolvedDraftRows = workspace?.draftRows ?? draftRows; + const setResolvedDraftRows = workspace?.setDraftRows ?? setDraftRows; + const resolvedLoadingTypes = workspace?.loadingTypes ?? loadingTypes; + const resolvedLoadingList = workspace?.loadingList ?? loadingList; + const resolvedLoadingDetail = workspace?.loadingDetail ?? loadingDetail; + const resolvedError = workspace?.error ?? error; + 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(""); @@ -147,11 +161,14 @@ export function OddsConfigDocScreen({ }, [t]); useEffect(() => { + if (workspace) { + return; + } queueMicrotask(() => { void refreshTypes(); void refreshList(); }); - }, [refreshTypes, refreshList]); + }, [refreshTypes, refreshList, workspace]); const loadDetail = useCallback(async (id: number) => { setLoadingDetail(true); @@ -169,6 +186,9 @@ export function OddsConfigDocScreen({ }, [t]); useEffect(() => { + if (workspace) { + return; + } if (list.length === 0) { if (selectedId !== "") { queueMicrotask(() => { @@ -188,9 +208,12 @@ export function OddsConfigDocScreen({ setSelectedId(pickId); } }); - }, [list, selectedId, setSelectedId]); + }, [list, selectedId, setSelectedId, workspace]); useEffect(() => { + if (workspace) { + return; + } if (selectedId === "") { return; } @@ -201,11 +224,14 @@ export function OddsConfigDocScreen({ queueMicrotask(() => { void loadDetail(id); }); - }, [selectedId, loadDetail]); + }, [selectedId, loadDetail, workspace]); const sortedTypes = useMemo( - () => [...types].sort((a, b) => a.sort_order - b.sort_order || a.play_code.localeCompare(b.play_code)), - [types], + () => + [...resolvedTypes].sort( + (a, b) => a.sort_order - b.sort_order || a.play_code.localeCompare(b.play_code), + ), + [resolvedTypes], ); const filteredTypes = useMemo(() => filterTypes(catTab, sortedTypes), [catTab, sortedTypes]); @@ -221,11 +247,11 @@ export function OddsConfigDocScreen({ }, [filteredTypes, playCode]); const selectedVersionSummary = useMemo( - () => list.find((x) => String(x.id) === selectedId) ?? null, - [list, selectedId], + () => resolvedList.find((x) => String(x.id) === selectedId) ?? null, + [resolvedList, selectedId], ); - const isSelectedDetail = detail !== null && String(detail.id) === selectedId; - const selectedStatus = isSelectedDetail ? detail.status : selectedVersionSummary?.status; + const isSelectedDetail = resolvedDetail !== null && String(resolvedDetail.id) === selectedId; + const selectedStatus = isSelectedDetail ? resolvedDetail.status : selectedVersionSummary?.status; const isDraft = selectedStatus === "draft"; const canEditDraft = isDraft && canManage; @@ -234,14 +260,17 @@ export function OddsConfigDocScreen({ 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; + for (const row of resolvedDraftRows) { + if (row.play_code !== resolvedPlayCode) { + continue; + } + const scope = row.prize_scope as PrizeScopeCode; + if (PRIZE_SCOPE_ORDER.includes(scope)) { + rows[scope] = row; } } return rows; - }, [draftRows, resolvedPlayCode]); + }, [resolvedDraftRows, resolvedPlayCode]); const rebatePercentUi = useMemo(() => { const first = PRIZE_SCOPE_ORDER.map((s) => scopeRows[s]).find(Boolean); @@ -256,11 +285,11 @@ export function OddsConfigDocScreen({ }, [scopeRows]); function rowIndex(play_code: string, prize_scope: string): number { - return draftRows.findIndex((r) => r.play_code === play_code && r.prize_scope === prize_scope); + return resolvedDraftRows.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))); + setResolvedDraftRows((prev) => prev.map((r, i) => (i === idx ? { ...r, ...patch } : r))); } function updateOddsForScope(scope: PrizeScopeCode, patch: Partial) { @@ -273,7 +302,7 @@ export function OddsConfigDocScreen({ function setRebateForPlayPercent(percentStr: string) { const p = Number.parseFloat(percentStr); const rate = Number.isFinite(p) ? p / 100 : 0; - setDraftRows((prev) => + setResolvedDraftRows((prev) => prev.map((r) => r.play_code === resolvedPlayCode ? { ...r, rebate_rate: String(rate) } : r, ), @@ -281,12 +310,12 @@ export function OddsConfigDocScreen({ } async function handleSave() { - if (!detail || !canEditDraft) { + if (!resolvedDetail || !canEditDraft) { return; } setSaving(true); try { - const payload = draftRows.map((r) => ({ + const payload = resolvedDraftRows.map((r) => ({ play_code: r.play_code, prize_scope: r.prize_scope, odds_value: r.odds_value, @@ -295,11 +324,15 @@ export function OddsConfigDocScreen({ 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 }))); + const d = await putOddsItems(resolvedDetail.id, payload); + if (workspace) { + workspace.applyDetail(d); + } else { + setDetail(d); + setDraftRows(d.items.map((it) => ({ ...it }))); + } toast.success(t("versionActions.saveDraft", { ns: "config" })); - void refreshList(); + void (workspace?.refreshList() ?? refreshList()); } catch (e) { toast.error(e instanceof LotteryApiBizError ? e.message : t("versionActions.saveFailed", { ns: "config" })); } finally { @@ -308,16 +341,20 @@ export function OddsConfigDocScreen({ } async function handlePublish() { - if (!detail || !canEditDraft) { + if (!resolvedDetail || !canEditDraft) { return; } setSaving(true); try { - const d = await publishOddsVersion(detail.id); - setDetail(d); - setDraftRows(d.items.map((it) => ({ ...it }))); + const d = await publishOddsVersion(resolvedDetail.id); + if (workspace) { + workspace.applyDetail(d); + } else { + setDetail(d); + setDraftRows(d.items.map((it) => ({ ...it }))); + } toast.success(t("versionActions.publishCurrent", { ns: "config" })); - void refreshList(); + void (workspace?.refreshList() ?? refreshList()); setSelectedId(String(d.id)); } catch (e) { toast.error(e instanceof LotteryApiBizError ? e.message : t("odds.publishFailed", { ns: "config" })); @@ -327,11 +364,11 @@ export function OddsConfigDocScreen({ } async function requestPublishConfirm() { - if (!detail || !canEditDraft) { + if (!resolvedDetail || !canEditDraft) { return; } - const active = list.find((x) => x.status === "active"); - if (active && active.id !== detail.id) { + const active = resolvedList.find((x) => x.status === "active"); + if (active && active.id !== resolvedDetail.id) { try { const d = await getOddsVersion(active.id); setActiveCompareRows(d.items); @@ -347,16 +384,20 @@ export function OddsConfigDocScreen({ async function handleNewDraft() { setSaving(true); try { - const active = list.find((x) => x.status === "active"); + const active = resolvedList.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(); + await (workspace?.refreshList() ?? refreshList()); setSelectedId(String(d.id)); - setDetail(d); - setDraftRows(d.items.map((it) => ({ ...it }))); + if (workspace) { + workspace.applyDetail(d); + } else { + setDetail(d); + setDraftRows(d.items.map((it) => ({ ...it }))); + } } catch (e) { toast.error(e instanceof LotteryApiBizError ? e.message : t("odds.createDraftFailed", { ns: "config" })); } finally { @@ -381,10 +422,14 @@ export function OddsConfigDocScreen({ version: d.version_no, }), ); - await refreshList(); + await (workspace?.refreshList() ?? refreshList()); setSelectedId(String(d.id)); - setDetail(d); - setDraftRows(d.items.map((it) => ({ ...it }))); + if (workspace) { + workspace.applyDetail(d); + } else { + setDetail(d); + setDraftRows(d.items.map((it) => ({ ...it }))); + } setRollbackOpen(false); setRollbackTarget(null); } catch (e) { @@ -394,13 +439,13 @@ export function OddsConfigDocScreen({ } } - const activeHead = list.find((x) => x.status === "active"); + const activeHead = resolvedList.find((x) => x.status === "active"); async function handleDeleteVersion(row: ConfigVersionSummary) { try { await deleteOddsVersion(row.id); toast.success(t("versionSwitcher.delete", { ns: "config" })); - await refreshList(); + await (workspace?.refreshList() ?? refreshList()); } catch (e) { toast.error(e instanceof LotteryApiBizError ? e.message : t("odds.deleteFailed", { ns: "config" })); throw e; @@ -413,14 +458,14 @@ export function OddsConfigDocScreen({ } const publishDiffRows = useMemo(() => { - if (!detail) { + if (!resolvedDetail) { return []; } const selectedPlay = resolvedPlayCode; return PRIZE_SCOPE_ORDER.map((scope) => { - const next = draftRows.find((r) => r.play_code === selectedPlay && r.prize_scope === scope); + const next = resolvedDraftRows.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, @@ -429,7 +474,7 @@ export function OddsConfigDocScreen({ newValue: next?.odds_value ?? null, }; }); - }, [activeCompareRows, detail, draftRows, resolvedPlayCode, t, i18n.language]); + }, [activeCompareRows, resolvedDetail, resolvedDraftRows, resolvedPlayCode, t, i18n.language]); const catTabs: { id: CatTab; label: string }[] = [ { id: "all", label: t("odds.tabs.all", { ns: "config" }) }, @@ -477,10 +522,10 @@ export function OddsConfigDocScreen({ className={embedded ? "rounded-none border-0 shadow-none" : undefined} switcher={ void refreshList()} onNewDraft={() => void handleNewDraft()} @@ -502,7 +547,7 @@ export function OddsConfigDocScreen({ /> } footer={ - !detail ? null : ( + !resolvedDetail ? null : ( {t("odds.activeVersionPrefix", { ns: "config" })} @@ -530,9 +575,9 @@ export function OddsConfigDocScreen({ const mainBlock = ( <> - {error ?

{error}

: null} + {resolvedError ?

{resolvedError}

: null} - {loadingDetail || loadingTypes ? ( + {resolvedLoadingDetail || resolvedLoadingTypes ? (

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

diff --git a/src/modules/config/doc/rebate-config-doc-screen.tsx b/src/modules/config/doc/rebate-config-doc-screen.tsx index 7ef8224..bbef94d 100644 --- a/src/modules/config/doc/rebate-config-doc-screen.tsx +++ b/src/modules/config/doc/rebate-config-doc-screen.tsx @@ -40,6 +40,7 @@ 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"; @@ -100,12 +101,14 @@ function dimensionDistinctPrimaryScopePercents( type RebateConfigDocScreenProps = { embedded?: boolean; + workspace?: OddsConfigWorkspace; versionId?: string; onVersionIdChange?: (id: string) => void; }; export function RebateConfigDocScreen({ embedded = false, + workspace, versionId: controlledVersionId, onVersionIdChange, }: RebateConfigDocScreenProps) { @@ -125,14 +128,22 @@ export function RebateConfigDocScreen({ const [listRows, setListRows] = useState([]); const [internalSelectedId, setInternalSelectedId] = useState(""); - const selectedId = controlledVersionId ?? internalSelectedId; - const setSelectedId = onVersionIdChange ?? setInternalSelectedId; + 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"); @@ -173,13 +184,16 @@ export function RebateConfigDocScreen({ }, [t]); useEffect(() => { + if (workspace) { + return; + } queueMicrotask(async () => { setLoading(true); await refreshTypes(); await refreshList(); setLoading(false); }); - }, [refreshTypes, refreshList]); + }, [refreshTypes, refreshList, workspace]); useEffect(() => { queueMicrotask(() => { @@ -187,6 +201,15 @@ export function RebateConfigDocScreen({ }); }, [loadWinEnjoySetting]); + useEffect(() => { + if (!workspace) { + return; + } + setP2(inferPercentFrom(2, workspace.draftRows, workspace.types)); + setP3(inferPercentFrom(3, workspace.draftRows, workspace.types)); + setP4(inferPercentFrom(4, workspace.draftRows, workspace.types)); + }, [workspace?.draftRows, workspace?.types, workspace]); + async function handleWinEnjoyChange(checked: boolean): Promise { if (!canEditWinEnjoy) { return; @@ -226,6 +249,9 @@ export function RebateConfigDocScreen({ }, [t]); useEffect(() => { + if (workspace) { + return; + } if (listRows.length === 0) { if (selectedId !== "") { queueMicrotask(() => { @@ -245,9 +271,12 @@ export function RebateConfigDocScreen({ setSelectedId(pickId); } }); - }, [listRows, selectedId, setSelectedId]); + }, [listRows, selectedId, setSelectedId, workspace]); useEffect(() => { + if (workspace) { + return; + } if (selectedId === "") { return; } @@ -258,34 +287,34 @@ export function RebateConfigDocScreen({ queueMicrotask(() => { void loadDetail(id); }); - }, [selectedId, loadDetail]); + }, [selectedId, loadDetail, workspace]); const typesByCode = useMemo(() => { const m = new Map(); - for (const t of types) { - m.set(t.play_code, t); + for (const row of resolvedTypes) { + m.set(row.play_code, row); } return m; - }, [types]); + }, [resolvedTypes]); const rebateBulkPercentsMixed = useMemo(() => { - if (types.length === 0 || draftRows.length === 0) { + if (resolvedTypes.length === 0 || resolvedDraftRows.length === 0) { return false; } for (const dim of [2, 3, 4] as const) { - if (dimensionDistinctPrimaryScopePercents(dim, draftRows, types).size > 1) { + if (dimensionDistinctPrimaryScopePercents(dim, resolvedDraftRows, resolvedTypes).size > 1) { return true; } } return false; - }, [types, draftRows]); + }, [resolvedTypes, resolvedDraftRows]); const selectedVersionSummary = useMemo( - () => listRows.find((x) => String(x.id) === selectedId) ?? null, - [listRows, selectedId], + () => resolvedList.find((x) => String(x.id) === selectedId) ?? null, + [resolvedList, selectedId], ); - const isSelectedDetail = detail !== null && String(detail.id) === selectedId; - const selectedStatus = isSelectedDetail ? detail.status : selectedVersionSummary?.status; + const isSelectedDetail = resolvedDetail !== null && String(resolvedDetail.id) === selectedId; + const selectedStatus = isSelectedDetail ? resolvedDetail.status : selectedVersionSummary?.status; const isDraft = selectedStatus === "draft"; const canEditDraft = isDraft && canManage; @@ -305,12 +334,12 @@ export function RebateConfigDocScreen({ } async function handleSave() { - if (!detail || !canEditDraft) { + if (!resolvedDetail || !canEditDraft) { return; } setSaving(true); try { - const nextRows = applyDimensionPercentsToRows(draftRows); + const nextRows = applyDimensionPercentsToRows(resolvedDraftRows); const payload = nextRows.map((r) => ({ play_code: r.play_code, prize_scope: r.prize_scope, @@ -320,15 +349,19 @@ export function RebateConfigDocScreen({ currency_code: r.currency_code, extra_config_json: r.extra_config_json, })); - const d = await putOddsItems(detail.id, payload); + const d = await putOddsItems(resolvedDetail.id, payload); const rows = d.items.map((it) => ({ ...it })); - setDetail(d); - setDraftRows(rows); - setP2(inferPercentFrom(2, rows, types)); - setP3(inferPercentFrom(3, rows, types)); - setP4(inferPercentFrom(4, rows, types)); + if (workspace) { + workspace.applyDetail(d); + } else { + setDetail(d); + setDraftRows(rows); + } + setP2(inferPercentFrom(2, rows, resolvedTypes)); + setP3(inferPercentFrom(3, rows, resolvedTypes)); + setP4(inferPercentFrom(4, rows, resolvedTypes)); toast.success(t("versionActions.saveDraft", { ns: "config" })); - void refreshList(); + void (workspace?.refreshList() ?? refreshList()); } catch (e) { toast.error(e instanceof LotteryApiBizError ? e.message : t("versionActions.saveFailed", { ns: "config" })); } finally { @@ -337,20 +370,24 @@ export function RebateConfigDocScreen({ } async function handlePublish() { - if (!detail || !canEditDraft) { + if (!resolvedDetail || !canEditDraft) { return; } setSaving(true); try { - const d = await publishOddsVersion(detail.id); + const d = await publishOddsVersion(resolvedDetail.id); const rows = d.items.map((it) => ({ ...it })); - setDetail(d); - setDraftRows(rows); - setP2(inferPercentFrom(2, rows, types)); - setP3(inferPercentFrom(3, rows, types)); - setP4(inferPercentFrom(4, rows, types)); + if (workspace) { + workspace.applyDetail(d); + } else { + setDetail(d); + setDraftRows(rows); + } + setP2(inferPercentFrom(2, rows, resolvedTypes)); + setP3(inferPercentFrom(3, rows, resolvedTypes)); + setP4(inferPercentFrom(4, rows, resolvedTypes)); toast.success(t("rebate.publishSuccess", { ns: "config" })); - void refreshList(); + void (workspace?.refreshList() ?? refreshList()); setSelectedId(String(d.id)); } catch (e) { toast.error(e instanceof LotteryApiBizError ? e.message : t("rebate.publishFailed", { ns: "config" })); @@ -362,20 +399,24 @@ export function RebateConfigDocScreen({ async function handleNewDraft() { setSaving(true); try { - const active = listRows.find((x) => x.status === "active"); + 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 refreshList(); + await (workspace?.refreshList() ?? refreshList()); setSelectedId(String(d.id)); const rows = d.items.map((it) => ({ ...it })); - setDetail(d); - setDraftRows(rows); - setP2(inferPercentFrom(2, rows, types)); - setP3(inferPercentFrom(3, rows, types)); - setP4(inferPercentFrom(4, rows, types)); + if (workspace) { + workspace.applyDetail(d); + } else { + setDetail(d); + setDraftRows(rows); + } + setP2(inferPercentFrom(2, rows, resolvedTypes)); + setP3(inferPercentFrom(3, rows, resolvedTypes)); + setP4(inferPercentFrom(4, rows, resolvedTypes)); } catch (e) { toast.error(e instanceof LotteryApiBizError ? e.message : t("rebate.createDraftFailed", { ns: "config" })); } finally { @@ -383,7 +424,7 @@ export function RebateConfigDocScreen({ } } - const activeHead = listRows.find((x) => x.status === "active"); + const activeHead = resolvedList.find((x) => x.status === "active"); function requestRollback(row: ConfigVersionSummary) { setRollbackTarget(row); @@ -407,14 +448,18 @@ export function RebateConfigDocScreen({ version: d.version_no, }), ); - await refreshList(); + await (workspace?.refreshList() ?? refreshList()); setSelectedId(String(d.id)); const rows = d.items.map((it) => ({ ...it })); - setDetail(d); - setDraftRows(rows); - setP2(inferPercentFrom(2, rows, types)); - setP3(inferPercentFrom(3, rows, types)); - setP4(inferPercentFrom(4, rows, types)); + if (workspace) { + workspace.applyDetail(d); + } else { + setDetail(d); + setDraftRows(rows); + } + setP2(inferPercentFrom(2, rows, resolvedTypes)); + setP3(inferPercentFrom(3, rows, resolvedTypes)); + setP4(inferPercentFrom(4, rows, resolvedTypes)); setRollbackOpen(false); setRollbackTarget(null); } catch (e) { @@ -428,7 +473,7 @@ export function RebateConfigDocScreen({ try { await deleteOddsVersion(row.id); toast.success(t("versionSwitcher.delete", { ns: "config" })); - await refreshList(); + await (workspace?.refreshList() ?? refreshList()); } catch (e) { toast.error(e instanceof LotteryApiBizError ? e.message : t("rebate.deleteFailed", { ns: "config" })); throw e; @@ -439,10 +484,10 @@ export function RebateConfigDocScreen({ void refreshList()} @@ -473,16 +518,16 @@ export function RebateConfigDocScreen({ /> } footer={ - embedded || !detail ? null : ( + embedded || !resolvedDetail ? null : ( {t("rebate.editingVersion", { ns: "config", - version: detail.version_no, + version: resolvedDetail.version_no, status: - detail.status === "draft" + resolvedDetail.status === "draft" ? t("versionStatus.draft", { ns: "config" }) - : detail.status === "active" + : resolvedDetail.status === "active" ? t("versionStatus.active", { ns: "config" }) : t("versionStatus.archived", { ns: "config" }), })} @@ -583,7 +628,7 @@ export function RebateConfigDocScreen({ ) : null} - {loading || loadingDetail ? ( + {resolvedLoading || resolvedLoadingDetail ? (

{t("states.loading", { ns: "common" })}

) : null} diff --git a/src/modules/config/risk-cap-runtime-panel.tsx b/src/modules/config/risk-cap-runtime-panel.tsx index deca3fa..8f259cd 100644 --- a/src/modules/config/risk-cap-runtime-panel.tsx +++ b/src/modules/config/risk-cap-runtime-panel.tsx @@ -124,8 +124,8 @@ export function RiskCapRuntimePanel() {
updateDraft(p.id, { current_amount: e.target.value })} - /> +
+

{p.currency_code}

+

+ {t("displayBalance", { + amount: formatAdminMinorDecimal(p.current_amount, p.currency_code), + })} +

+
+ {canManageJackpot ? ( +
+

{t("balanceAdjustmentTitle")}

+

{t("balanceAdjustmentHint")}

+
+
+ + +
+
+ + updateAdjustmentDraft(p.id, { amount: e.target.value })} + /> +
+
+ +