feat(api, ui, i18n): 增强奖池管理与钱包功能
新增奖池余额调整与调整记录查询相关 API,提升后台对奖池的管理与控制能力。 更新奖池与钱包相关多语言文案,新增余额调整与转账完成提示信息,提升用户理解与反馈体验。 优化奖池管理相关 UI 组件,新增余额调整功能并改进页面布局,提升操作易用性。 重构相关组件以整合新功能,并进一步优化后台管理界面的整体用户体验。
This commit is contained in:
@@ -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<AdminJackpotPoolsData> {
|
||||
}
|
||||
|
||||
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<AdminJackpotPoolRow>(`${A}/jackpot/pools/${poolId}`, body);
|
||||
}
|
||||
|
||||
export async function postAdminJackpotPoolAdjustment(
|
||||
poolId: number,
|
||||
body: { amount_delta: number; reason: string },
|
||||
): Promise<AdminJackpotPoolAdjustResult> {
|
||||
return adminRequest.post<AdminJackpotPoolAdjustResult>(`${A}/jackpot/pools/${poolId}/adjustments`, body);
|
||||
}
|
||||
|
||||
export async function getAdminJackpotPoolAdjustments(
|
||||
poolId: number,
|
||||
q: { page?: number; per_page?: number } = {},
|
||||
): Promise<AdminJackpotPoolAdjustmentsData> {
|
||||
return adminRequest.get<AdminJackpotPoolAdjustmentsData>(`${A}/jackpot/pools/${poolId}/adjustments`, {
|
||||
params: q,
|
||||
});
|
||||
}
|
||||
|
||||
export async function postAdminJackpotManualBurst(
|
||||
poolId: number,
|
||||
body: { draw_id: number },
|
||||
|
||||
@@ -89,3 +89,13 @@ export async function manuallyProcessTransferOrder(
|
||||
remark ? { remark } : {},
|
||||
);
|
||||
}
|
||||
|
||||
export async function completeTransferInCredit(
|
||||
transferNo: string,
|
||||
remark?: string,
|
||||
): Promise<TransferOrderActionResult> {
|
||||
return adminRequest.post<TransferOrderActionResult>(
|
||||
`${A}/wallet/transfer-orders/${transferNo}/complete-credit`,
|
||||
remark ? { remark } : {},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -313,6 +313,7 @@
|
||||
},
|
||||
"rebate": {
|
||||
"sectionHint": "रिबेट दर अड्स संस्करणमा लेखिन्छ; पहिले माथिको «बाधा» खण्डमा ड्राफ्ट छान्नुहोस्।",
|
||||
"lazyLoadHint": "रिबेट खण्डमा स्क्रोल गर्दा लोड हुन्छ",
|
||||
"embeddedVersionHint": "रिबेट माथिको बाधा संस्करण लाइन साझा गर्छ—संस्करण त्यहीँबाट बदल्नुहोस्।",
|
||||
"sheetDescription": "रिबेट अड्स ड्राफ्ट संस्करणमा राखिन्छ र अड्ससँग एउटै संस्करण सेट साझा गर्छ।",
|
||||
"publishLabel": "प्रकाशन",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "प्रकार",
|
||||
|
||||
@@ -313,6 +313,7 @@
|
||||
},
|
||||
"rebate": {
|
||||
"sectionHint": "回水比例写入赔率版本;请先在上方选择或创建赔率草稿。",
|
||||
"lazyLoadHint": "向下滚动至回水区域后加载",
|
||||
"embeddedVersionHint": "回水与上方赔率共用版本线,请在「赔率」区块切换版本。",
|
||||
"sheetDescription": "回水配置存放在赔率草稿版本中,与赔率共用同一套版本记录。",
|
||||
"publishLabel": "发布",
|
||||
|
||||
@@ -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": "确认手动爆池?",
|
||||
|
||||
@@ -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}} 为已人工处理,不会自动调整钱包。"
|
||||
},
|
||||
|
||||
@@ -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<AdminPlayTypeRow[]>([]);
|
||||
const [list, setList] = useState<ConfigVersionSummary[]>([]);
|
||||
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<OddsVersionDetail | null>(null);
|
||||
const [draftRows, setDraftRows] = useState<OddsItemRow[]>([]);
|
||||
const [loadingTypes, setLoadingTypes] = useState(true);
|
||||
@@ -109,6 +113,16 @@ export function OddsConfigDocScreen({
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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<CatTab>("all");
|
||||
/** User-selected play type. Empty means none selected yet and falls back to the first item in the category. */
|
||||
const [playCode, setPlayCode] = useState<string>("");
|
||||
@@ -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<OddsItemRow>) {
|
||||
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<OddsItemRow>) {
|
||||
@@ -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={
|
||||
<ConfigVersionSwitcher
|
||||
versions={list}
|
||||
versions={resolvedList}
|
||||
selectedId={selectedId}
|
||||
onSelectedIdChange={setSelectedId}
|
||||
loading={loadingList}
|
||||
loading={resolvedLoadingList}
|
||||
sheetTitle={`${t("nav.items.odds", { ns: "config" })} ${t("versionSwitcher.sheetTitle", { ns: "config" })}`}
|
||||
sheetDescription={embedded ? undefined : t("odds.sheetDescription", { ns: "config" })}
|
||||
onDeleteVersion={handleDeleteVersion}
|
||||
@@ -492,8 +537,8 @@ export function OddsConfigDocScreen({
|
||||
<ConfigVersionActions
|
||||
isDraft={isDraft}
|
||||
canManage={canManage}
|
||||
loadingList={loadingList}
|
||||
loadingDetail={loadingDetail}
|
||||
loadingList={resolvedLoadingList}
|
||||
loadingDetail={resolvedLoadingDetail}
|
||||
saving={saving}
|
||||
onRefresh={() => void refreshList()}
|
||||
onNewDraft={() => void handleNewDraft()}
|
||||
@@ -502,7 +547,7 @@ export function OddsConfigDocScreen({
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
!detail ? null : (
|
||||
!resolvedDetail ? null : (
|
||||
<ConfigVersionToolbarMeta emphasis={!isDraft}>
|
||||
<span>
|
||||
{t("odds.activeVersionPrefix", { ns: "config" })}
|
||||
@@ -530,9 +575,9 @@ export function OddsConfigDocScreen({
|
||||
|
||||
const mainBlock = (
|
||||
<>
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
{resolvedError ? <p className="text-sm text-destructive">{resolvedError}</p> : null}
|
||||
|
||||
{loadingDetail || loadingTypes ? (
|
||||
{resolvedLoadingDetail || resolvedLoadingTypes ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
{t("odds.loadingDetails", { ns: "config" })}
|
||||
</p>
|
||||
|
||||
@@ -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<ConfigVersionSummary[]>([]);
|
||||
|
||||
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<OddsVersionDetail | null>(null);
|
||||
const [draftRows, setDraftRows] = useState<OddsItemRow[]>([]);
|
||||
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<void> {
|
||||
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<string, AdminPlayTypeRow>();
|
||||
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({
|
||||
<ConfigDocToolbar
|
||||
switcher={
|
||||
<ConfigVersionSwitcher
|
||||
versions={listRows}
|
||||
versions={resolvedList}
|
||||
selectedId={selectedId}
|
||||
onSelectedIdChange={setSelectedId}
|
||||
loading={loading}
|
||||
loading={resolvedLoading}
|
||||
sheetTitle={`${t("nav.items.rebate", { ns: "config" })} ${t("versionSwitcher.sheetTitle", { ns: "config" })}`}
|
||||
sheetDescription={t("rebate.sheetDescription", { ns: "config" })}
|
||||
onDeleteVersion={handleDeleteVersion}
|
||||
@@ -454,8 +499,8 @@ export function RebateConfigDocScreen({
|
||||
<ConfigVersionActions
|
||||
isDraft={isDraft}
|
||||
canManage={canManage}
|
||||
loadingList={loading}
|
||||
loadingDetail={loadingDetail}
|
||||
loadingList={resolvedLoading}
|
||||
loadingDetail={resolvedLoadingDetail}
|
||||
saving={saving}
|
||||
publishLabel={t("rebate.publishLabel", { ns: "config" })}
|
||||
onRefresh={() => void refreshList()}
|
||||
@@ -473,16 +518,16 @@ export function RebateConfigDocScreen({
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
embedded || !detail ? null : (
|
||||
embedded || !resolvedDetail ? null : (
|
||||
<ConfigVersionToolbarMeta emphasis={!isDraft}>
|
||||
<span>
|
||||
{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({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{loading || loadingDetail ? (
|
||||
{resolvedLoading || resolvedLoadingDetail ? (
|
||||
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
||||
) : null}
|
||||
</>
|
||||
|
||||
@@ -124,8 +124,8 @@ export function RiskCapRuntimePanel() {
|
||||
<div className="grid min-w-[12rem] flex-1 gap-1.5">
|
||||
<Label htmlFor="risk-cap-draw">{t("riskCap.runtime.drawLabel", { ns: "config" })}</Label>
|
||||
<Select
|
||||
value={drawId || undefined}
|
||||
onValueChange={(v) => setDrawId(v ?? "")}
|
||||
value={drawId}
|
||||
onValueChange={(v) => setDrawId(v == null ? "" : String(v))}
|
||||
disabled={drawsLoading || draws.length === 0}
|
||||
>
|
||||
<SelectTrigger id="risk-cap-draw" className="font-mono">
|
||||
|
||||
177
src/modules/config/use-odds-config-workspace.ts
Normal file
177
src/modules/config/use-odds-config-workspace.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
getAdminPlayTypes,
|
||||
getAllConfigVersions,
|
||||
getOddsVersion,
|
||||
getOddsVersions,
|
||||
} from "@/api/admin-config";
|
||||
import { pickDefaultConfigVersionId } from "@/lib/config-version-auto-pick";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type {
|
||||
AdminPlayTypeRow,
|
||||
ConfigVersionSummary,
|
||||
OddsItemRow,
|
||||
OddsVersionDetail,
|
||||
} from "@/types/api/admin-config";
|
||||
|
||||
export type OddsConfigWorkspace = {
|
||||
types: AdminPlayTypeRow[];
|
||||
list: ConfigVersionSummary[];
|
||||
selectedId: string;
|
||||
setSelectedId: (id: string) => void;
|
||||
detail: OddsVersionDetail | null;
|
||||
draftRows: OddsItemRow[];
|
||||
setDraftRows: React.Dispatch<React.SetStateAction<OddsItemRow[]>>;
|
||||
applyDetail: (detail: OddsVersionDetail) => void;
|
||||
loadingTypes: boolean;
|
||||
loadingList: boolean;
|
||||
loadingDetail: boolean;
|
||||
error: string | null;
|
||||
refreshTypes: () => Promise<void>;
|
||||
refreshList: () => Promise<void>;
|
||||
reloadDetail: () => Promise<void>;
|
||||
};
|
||||
|
||||
/** 赔率/回水合并页:版本列表与详情只加载一次,避免双组件重复请求导致卡顿。 */
|
||||
export function useOddsConfigWorkspace(
|
||||
selectedId: string,
|
||||
onSelectedIdChange: (id: string) => void,
|
||||
): OddsConfigWorkspace {
|
||||
const { t } = useTranslation("common");
|
||||
const [types, setTypes] = useState<AdminPlayTypeRow[]>([]);
|
||||
const [list, setList] = useState<ConfigVersionSummary[]>([]);
|
||||
const [detail, setDetail] = useState<OddsVersionDetail | null>(null);
|
||||
const [draftRows, setDraftRows] = useState<OddsItemRow[]>([]);
|
||||
const [loadingTypes, setLoadingTypes] = useState(true);
|
||||
const [loadingList, setLoadingList] = useState(true);
|
||||
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const detailRequestSeq = useRef(0);
|
||||
|
||||
const applyDetail = useCallback((next: OddsVersionDetail) => {
|
||||
setDetail(next);
|
||||
setDraftRows(next.items.map((it) => ({ ...it })));
|
||||
}, []);
|
||||
|
||||
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"));
|
||||
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");
|
||||
setError(msg);
|
||||
setList([]);
|
||||
} finally {
|
||||
setLoadingList(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const loadDetail = useCallback(
|
||||
async (id: number) => {
|
||||
const seq = ++detailRequestSeq.current;
|
||||
setLoadingDetail(true);
|
||||
try {
|
||||
const d = await getOddsVersion(id);
|
||||
if (seq !== detailRequestSeq.current) {
|
||||
return;
|
||||
}
|
||||
applyDetail(d);
|
||||
} catch (e) {
|
||||
if (seq !== detailRequestSeq.current) {
|
||||
return;
|
||||
}
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("errors.loadFailed"));
|
||||
setDetail(null);
|
||||
setDraftRows([]);
|
||||
} finally {
|
||||
if (seq === detailRequestSeq.current) {
|
||||
setLoadingDetail(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[applyDetail, t],
|
||||
);
|
||||
|
||||
const reloadDetail = useCallback(async () => {
|
||||
if (selectedId === "") {
|
||||
return;
|
||||
}
|
||||
const id = Number(selectedId);
|
||||
if (!Number.isFinite(id)) {
|
||||
return;
|
||||
}
|
||||
await loadDetail(id);
|
||||
}, [loadDetail, selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshTypes();
|
||||
void refreshList();
|
||||
}, [refreshTypes, refreshList]);
|
||||
|
||||
useEffect(() => {
|
||||
if (list.length === 0) {
|
||||
if (selectedId !== "") {
|
||||
onSelectedIdChange("");
|
||||
setDetail(null);
|
||||
setDraftRows([]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (selectedId !== "" && list.some((x) => String(x.id) === selectedId)) {
|
||||
return;
|
||||
}
|
||||
const pickId = pickDefaultConfigVersionId(list);
|
||||
if (pickId) {
|
||||
onSelectedIdChange(pickId);
|
||||
}
|
||||
}, [list, onSelectedIdChange, selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedId === "") {
|
||||
return;
|
||||
}
|
||||
const id = Number(selectedId);
|
||||
if (!Number.isFinite(id)) {
|
||||
return;
|
||||
}
|
||||
void loadDetail(id);
|
||||
}, [loadDetail, selectedId]);
|
||||
|
||||
return {
|
||||
types,
|
||||
list,
|
||||
selectedId,
|
||||
setSelectedId: onSelectedIdChange,
|
||||
detail,
|
||||
draftRows,
|
||||
setDraftRows,
|
||||
applyDetail,
|
||||
loadingTypes,
|
||||
loadingList,
|
||||
loadingDetail,
|
||||
error,
|
||||
refreshTypes,
|
||||
refreshList,
|
||||
reloadDetail,
|
||||
};
|
||||
}
|
||||
@@ -29,7 +29,11 @@ export function JackpotConfigScreen() {
|
||||
<JackpotPoolsConsole embedded />
|
||||
</AdminPageCard>
|
||||
|
||||
<AdminPageCard id="jackpot-records" title={t("recordsSectionTitle")}>
|
||||
<AdminPageCard
|
||||
id="jackpot-records"
|
||||
title={t("recordsSectionTitle")}
|
||||
description={t("recordsSectionDescription")}
|
||||
>
|
||||
<JackpotRecordsConsole embedded />
|
||||
</AdminPageCard>
|
||||
</div>
|
||||
|
||||
@@ -4,8 +4,10 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
getAdminJackpotPoolAdjustments,
|
||||
getAdminJackpotPools,
|
||||
postAdminJackpotManualBurst,
|
||||
postAdminJackpotPoolAdjustment,
|
||||
putAdminJackpotPool,
|
||||
} from "@/api/admin-jackpot";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
@@ -29,10 +31,17 @@ import {
|
||||
import { toast } from "sonner";
|
||||
import { useAdminProfile } from "@/stores/admin-session";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type { AdminJackpotPoolRow } from "@/types/api/admin-jackpot";
|
||||
import type { AdminJackpotPoolAdjustmentRow, AdminJackpotPoolRow } from "@/types/api/admin-jackpot";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
type Draft = {
|
||||
current_amount: string;
|
||||
contribution_rate: string;
|
||||
trigger_threshold: string;
|
||||
payout_rate: string;
|
||||
@@ -43,9 +52,14 @@ type Draft = {
|
||||
manual_burst_draw_id: string;
|
||||
};
|
||||
|
||||
type AdjustmentDraft = {
|
||||
direction: "increase" | "decrease";
|
||||
amount: string;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
function toDraft(p: AdminJackpotPoolRow): Draft {
|
||||
return {
|
||||
current_amount: formatAdminMinorDecimal(p.current_amount, p.currency_code),
|
||||
contribution_rate: String(p.contribution_rate),
|
||||
trigger_threshold: formatAdminMinorDecimal(p.trigger_threshold, p.currency_code),
|
||||
payout_rate: String(p.payout_rate),
|
||||
@@ -74,6 +88,9 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
||||
const [savingId, setSavingId] = useState<number | null>(null);
|
||||
const [burstingId, setBurstingId] = useState<number | null>(null);
|
||||
const [confirmBurstPoolId, setConfirmBurstPoolId] = useState<number | null>(null);
|
||||
const [adjustmentDrafts, setAdjustmentDrafts] = useState<Record<number, AdjustmentDraft>>({});
|
||||
const [adjustmentRows, setAdjustmentRows] = useState<Record<number, AdminJackpotPoolAdjustmentRow[]>>({});
|
||||
const [adjustingId, setAdjustingId] = useState<number | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -81,10 +98,21 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
||||
const res = await getAdminJackpotPools();
|
||||
setItems(res.items);
|
||||
const d: Record<number, Draft> = {};
|
||||
const adjDrafts: Record<number, AdjustmentDraft> = {};
|
||||
const adjRows: Record<number, AdminJackpotPoolAdjustmentRow[]> = {};
|
||||
for (const p of res.items) {
|
||||
d[p.id] = toDraft(p);
|
||||
adjDrafts[p.id] = { direction: "increase", amount: "", reason: "" };
|
||||
try {
|
||||
const ledger = await getAdminJackpotPoolAdjustments(p.id, { per_page: 5 });
|
||||
adjRows[p.id] = ledger.items;
|
||||
} catch {
|
||||
adjRows[p.id] = [];
|
||||
}
|
||||
}
|
||||
setDrafts(d);
|
||||
setAdjustmentDrafts(adjDrafts);
|
||||
setAdjustmentRows(adjRows);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("loadFailed"));
|
||||
} finally {
|
||||
@@ -105,13 +133,19 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
||||
}));
|
||||
};
|
||||
|
||||
const updateAdjustmentDraft = (id: number, patch: Partial<AdjustmentDraft>) => {
|
||||
setAdjustmentDrafts((prev) => ({
|
||||
...prev,
|
||||
[id]: { ...(prev[id] ?? { direction: "increase", amount: "", reason: "" }), ...patch },
|
||||
}));
|
||||
};
|
||||
|
||||
const save = async (p: AdminJackpotPoolRow) => {
|
||||
const d = drafts[p.id];
|
||||
if (!d) return;
|
||||
setSavingId(p.id);
|
||||
try {
|
||||
await putAdminJackpotPool(p.id, {
|
||||
current_amount: parseAdminMajorToMinor(d.current_amount, p.currency_code) ?? 0,
|
||||
contribution_rate: Number(d.contribution_rate),
|
||||
trigger_threshold: parseAdminMajorToMinor(d.trigger_threshold, p.currency_code) ?? 0,
|
||||
payout_rate: Number(d.payout_rate),
|
||||
@@ -132,6 +166,43 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
||||
}
|
||||
};
|
||||
|
||||
const submitAdjustment = async (p: AdminJackpotPoolRow) => {
|
||||
const adj = adjustmentDrafts[p.id];
|
||||
if (!adj) return;
|
||||
const minor = parseAdminMajorToMinor(adj.amount, p.currency_code);
|
||||
if (minor === null || minor <= 0) {
|
||||
toast.error(t("adjustmentAmountInvalid"));
|
||||
return;
|
||||
}
|
||||
const trimmedReason = adj.reason.trim();
|
||||
if (trimmedReason.length < 3) {
|
||||
toast.error(t("adjustmentReasonRequired"));
|
||||
return;
|
||||
}
|
||||
const amountDelta = adj.direction === "decrease" ? -minor : minor;
|
||||
|
||||
setAdjustingId(p.id);
|
||||
try {
|
||||
const res = await postAdminJackpotPoolAdjustment(p.id, {
|
||||
amount_delta: amountDelta,
|
||||
reason: trimmedReason,
|
||||
});
|
||||
toast.success(t("adjustmentSuccess"));
|
||||
setItems((prev) =>
|
||||
prev.map((row) =>
|
||||
row.id === p.id ? { ...row, current_amount: res.pool.current_amount } : row,
|
||||
),
|
||||
);
|
||||
updateAdjustmentDraft(p.id, { amount: "", reason: "" });
|
||||
const ledger = await getAdminJackpotPoolAdjustments(p.id, { per_page: 5 });
|
||||
setAdjustmentRows((prev) => ({ ...prev, [p.id]: ledger.items }));
|
||||
} catch (e) {
|
||||
toast.error(e instanceof LotteryApiBizError ? e.message : t("adjustmentFailed"));
|
||||
} finally {
|
||||
setAdjustingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const manualBurst = async (p: AdminJackpotPoolRow) => {
|
||||
const d = drafts[p.id];
|
||||
if (!d) return;
|
||||
@@ -164,22 +235,96 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
||||
) : null}
|
||||
{items.map((p) => {
|
||||
const d = drafts[p.id] ?? toDraft(p);
|
||||
const adj = adjustmentDrafts[p.id] ?? { direction: "increase", amount: "", reason: "" };
|
||||
const ledger = adjustmentRows[p.id] ?? [];
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
className="space-y-4 rounded-xl border border-border/60 bg-muted/10 p-4"
|
||||
>
|
||||
<h3 className="font-mono text-sm font-semibold">{p.currency_code}</h3>
|
||||
<fieldset disabled={!canManageJackpot} className="grid gap-4 border-0 p-0 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`amt-${p.id}`}>{t("currentAmount")}</Label>
|
||||
<Input
|
||||
id={`amt-${p.id}`}
|
||||
className="font-mono"
|
||||
value={d.current_amount}
|
||||
onChange={(e) => updateDraft(p.id, { current_amount: e.target.value })}
|
||||
/>
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<h3 className="font-mono text-sm font-semibold">{p.currency_code}</h3>
|
||||
<p className="text-muted-foreground font-mono text-xs">
|
||||
{t("displayBalance", {
|
||||
amount: formatAdminMinorDecimal(p.current_amount, p.currency_code),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
{canManageJackpot ? (
|
||||
<div className="space-y-3 rounded-lg border border-border/60 bg-background/60 p-4">
|
||||
<p className="text-sm font-medium">{t("balanceAdjustmentTitle")}</p>
|
||||
<p className="text-muted-foreground text-xs">{t("balanceAdjustmentHint")}</p>
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("adjustmentDirection")}</Label>
|
||||
<Select
|
||||
value={adj.direction}
|
||||
onValueChange={(value: "increase" | "decrease") =>
|
||||
updateAdjustmentDraft(p.id, { direction: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="font-mono">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="increase">{t("adjustmentIncrease")}</SelectItem>
|
||||
<SelectItem value="decrease">{t("adjustmentDecrease")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`adj-amt-${p.id}`}>{t("adjustmentAmount")}</Label>
|
||||
<Input
|
||||
id={`adj-amt-${p.id}`}
|
||||
className="font-mono"
|
||||
value={adj.amount}
|
||||
onChange={(e) => updateAdjustmentDraft(p.id, { amount: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5 sm:col-span-2 xl:col-span-2">
|
||||
<Label htmlFor={`adj-reason-${p.id}`}>{t("adjustmentReason")}</Label>
|
||||
<Textarea
|
||||
id={`adj-reason-${p.id}`}
|
||||
rows={2}
|
||||
value={adj.reason}
|
||||
onChange={(e) => updateAdjustmentDraft(p.id, { reason: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={adjustingId === p.id}
|
||||
onClick={() =>
|
||||
requestConfirm({
|
||||
title: t("confirmAdjustmentTitle"),
|
||||
description: t("confirmAdjustmentDescription"),
|
||||
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
|
||||
onConfirm: () => submitAdjustment(p),
|
||||
})
|
||||
}
|
||||
>
|
||||
{adjustingId === p.id ? t("processing") : t("submitAdjustment")}
|
||||
</Button>
|
||||
</div>
|
||||
{ledger.length > 0 ? (
|
||||
<div className="space-y-2 border-t border-border/60 pt-3">
|
||||
<p className="text-xs font-medium">{t("recentAdjustments")}</p>
|
||||
<ul className="text-muted-foreground space-y-1 font-mono text-xs">
|
||||
{ledger.map((row) => (
|
||||
<li key={row.id}>
|
||||
{row.adjustment_no} · {row.amount_delta > 0 ? "+" : ""}
|
||||
{formatAdminMinorDecimal(row.amount_delta, p.currency_code)} ·{" "}
|
||||
{row.reason}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<fieldset disabled={!canManageJackpot} className="grid gap-4 border-0 p-0 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`cr-${p.id}`}>{t("contributionRate")}</Label>
|
||||
<Input
|
||||
|
||||
@@ -37,6 +37,10 @@ type JackpotRecordsConsoleProps = {
|
||||
const TABLE_IN_SHELL_CLASS =
|
||||
"[&_[data-slot=table-container]]:rounded-none [&_[data-slot=table-container]]:border-0 [&_[data-slot=table-container]]:bg-transparent [&_[data-slot=table-container]]:shadow-none";
|
||||
|
||||
/** 流水表左对齐文本列,避免 table-fixed 拉宽空白列 */
|
||||
const TABLE_LAYOUT_CLASS =
|
||||
"w-full min-w-[42rem] text-left [&_[data-slot=table-head]]:text-left [&_[data-slot=table-cell]]:text-left";
|
||||
|
||||
function JackpotRecordTableSection({
|
||||
title,
|
||||
tableId,
|
||||
@@ -60,7 +64,7 @@ function JackpotRecordTableSection({
|
||||
|
||||
return (
|
||||
<div className="admin-table-shell">
|
||||
<div className="admin-table-toolbar flex items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border/70 bg-muted/20 px-4 py-3">
|
||||
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
|
||||
<AdminTableExportButton
|
||||
tableId={tableId}
|
||||
@@ -154,9 +158,9 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
|
||||
return translated === key ? value : translated;
|
||||
};
|
||||
|
||||
const filterBlock = embedded ? (
|
||||
<div className="admin-list-toolbar mb-0 border-t-0 pt-0">
|
||||
<div className="admin-list-field max-w-xs flex-1">
|
||||
const filterFields = (
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-end">
|
||||
<div className="flex w-full min-w-0 max-w-sm flex-col gap-1.5">
|
||||
<Label htmlFor="jk-draw">{t("drawNo")}</Label>
|
||||
<Input
|
||||
id="jk-draw"
|
||||
@@ -164,34 +168,27 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
|
||||
value={drawNo}
|
||||
onChange={(e) => setDrawNo(e.target.value)}
|
||||
placeholder={t("optional")}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
applyDraw();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-list-actions">
|
||||
<Button type="button" onClick={applyDraw}>
|
||||
{t("apply")}
|
||||
</Button>
|
||||
</div>
|
||||
<Button type="button" className="shrink-0 sm:self-end" onClick={applyDraw}>
|
||||
{t("apply")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const filterBlock = embedded ? (
|
||||
filterFields
|
||||
) : (
|
||||
<Card className="mb-6">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">{t("filter")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<div className="flex max-w-xs flex-1 flex-col gap-1.5">
|
||||
<Label htmlFor="jk-draw">{t("drawNo")}</Label>
|
||||
<Input
|
||||
id="jk-draw"
|
||||
className="font-mono"
|
||||
value={drawNo}
|
||||
onChange={(e) => setDrawNo(e.target.value)}
|
||||
placeholder={t("optional")}
|
||||
/>
|
||||
</div>
|
||||
<Button type="button" onClick={applyDraw}>
|
||||
{t("apply")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
<CardContent>{filterFields}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -237,21 +234,21 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
|
||||
hasData={payouts != null}
|
||||
footer={payoutFooter}
|
||||
>
|
||||
<Table id="jackpot-payout-table" className="table-fixed">
|
||||
<Table id="jackpot-payout-table" className={TABLE_LAYOUT_CLASS}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-14">{t("table.id", { ns: "common" })}</TableHead>
|
||||
<TableHead className="w-[11rem]">{t("drawNo")}</TableHead>
|
||||
<TableHead className="w-28">{t("trigger")}</TableHead>
|
||||
<TableHead className="w-32 text-center">{t("payoutAmount")}</TableHead>
|
||||
<TableHead className="w-24 text-center">{t("winnerCount")}</TableHead>
|
||||
<TableHead className="w-[11rem]">{t("time")}</TableHead>
|
||||
<TableHead className="w-16 whitespace-nowrap">{t("table.id", { ns: "common" })}</TableHead>
|
||||
<TableHead className="whitespace-nowrap">{t("drawNo")}</TableHead>
|
||||
<TableHead className="whitespace-nowrap">{t("trigger")}</TableHead>
|
||||
<TableHead className="whitespace-nowrap text-right">{t("payoutAmount")}</TableHead>
|
||||
<TableHead className="w-24 whitespace-nowrap text-right">{t("winnerCount")}</TableHead>
|
||||
<TableHead className="whitespace-nowrap">{t("time")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(payouts?.items ?? []).length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-muted-foreground">
|
||||
<TableCell colSpan={6} className="py-10 text-center text-muted-foreground">
|
||||
{t("states.noData", { ns: "common" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -261,10 +258,10 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
|
||||
<TableCell className="font-mono text-xs">{r.id}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{r.draw_no ?? "—"}</TableCell>
|
||||
<TableCell className="text-xs">{triggerTypeText(r.trigger_type)}</TableCell>
|
||||
<TableCell className="text-center font-mono text-xs tabular-nums">
|
||||
<TableCell className="text-right font-mono text-xs tabular-nums">
|
||||
{formatAdminMinorUnits(r.total_payout_amount, r.currency_code ?? "NPR")}
|
||||
</TableCell>
|
||||
<TableCell className="text-center tabular-nums">{r.winner_count}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{r.winner_count}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{formatDt(r.created_at)}
|
||||
</TableCell>
|
||||
@@ -286,21 +283,21 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
|
||||
hasData={contribs != null}
|
||||
footer={contributionFooter}
|
||||
>
|
||||
<Table id="jackpot-contribution-table" className="table-fixed">
|
||||
<Table id="jackpot-contribution-table" className={TABLE_LAYOUT_CLASS}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-14">{t("table.id", { ns: "common" })}</TableHead>
|
||||
<TableHead className="w-[11rem]">{t("drawNo")}</TableHead>
|
||||
<TableHead className="w-[11rem]">{t("ticketNo")}</TableHead>
|
||||
<TableHead>{t("player")}</TableHead>
|
||||
<TableHead className="w-32 text-center">{t("contributionAmount")}</TableHead>
|
||||
<TableHead className="w-[11rem]">{t("time")}</TableHead>
|
||||
<TableHead className="w-16 whitespace-nowrap">{t("table.id", { ns: "common" })}</TableHead>
|
||||
<TableHead className="whitespace-nowrap">{t("drawNo")}</TableHead>
|
||||
<TableHead className="whitespace-nowrap">{t("ticketNo")}</TableHead>
|
||||
<TableHead className="max-w-[10rem] whitespace-nowrap">{t("player")}</TableHead>
|
||||
<TableHead className="whitespace-nowrap text-right">{t("contributionAmount")}</TableHead>
|
||||
<TableHead className="whitespace-nowrap">{t("time")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(contribs?.items ?? []).length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-muted-foreground">
|
||||
<TableCell colSpan={6} className="py-10 text-center text-muted-foreground">
|
||||
{t("states.noData", { ns: "common" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -310,8 +307,10 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
|
||||
<TableCell className="font-mono text-xs">{r.id}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{r.draw_no ?? "—"}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{r.ticket_no ?? "—"}</TableCell>
|
||||
<TableCell className="max-w-[12rem] truncate text-xs">{r.player_username ?? "—"}</TableCell>
|
||||
<TableCell className="text-center font-mono text-xs tabular-nums">
|
||||
<TableCell className="max-w-[10rem] truncate text-xs" title={r.player_username ?? undefined}>
|
||||
{r.player_username ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-xs tabular-nums">
|
||||
{formatAdminMinorUnits(r.contribution_amount, r.currency_code ?? "NPR")}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
@@ -330,17 +329,10 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
|
||||
{filterBlock}
|
||||
{err ? <p className="text-destructive text-sm">{err}</p> : null}
|
||||
|
||||
{embedded ? (
|
||||
<div className="space-y-8">
|
||||
{payoutTable}
|
||||
{contributionTable}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{payoutTable}
|
||||
{contributionTable}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-6">
|
||||
{payoutTable}
|
||||
{contributionTable}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
|
||||
@@ -9,12 +9,18 @@ import { ConfigDocPage } from "@/modules/config/config-doc-page";
|
||||
import { ConfigSection } from "@/modules/config/config-section";
|
||||
import { OddsConfigDocScreen } from "@/modules/config/doc/odds-config-doc-screen";
|
||||
import { RebateConfigDocScreen } from "@/modules/config/doc/rebate-config-doc-screen";
|
||||
import { useOddsConfigWorkspace } from "@/modules/config/use-odds-config-workspace";
|
||||
import { RulesPageShell } from "@/modules/rules/rules-page-shell";
|
||||
|
||||
/** 赔率与回水:共用赔率版本线,单页上下分区。 */
|
||||
export function RulesOddsConfigScreen() {
|
||||
const { t } = useTranslation("config");
|
||||
const [sharedVersionId, setSharedVersionId] = useState("");
|
||||
const workspace = useOddsConfigWorkspace(sharedVersionId, setSharedVersionId);
|
||||
const rebateSectionRef = useRef<HTMLDivElement>(null);
|
||||
const [rebateMounted, setRebateMounted] = useState(
|
||||
() => typeof window !== "undefined" && window.location.hash === "#rebate",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const scrollToRebate = () => {
|
||||
@@ -28,23 +34,43 @@ export function RulesOddsConfigScreen() {
|
||||
return () => window.removeEventListener("hashchange", scrollToRebate);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (rebateMounted) {
|
||||
return;
|
||||
}
|
||||
const node = rebateSectionRef.current;
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry?.isIntersecting) {
|
||||
setRebateMounted(true);
|
||||
}
|
||||
},
|
||||
{ rootMargin: "240px 0px" },
|
||||
);
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, [rebateMounted]);
|
||||
|
||||
return (
|
||||
<RulesPageShell>
|
||||
<AdminPermissionGate requiredAny={PRD_RULES_ODDS_ACCESS_ANY}>
|
||||
<ConfigDocPage title={t("nav.rulesOddsTitle")} contentClassName="space-y-8">
|
||||
<ConfigSection title={t("nav.items.odds")}>
|
||||
<OddsConfigDocScreen
|
||||
embedded
|
||||
versionId={sharedVersionId}
|
||||
onVersionIdChange={setSharedVersionId}
|
||||
/>
|
||||
<OddsConfigDocScreen embedded workspace={workspace} />
|
||||
</ConfigSection>
|
||||
<ConfigSection id="rebate" title={t("nav.items.rebate")}>
|
||||
<RebateConfigDocScreen
|
||||
embedded
|
||||
versionId={sharedVersionId}
|
||||
onVersionIdChange={setSharedVersionId}
|
||||
/>
|
||||
<div ref={rebateSectionRef}>
|
||||
{rebateMounted ? (
|
||||
<RebateConfigDocScreen embedded workspace={workspace} />
|
||||
) : (
|
||||
<p className="text-muted-foreground py-6 text-center text-sm">
|
||||
{t("rebate.lazyLoadHint", { ns: "config" })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</ConfigSection>
|
||||
</ConfigDocPage>
|
||||
</AdminPermissionGate>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Copy } from "lucide-react";
|
||||
import { Copy, Loader2, MoreHorizontal } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -11,12 +11,20 @@ import {
|
||||
getAdminWalletTransactions,
|
||||
reverseTransferOrder,
|
||||
manuallyProcessTransferOrder,
|
||||
completeTransferInCredit,
|
||||
} from "@/api/admin-wallet";
|
||||
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -44,9 +52,11 @@ import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { useExportLabels } from "@/hooks/use-export-labels";
|
||||
import { formatAdminMinorUnits } from "@/lib/money";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
import type {
|
||||
AdminPlayerWalletsData,
|
||||
AdminTransferOrderItem,
|
||||
AdminTransferOrderListData,
|
||||
AdminWalletTxnListData,
|
||||
} from "@/types/api/admin-wallet";
|
||||
@@ -213,8 +223,29 @@ function canReverseTransferOrder(
|
||||
return canWriteWallet && (row.can_reverse ?? row.status === "pending_reconcile");
|
||||
}
|
||||
|
||||
function canCompleteTransferInCredit(
|
||||
row: {
|
||||
direction: string;
|
||||
status: string;
|
||||
fail_reason?: string | null;
|
||||
external_ref_no?: string | null;
|
||||
can_complete_credit?: boolean;
|
||||
},
|
||||
canWriteWallet: boolean,
|
||||
): boolean {
|
||||
return (
|
||||
canWriteWallet &&
|
||||
(row.can_complete_credit ??
|
||||
(row.direction === "in" &&
|
||||
row.status === "pending_reconcile" &&
|
||||
row.fail_reason === "lottery_credit_failed" &&
|
||||
Boolean(row.external_ref_no?.trim())))
|
||||
);
|
||||
}
|
||||
|
||||
function canManuallyProcessTransferOrder(
|
||||
row: {
|
||||
direction?: string;
|
||||
status: string;
|
||||
can_manually_process?: boolean;
|
||||
},
|
||||
@@ -222,7 +253,80 @@ function canManuallyProcessTransferOrder(
|
||||
): boolean {
|
||||
return (
|
||||
canWriteWallet &&
|
||||
(row.can_manually_process ?? ["processing", "failed", "pending_reconcile"].includes(row.status))
|
||||
(row.can_manually_process ??
|
||||
(["processing", "failed", "pending_reconcile"].includes(row.status) &&
|
||||
!(row.direction === "out" && row.status === "pending_reconcile")))
|
||||
);
|
||||
}
|
||||
|
||||
type TransferOrderRowActionsProps = {
|
||||
row: AdminTransferOrderItem;
|
||||
canWriteWallet: boolean;
|
||||
busy: boolean;
|
||||
onCompleteCredit: (transferNo: string) => void;
|
||||
onReverse: (transferNo: string) => void;
|
||||
onManualProcess: (transferNo: string) => void;
|
||||
t: (key: string) => string;
|
||||
};
|
||||
|
||||
function TransferOrderRowActions({
|
||||
row,
|
||||
canWriteWallet,
|
||||
busy,
|
||||
onCompleteCredit,
|
||||
onReverse,
|
||||
onManualProcess,
|
||||
t,
|
||||
}: TransferOrderRowActionsProps): React.ReactElement {
|
||||
const showComplete = canCompleteTransferInCredit(row, canWriteWallet);
|
||||
const showReverse = canReverseTransferOrder(row, canWriteWallet);
|
||||
const showManual = canManuallyProcessTransferOrder(row, canWriteWallet);
|
||||
|
||||
if (!showComplete && !showReverse && !showManual) {
|
||||
return <span className="text-xs text-muted-foreground">—</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
disabled={busy}
|
||||
aria-label={t("actionsMenuAriaLabel")}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "ghost", size: "icon-sm" }),
|
||||
"text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="size-4 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<MoreHorizontal className="size-4" aria-hidden />
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[11rem]">
|
||||
{showComplete ? (
|
||||
<DropdownMenuItem disabled={busy} onClick={() => onCompleteCredit(row.transfer_no)}>
|
||||
{t("completeCredit")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{showManual ? (
|
||||
<DropdownMenuItem disabled={busy} onClick={() => onManualProcess(row.transfer_no)}>
|
||||
{t("manualProcess")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{showReverse ? (
|
||||
<>
|
||||
{showComplete || showManual ? <DropdownMenuSeparator /> : null}
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
disabled={busy}
|
||||
onClick={() => onReverse(row.transfer_no)}
|
||||
>
|
||||
{t("reverse")}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -280,6 +384,14 @@ export function TransferOrdersPanel(): React.ReactElement {
|
||||
doAction(transferNo, () => manuallyProcessTransferOrder(transferNo), t("manualProcessSuccess")),
|
||||
});
|
||||
|
||||
const handleCompleteCredit = (transferNo: string) =>
|
||||
requestConfirm({
|
||||
title: t("confirm.completeCreditTitle"),
|
||||
description: t("confirm.completeCreditDescription", { transferNo }),
|
||||
onConfirm: () =>
|
||||
doAction(transferNo, () => completeTransferInCredit(transferNo), t("completeCreditSuccess")),
|
||||
});
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
@@ -464,7 +576,7 @@ export function TransferOrdersPanel(): React.ReactElement {
|
||||
<TableHead className="min-w-0 max-w-[14rem]">{t("failReason")}</TableHead>
|
||||
<TableHead className="min-w-0 whitespace-normal leading-tight">{t("requestTime")}</TableHead>
|
||||
<TableHead className="min-w-0 whitespace-normal leading-tight">{t("finishedTime")}</TableHead>
|
||||
<TableHead className="w-24">{t("actions")}</TableHead>
|
||||
<TableHead className="w-12 text-center">{t("actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -506,36 +618,18 @@ export function TransferOrdersPanel(): React.ReactElement {
|
||||
<TableCell className="min-w-0 whitespace-normal font-mono text-[11px] leading-snug text-muted-foreground">
|
||||
{formatTs(row.finished_at)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{canReverseTransferOrder(row, canWriteWallet) ||
|
||||
canManuallyProcessTransferOrder(row, canWriteWallet) ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
{canReverseTransferOrder(row, canWriteWallet) ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="text-xs"
|
||||
disabled={actionLoading.has(row.transfer_no)}
|
||||
onClick={() => handleReverse(row.transfer_no)}
|
||||
>
|
||||
{actionLoading.has(row.transfer_no) ? t("processing") : t("reverse")}
|
||||
</Button>
|
||||
) : null}
|
||||
{canManuallyProcessTransferOrder(row, canWriteWallet) ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
disabled={actionLoading.has(row.transfer_no)}
|
||||
onClick={() => handleManuallyProcess(row.transfer_no)}
|
||||
>
|
||||
{actionLoading.has(row.transfer_no) ? t("processing") : t("manualProcess")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
<TableCell className="text-center align-middle">
|
||||
<div className="flex justify-center">
|
||||
<TransferOrderRowActions
|
||||
row={row}
|
||||
canWriteWallet={canWriteWallet}
|
||||
busy={actionLoading.has(row.transfer_no)}
|
||||
onCompleteCredit={handleCompleteCredit}
|
||||
onReverse={handleReverse}
|
||||
onManualProcess={handleManuallyProcess}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
|
||||
@@ -63,3 +63,28 @@ export type AdminJackpotContributionsData = {
|
||||
last_page: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type AdminJackpotPoolAdjustmentRow = {
|
||||
id: number;
|
||||
adjustment_no: string;
|
||||
jackpot_pool_id: number;
|
||||
admin_user_id: number;
|
||||
admin_username: string | null;
|
||||
amount_delta: number;
|
||||
balance_before: number;
|
||||
balance_after: number;
|
||||
reason: string;
|
||||
created_at: string | null;
|
||||
};
|
||||
|
||||
export type AdminJackpotPoolAdjustmentsData = {
|
||||
items: AdminJackpotPoolAdjustmentRow[];
|
||||
total: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
};
|
||||
|
||||
export type AdminJackpotPoolAdjustResult = {
|
||||
adjustment: AdminJackpotPoolAdjustmentRow;
|
||||
pool: Pick<AdminJackpotPoolRow, "id" | "currency_code" | "current_amount" | "updated_at">;
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ export type AdminTransferOrderItem = {
|
||||
idempotent_key: string;
|
||||
status: string;
|
||||
can_reverse?: boolean;
|
||||
can_complete_credit?: boolean;
|
||||
can_manually_process?: boolean;
|
||||
external_ref_no: string | null;
|
||||
external_request_payload: Record<string, unknown> | null;
|
||||
|
||||
Reference in New Issue
Block a user