fix(settlement): 操作记录服务端分页、流水提示与坏账幂等键
Some checks failed
lotteryadmin CI / build (push) Has been cancelled

- 操作记录 Tab 改用 settlement-operations API 服务端筛选分页
- 流水面板展示 truncated 截断提示
- 坏账核销对齐收付/补差的幂等键生成与复用
- 结算中心 URL 同步、代理控制台 lint 与玩家/奖池模块更新
This commit is contained in:
2026-06-26 16:40:46 +08:00
parent 2d80127b08
commit bea342a4d7
15 changed files with 544 additions and 216 deletions

View File

@@ -127,7 +127,7 @@ export async function postSettlementPeriodClose(
export async function postSettlementBillBadDebtWriteOff( export async function postSettlementBillBadDebtWriteOff(
billId: number, billId: number,
body?: { reason?: string }, body?: { reason?: string; idempotency_key?: string },
): Promise<{ original_bill_id: number; bad_debt_bill_id: number; bill: SettlementBillRow }> { ): Promise<{ original_bill_id: number; bad_debt_bill_id: number; bill: SettlementBillRow }> {
return adminRequest.post(`${A}/settlement-bills/${billId}/bad-debt-write-off`, body ?? {}); return adminRequest.post(`${A}/settlement-bills/${billId}/bad-debt-write-off`, body ?? {});
} }
@@ -188,10 +188,39 @@ export async function getCreditLedger(params: {
page: number; page: number;
per_page: number; per_page: number;
ledger_source: string; ledger_source: string;
truncated?: boolean;
}> { }> {
return adminRequest.get(`${A}/credit-ledger`, { params }); return adminRequest.get(`${A}/credit-ledger`, { params });
} }
export type SettlementOperationRow = {
kind: "payment" | "adjustment" | "reversal" | "bad_debt";
record_id: number;
bill_id: number;
amount: number;
summary: string;
detail: string | null;
sort_at: string | null;
};
export async function getSettlementOperations(params: {
admin_site_id: number;
settlement_period_id: number;
bill_id?: number;
keyword?: string;
operation_type?: "all" | "payment" | "adjustment" | "reversal" | "bad_debt";
page?: number;
per_page?: number;
}): Promise<{
items: SettlementOperationRow[];
total: number;
page: number;
per_page: number;
last_page: number;
}> {
return adminRequest.get(`${A}/settlement-operations`, { params });
}
export async function getSettlementBills(params?: { export async function getSettlementBills(params?: {
settlement_period_id?: number; settlement_period_id?: number;
admin_site_id?: number; admin_site_id?: number;
@@ -243,7 +272,15 @@ export type SettlementBillPaymentRow = {
export async function getSettlementPayments(params?: { export async function getSettlementPayments(params?: {
settlement_period_id?: number; settlement_period_id?: number;
admin_site_id?: number; admin_site_id?: number;
}): Promise<{ items: SettlementPaymentRow[] }> { page?: number;
per_page?: number;
}): Promise<{
items: SettlementPaymentRow[];
total: number;
page: number;
per_page: number;
last_page: number;
}> {
return adminRequest.get(`${A}/settlement-payments`, { params }); return adminRequest.get(`${A}/settlement-payments`, { params });
} }
@@ -267,7 +304,15 @@ export async function getSettlementAdjustments(params?: {
settlement_period_id?: number; settlement_period_id?: number;
admin_site_id?: number; admin_site_id?: number;
adjustment_type?: string; adjustment_type?: string;
}): Promise<{ items: SettlementAdjustmentRow[] }> { page?: number;
per_page?: number;
}): Promise<{
items: SettlementAdjustmentRow[];
total: number;
page: number;
per_page: number;
last_page: number;
}> {
return adminRequest.get(`${A}/settlement-adjustments`, { params }); return adminRequest.get(`${A}/settlement-adjustments`, { params });
} }

View File

@@ -14,8 +14,10 @@
"balanceAdjustmentHint": "A reason is required; each change is recorded in the adjustment ledger. Balance cannot be edited via Save.", "balanceAdjustmentHint": "A reason is required; each change is recorded in the adjustment ledger. Balance cannot be edited via Save.",
"balanceAdjustmentTitle": "Balance adjustment", "balanceAdjustmentTitle": "Balance adjustment",
"cancel": "Cancel", "cancel": "Cancel",
"comboTriggerPlays": "Combo trigger plays (comma separated)", "comboTriggerPlays": "Combo trigger plays",
"comboTriggerPlaysPlaceholder": "Enter play codes separated by commas, for example straight,ibox", "comboTriggerPlaysHint": "First-prize wins on a selected play can burst the pool without reaching the threshold. Leave all unchecked to disable this rule.",
"comboTriggerPlaysEmpty": "No play types available. Enable plays in Play configuration first.",
"comboTriggerPlaysUnknown": "Unknown",
"configTitle": "Jackpot pool configuration", "configTitle": "Jackpot pool configuration",
"confirmAdjustmentDescription": "This writes a ledger entry and updates the pool balance. Verify amount and reason.", "confirmAdjustmentDescription": "This writes a ledger entry and updates the pool balance. Verify amount and reason.",
"confirmAdjustmentTitle": "Confirm pool balance adjustment?", "confirmAdjustmentTitle": "Confirm pool balance adjustment?",

View File

@@ -20,7 +20,23 @@
"createAgentNode": "Agent node", "createAgentNode": "Agent node",
"createAgentNodePlaceholder": "Select agent node", "createAgentNodePlaceholder": "Select agent node",
"createAgentRequired": "Your account is not bound to an agent node. Sign in with an agent account, or as super admin pick a valid site and agent.", "createAgentRequired": "Your account is not bound to an agent node. Sign in with an agent account, or as super admin pick a valid site and agent.",
"createDialogDesc": "Manually register a main-site player to the lottery platform. Usually this is created automatically through SSO login.", "createDialogDesc": "Register a player manually. Wallet players need a main-site SSO ID; credit players need lottery login credentials and a credit limit.",
"createFundingMode": "Funding mode",
"createFundingWallet": "Main-site wallet (SSO)",
"createFundingCredit": "Credit line (lottery login)",
"createLoginPassword": "Initial password",
"createLoginPasswordPlaceholder": "At least 6 characters",
"createLoginRequired": "Enter login username and initial password",
"createUsernameRequired": "Enter login username",
"createSitePlayerIdOptional": "Optional; auto-generated if empty",
"createSuccessNative": "Player {{name}} created — sign in on the lottery site",
"editCreditLimit": "Credit limit",
"editDetailLoading": "Loading player details…",
"usernameInvalidCharset": "Username may only contain letters, digits, dots, underscores, and hyphens",
"passwordMinLength": "Initial password must be at least 6 characters",
"creditLimitInvalid": "Credit limit must be a non-negative integer",
"creditCapLoading": "Agent grantable credit is still loading — try again shortly",
"creditLimitExceeded": "Credit limit cannot exceed the agent's grantable amount",
"createDialogTitle": "Create player", "createDialogTitle": "Create player",
"createFailed": "Failed to create player", "createFailed": "Failed to create player",
"createPlayer": "Create player", "createPlayer": "Create player",

View File

@@ -45,8 +45,10 @@
"forceTriggerGapPlaceholder": "बलपूर्वक ट्रिगर अन्तर प्रविष्ट गर्नुहोस्", "forceTriggerGapPlaceholder": "बलपूर्वक ट्रिगर अन्तर प्रविष्ट गर्नुहोस्",
"minBetAmount": "न्यूनतम सहभागिता बेट रकम (मुख्य एकाइ)", "minBetAmount": "न्यूनतम सहभागिता बेट रकम (मुख्य एकाइ)",
"minBetAmountPlaceholder": "न्यूनतम बेट रकम प्रविष्ट गर्नुहोस्", "minBetAmountPlaceholder": "न्यूनतम बेट रकम प्रविष्ट गर्नुहोस्",
"comboTriggerPlays": "कम्बो ट्रिगर प्ले (comma-separated)", "comboTriggerPlays": "कम्बो ट्रिगर प्ले",
"comboTriggerPlaysPlaceholder": "प्ले कोडहरू अल्पविरामले छुट्याएर लेख्नुहोस्, जस्तै straight,ibox", "comboTriggerPlaysHint": "छानिएका प्लेमा प्रथम पुरस्कार जित्दा थ्रेसहोल्ड नपुगी पनि पूल बर्स्ट हुन सक्छ। सबै अनचेक राख्दा यो नियम बन्द हुन्छ।",
"comboTriggerPlaysEmpty": "कुनै प्ले उपलब्ध छैन। पहिले प्ले कन्फिगरेसनमा सक्षम गर्नुहोस्।",
"comboTriggerPlaysUnknown": "अमान्य",
"status": "स्थिति", "status": "स्थिति",
"disabled": "बन्द", "disabled": "बन्द",
"enabled": "खुला", "enabled": "खुला",

View File

@@ -20,7 +20,23 @@
"createAgentNode": "एजेन्ट नोड", "createAgentNode": "एजेन्ट नोड",
"createAgentNodePlaceholder": "एजेन्ट नोड छान्नुहोस्", "createAgentNodePlaceholder": "एजेन्ट नोड छान्नुहोस्",
"createAgentRequired": "तपाईंको खाता एजेन्ट नोडसँग जोडिएको छैन। एजेन्ट खाताबाट लगइन गर्नुहोस्, वा सुपर एडमिनले मान्य साइट र एजेन्ट छान्नुहोस्।", "createAgentRequired": "तपाईंको खाता एजेन्ट नोडसँग जोडिएको छैन। एजेन्ट खाताबाट लगइन गर्नुहोस्, वा सुपर एडमिनले मान्य साइट र एजेन्ट छान्नुहोस्।",
"createDialogDesc": "मुख्य साइटको खेलाडीलाई लटरी प्लेटफर्ममा म्यानुअल दर्ता गर्नुहोस्। प्रायः SSO लगइनबाट स्वतः सिर्जना हुन्छ।", "createDialogDesc": "खेलाडी म्यानुअल दर्ता गर्नुहोस्। वालेट खेलाडीलाई मुख्य साइट SSO ID चाहिन्छ; क्रेडिट खेलाडीलाई लटरी लगइन र क्रेडिट सीमा चाहिन्छ।",
"createFundingMode": "फन्डिङ मोड",
"createFundingWallet": "मुख्य साइट वालेट (SSO)",
"createFundingCredit": "क्रेडिट लाइन (लटरी लगइन)",
"createLoginPassword": "प्रारम्भिक पासवर्ड",
"createLoginPasswordPlaceholder": "कम्तीमा ६ वर्ण",
"createLoginRequired": "लगइन प्रयोगकर्ता नाम र प्रारम्भिक पासवर्ड लेख्नुहोस्",
"createUsernameRequired": "लगइन प्रयोगकर्ता नाम लेख्नुहोस्",
"createSitePlayerIdOptional": "वैकल्पिक; खाली छोड्दा स्वतः सिर्जना हुन्छ",
"createSuccessNative": "खेलाडी {{name}} सिर्जना भयो — लटरी साइटमा लगइन गर्नुहोस्",
"editCreditLimit": "क्रेडिट सीमा",
"editDetailLoading": "खेलाडी विवरण लोड हुँदैछ…",
"usernameInvalidCharset": "प्रयोगकर्ता नाममा अक्षर, अंक, डट, अन्डरस्कोर र हाइफन मात्र हुन सक्छ",
"passwordMinLength": "प्रारम्भिक पासवर्ड कम्तीमा ६ वर्ण हुनुपर्छ",
"creditLimitInvalid": "क्रेडिट सीमा नऋणात्मक पूर्णांक हुनुपर्छ",
"creditCapLoading": "एजेन्ट क्रेडिट लोड हुँदैछ — केही बेरपछि पुनः प्रयास गर्नुहोस्",
"creditLimitExceeded": "क्रेडिट सीमा एजेन्टको दिन सकिने रकमभन्दा बढी हुन सक्दैन",
"createDialogTitle": "खेलाडी सिर्जना", "createDialogTitle": "खेलाडी सिर्जना",
"createFailed": "खेलाडी सिर्जना असफल भयो", "createFailed": "खेलाडी सिर्जना असफल भयो",
"createPlayer": "खेलाडी सिर्जना", "createPlayer": "खेलाडी सिर्जना",

View File

@@ -35,7 +35,9 @@
"forceTriggerGap": "强制爆池间隔(期)", "forceTriggerGap": "强制爆池间隔(期)",
"minBetAmount": "最低下注额", "minBetAmount": "最低下注额",
"comboTriggerPlays": "组合触发玩法", "comboTriggerPlays": "组合触发玩法",
"comboTriggerPlaysPlaceholder": "straight,ibox", "comboTriggerPlaysHint": "勾选后,该玩法中头奖即可触发奖池(无需达到阈值)。不选表示不启用此条件。",
"comboTriggerPlaysEmpty": "暂无可用玩法,请先在玩法配置中启用。",
"comboTriggerPlaysUnknown": "已失效",
"status": "开关", "status": "开关",
"disabled": "关闭", "disabled": "关闭",
"enabled": "开启", "enabled": "开启",

View File

@@ -80,7 +80,23 @@
"delete": "删除", "delete": "删除",
"createDialogTitle": "新建玩家", "createDialogTitle": "新建玩家",
"editDialogTitle": "编辑玩家", "editDialogTitle": "编辑玩家",
"createDialogDesc": "手动注册一个主站玩家到彩票平台,通常由 SSO 登录自动创建。", "createDialogDesc": "手动注册玩家到彩票平台。钱包盘需主站 SSO 标识;信用盘需彩票端登录账号与授信。",
"createFundingMode": "资金模式",
"createFundingWallet": "主站钱包SSO",
"createFundingCredit": "信用盘(彩票端登录)",
"createLoginPassword": "初始密码",
"createLoginPasswordPlaceholder": "至少 6 位",
"createLoginRequired": "请填写登录账号与初始密码",
"createUsernameRequired": "请填写登录账号",
"createSitePlayerIdOptional": "选填,留空将自动生成",
"createSuccessNative": "玩家 {{name}} 已创建,请使用彩票端登录",
"editCreditLimit": "授信额度",
"editDetailLoading": "加载玩家详情…",
"usernameInvalidCharset": "登录账号只能使用字母、数字、点(.)、下划线和连字符",
"passwordMinLength": "初始密码至少 6 位",
"creditLimitInvalid": "授信额度必须为不小于 0 的整数",
"creditCapLoading": "代理可下发额度加载中,请稍后再保存",
"creditLimitExceeded": "授信额度不能超过当前代理可下发额度",
"editDialogDesc": "编辑玩家信息。", "editDialogDesc": "编辑玩家信息。",
"siteCode": "主站编号", "siteCode": "主站编号",
"siteCodePlaceholder": "例如 main_site", "siteCodePlaceholder": "例如 main_site",

View File

@@ -37,10 +37,7 @@ import { Switch } from "@/components/ui/switch";
import { useAsyncEffect } from "@/hooks/use-async-effect"; import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useConfirmAction } from "@/hooks/use-confirm-action"; import { useConfirmAction } from "@/hooks/use-confirm-action";
import { useTranslationRef } from "@/hooks/use-translation-ref"; import { useTranslationRef } from "@/hooks/use-translation-ref";
import { import { percentValueToUi } from "@/lib/admin-rate-percent";
percentValueToUi,
parsePercentUi,
} from "@/lib/admin-rate-percent";
import { import {
isLineRootAgentNode, isLineRootAgentNode,
isRootProfileEditableByActor, isRootProfileEditableByActor,
@@ -163,7 +160,7 @@ export function AgentsConsole(): React.ReactElement {
const hasUsersManagePermission = const hasUsersManagePermission =
isSuperAdmin || isSuperAdmin ||
adminHasAnyPermission(profile?.permissions, [PRD_USERS_MANAGE]); adminHasAnyPermission(profile?.permissions, [PRD_USERS_MANAGE]);
const [rootProfile, setRootProfile] = useState<AgentProfileRow | null>(null);
const selectedNodeIdFromUrl = Number.parseInt( const selectedNodeIdFromUrl = Number.parseInt(
searchParams.get("agent_node_id") ?? searchParams.get("node") ?? "", searchParams.get("agent_node_id") ?? searchParams.get("node") ?? "",
10, 10,
@@ -329,13 +326,10 @@ export function AgentsConsole(): React.ReactElement {
return rootNode?.site_code?.trim() ?? ""; return rootNode?.site_code?.trim() ?? "";
}, [adminSiteId, boundAgent?.site_code, boundSite, rootNode?.site_code, siteOptions]); }, [adminSiteId, boundAgent?.site_code, boundSite, rootNode?.site_code, siteOptions]);
const selectedNode = useMemo( const selectedNode =
() => selectedNodeId !== null
selectedNodeId !== null ? (flatNodes.find((node) => node.id === selectedNodeId) ?? null)
? (flatNodes.find((node) => node.id === selectedNodeId) ?? null) : null;
: null,
[flatNodes, selectedNodeId],
);
const lineRootProfileLocked = useMemo( const lineRootProfileLocked = useMemo(
() => isLineRootAgentNode(selectedNode) && !isSuperAdmin, () => isLineRootAgentNode(selectedNode) && !isSuperAdmin,
@@ -392,7 +386,7 @@ export function AgentsConsole(): React.ReactElement {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [tRef]); }, [setAdminSiteId, tRef]);
useEffect(() => { useEffect(() => {
if (!canViewAgents) { if (!canViewAgents) {
@@ -492,17 +486,6 @@ export function AgentsConsole(): React.ReactElement {
}; };
}, [selectedNode?.id, nodeDialogOpen]); }, [selectedNode?.id, nodeDialogOpen]);
useAsyncEffect(() => {
if (rootNode === null) {
setRootProfile(null);
return;
}
void getAgentNodeProfile(rootNode.id)
.then((p) => setRootProfile(p))
.catch(() => setRootProfile(null));
}, [rootNode?.id]);
const isSiteAdmin = isSiteAdminOperator(profile); const isSiteAdmin = isSiteAdminOperator(profile);
const canShowDownlineTab = selectedNode !== null; const canShowDownlineTab = selectedNode !== null;
@@ -870,13 +853,6 @@ export function AgentsConsole(): React.ReactElement {
(hasSiteContext && adminSiteId === null) || (hasSiteContext && adminSiteId === null) ||
(adminSiteId !== null && loading)); (adminSiteId !== null && loading));
const openAddAgent = (): void => {
const parent = selectedNode ?? rootNode;
if (parent !== null) {
openCreateChildForNode(parent);
}
};
const saveNode = async () => { const saveNode = async () => {
if (!nodeName.trim()) { if (!nodeName.trim()) {
toast.error(t("nameRequired", { defaultValue: "请填写代理名称" })); toast.error(t("nameRequired", { defaultValue: "请填写代理名称" }));
@@ -1006,21 +982,6 @@ export function AgentsConsole(): React.ReactElement {
} }
}; };
const addParent = selectedNode ?? rootNode;
const parentProfileForAdd = useMemo(() => {
if (addParent === null) {
return null;
}
if (addParent.id === selectedNodeId && selectedProfile !== null) {
return selectedProfile;
}
if (rootNode !== null && addParent.id === rootNode.id) {
return rootProfile;
}
return null;
}, [addParent, rootNode, rootProfile, selectedNodeId, selectedProfile]);
if (!canViewAgents && !canProvisionLine) { if (!canViewAgents && !canProvisionLine) {
return ( return (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">

View File

@@ -307,6 +307,14 @@ export function AgentsPlayersPanel({
); );
return; return;
} }
if (effectiveAgentId !== null && parentAvailableCredit === null) {
toast.error(
t("playersPanel.creditCapLoading", {
defaultValue: "代理可下发额度加载中,请稍后再保存",
}),
);
return;
}
if ( if (
parentAvailableCredit !== null && parentAvailableCredit !== null &&
parsedCreditLimit > parentAvailableCredit parsedCreditLimit > parentAvailableCredit
@@ -485,6 +493,18 @@ export function AgentsPlayersPanel({
); );
return; return;
} }
if (
parsedCreditLimit !== baselineCredit &&
effectiveAgentId !== null &&
parentAvailableCredit === null
) {
toast.error(
t("playersPanel.creditCapLoading", {
defaultValue: "代理可下发额度加载中,请稍后再保存",
}),
);
return;
}
if ( if (
parentAvailableCredit !== null && parentAvailableCredit !== null &&
parsedCreditLimit > baselineCredit + parentAvailableCredit parsedCreditLimit > baselineCredit + parentAvailableCredit

View File

@@ -12,6 +12,8 @@ import {
postAdminJackpotPoolAdjustment, postAdminJackpotPoolAdjustment,
putAdminJackpotPool, putAdminJackpotPool,
} from "@/api/admin-jackpot"; } from "@/api/admin-jackpot";
import { useAdminPlayTypeCatalog } from "@/hooks/use-admin-play-type-catalog";
import { useCachedPlayTypeOptions } from "@/hooks/use-cached-play-type-options";
import { useConfirmAction } from "@/hooks/use-confirm-action"; import { useConfirmAction } from "@/hooks/use-confirm-action";
import { adminHasAnyPermission } from "@/lib/admin-permissions"; import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { formatAdminMinorDecimal, parseAdminMajorToMinor } from "@/lib/money"; import { formatAdminMinorDecimal, parseAdminMajorToMinor } from "@/lib/money";
@@ -23,6 +25,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { import {
Dialog, Dialog,
@@ -53,7 +56,7 @@ type Draft = {
payout_rate: string; payout_rate: string;
force_trigger_draw_gap: string; force_trigger_draw_gap: string;
min_bet_amount: string; min_bet_amount: string;
combo_trigger_play_codes: string; combo_trigger_play_codes: string[];
status: string; status: string;
manual_burst_draw_id: string; manual_burst_draw_id: string;
}; };
@@ -71,7 +74,7 @@ function toDraft(p: AdminJackpotPoolRow): Draft {
payout_rate: ratioToPercentUi(p.payout_rate), payout_rate: ratioToPercentUi(p.payout_rate),
force_trigger_draw_gap: String(p.force_trigger_draw_gap), force_trigger_draw_gap: String(p.force_trigger_draw_gap),
min_bet_amount: formatAdminMinorDecimal(p.min_bet_amount, p.currency_code), min_bet_amount: formatAdminMinorDecimal(p.min_bet_amount, p.currency_code),
combo_trigger_play_codes: p.combo_trigger_play_codes.join(","), combo_trigger_play_codes: p.combo_trigger_play_codes.map((code) => code.toLowerCase()),
status: String(p.status), status: String(p.status),
manual_burst_draw_id: "", manual_burst_draw_id: "",
}; };
@@ -84,6 +87,8 @@ type JackpotPoolsConsoleProps = {
export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsoleProps) { export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsoleProps) {
const { t } = useTranslation(["jackpot", "common"]); const { t } = useTranslation(["jackpot", "common"]);
const tRef = useTranslationRef(["jackpot", "common"]); const tRef = useTranslationRef(["jackpot", "common"]);
useAdminPlayTypeCatalog();
const playOptions = useCachedPlayTypeOptions();
const profile = useAdminProfile(); const profile = useAdminProfile();
const canManageJackpot = adminHasAnyPermission(profile?.permissions, [PRD_JACKPOT_MANAGE]); const canManageJackpot = adminHasAnyPermission(profile?.permissions, [PRD_JACKPOT_MANAGE]);
const canManualBurst = adminHasAnyPermission(profile?.permissions, [PRD_JACKPOT_MANUAL_BURST]); const canManualBurst = adminHasAnyPermission(profile?.permissions, [PRD_JACKPOT_MANUAL_BURST]);
@@ -139,6 +144,15 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
})); }));
}; };
const toggleComboPlay = (poolId: number, code: string, checked: boolean) => {
const normalized = code.toLowerCase();
const current = drafts[poolId]?.combo_trigger_play_codes ?? [];
const next = checked
? [...new Set([...current, normalized])]
: current.filter((item) => item !== normalized);
updateDraft(poolId, { combo_trigger_play_codes: next });
};
const updateAdjustmentDraft = (id: number, patch: Partial<AdjustmentDraft>) => { const updateAdjustmentDraft = (id: number, patch: Partial<AdjustmentDraft>) => {
setAdjustmentDrafts((prev) => ({ setAdjustmentDrafts((prev) => ({
...prev, ...prev,
@@ -164,10 +178,7 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
payout_rate: percentUiToRatio(d.payout_rate), payout_rate: percentUiToRatio(d.payout_rate),
force_trigger_draw_gap: Number.parseInt(d.force_trigger_draw_gap, 10), force_trigger_draw_gap: Number.parseInt(d.force_trigger_draw_gap, 10),
min_bet_amount: parseAdminMajorToMinor(d.min_bet_amount, p.currency_code) ?? 0, min_bet_amount: parseAdminMajorToMinor(d.min_bet_amount, p.currency_code) ?? 0,
combo_trigger_play_codes: d.combo_trigger_play_codes combo_trigger_play_codes: d.combo_trigger_play_codes,
.split(",")
.map((v) => v.trim().toLowerCase())
.filter(Boolean),
status: Number.parseInt(d.status, 10), status: Number.parseInt(d.status, 10),
}); });
toast.success(t("saveSuccess")); toast.success(t("saveSuccess"));
@@ -345,15 +356,58 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
onChange={(e) => updateDraft(p.id, { force_trigger_draw_gap: e.target.value })} onChange={(e) => updateDraft(p.id, { force_trigger_draw_gap: e.target.value })}
/> />
</div> </div>
<div className="space-y-1.5 sm:col-span-2 lg:col-span-3"> <div className="space-y-2 sm:col-span-2 lg:col-span-3">
<Label htmlFor={`combo-${p.id}`}>{t("comboTriggerPlays")}</Label> <div>
<Input <Label id={`combo-label-${p.id}`}>{t("comboTriggerPlays")}</Label>
id={`combo-${p.id}`} <p className="text-xs text-muted-foreground">{t("comboTriggerPlaysHint")}</p>
className="font-mono" </div>
value={d.combo_trigger_play_codes} {playOptions.length === 0 ? (
placeholder={t("comboTriggerPlaysPlaceholder")} <p className="text-sm text-muted-foreground">{t("comboTriggerPlaysEmpty")}</p>
onChange={(e) => updateDraft(p.id, { combo_trigger_play_codes: e.target.value })} ) : (
/> <div
role="group"
aria-labelledby={`combo-label-${p.id}`}
className="flex flex-wrap gap-2 rounded-md border border-border/60 p-2.5"
>
{playOptions.map((option) => {
const checked = d.combo_trigger_play_codes.includes(option.code);
return (
<label
key={option.code}
className="inline-flex cursor-pointer items-center gap-2 rounded-md border border-border/60 px-2.5 py-1.5 text-sm has-disabled:cursor-not-allowed has-disabled:opacity-60"
>
<Checkbox
checked={checked}
disabled={!canManageJackpot}
aria-label={option.label}
onCheckedChange={(value) => toggleComboPlay(p.id, option.code, value === true)}
/>
<span>{option.label}</span>
<span className="font-mono text-xs text-muted-foreground">{option.code}</span>
</label>
);
})}
{d.combo_trigger_play_codes
.filter((code) => !playOptions.some((option) => option.code === code))
.map((code) => (
<label
key={`orphan-${code}`}
className="inline-flex cursor-pointer items-center gap-2 rounded-md border border-dashed border-amber-500/50 bg-amber-500/5 px-2.5 py-1.5 text-sm"
>
<Checkbox
checked
disabled={!canManageJackpot}
aria-label={code}
onCheckedChange={() => toggleComboPlay(p.id, code, false)}
/>
<span className="font-mono text-xs">{code}</span>
<span className="text-xs text-amber-700 dark:text-amber-400">
{t("comboTriggerPlaysUnknown")}
</span>
</label>
))}
</div>
)}
</div> </div>
</fieldset> </fieldset>

View File

@@ -11,15 +11,17 @@ import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref"; import { useTranslationRef } from "@/hooks/use-translation-ref";
import { toast } from "sonner"; import { toast } from "sonner";
import { getAgentTree } from "@/api/admin-agents"; import { getAgentNodeProfile, getAgentTree } from "@/api/admin-agents";
import { import {
deleteAdminPlayer, deleteAdminPlayer,
getAdminPlayer,
getAdminPlayers, getAdminPlayers,
postAdminPlayer, postAdminPlayer,
postAdminPlayerFreeze, postAdminPlayerFreeze,
postAdminPlayerUnfreeze, postAdminPlayerUnfreeze,
putAdminPlayer, putAdminPlayer,
} from "@/api/admin-player"; } from "@/api/admin-player";
import { PlayerCreditLimitField } from "@/components/admin/player-credit-limit-field";
import { flattenAgentTree, type FlatAgentOption } from "@/lib/admin-agent-tree"; import { flattenAgentTree, type FlatAgentOption } from "@/lib/admin-agent-tree";
import { useAdminSiteCodeOptions } from "@/hooks/use-admin-site-code-options"; import { useAdminSiteCodeOptions } from "@/hooks/use-admin-site-code-options";
import { AdminAgentCell, AdminAgentHead } from "@/components/admin/admin-agent-columns"; import { AdminAgentCell, AdminAgentHead } from "@/components/admin/admin-agent-columns";
@@ -65,7 +67,14 @@ import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
import { adminPlayerDetailPath } from "@/lib/admin-player-paths"; import { adminPlayerDetailPath } from "@/lib/admin-player-paths";
import { useAdminProfile } from "@/stores/admin-session"; import { useAdminProfile } from "@/stores/admin-session";
import { PlayerFundingModeBadge } from "@/components/admin/player-funding-badges"; import { PlayerFundingModeBadge } from "@/components/admin/player-funding-badges";
import { playerBalanceCells } from "@/lib/admin-player-display"; import {
isCreditFundingPlayer,
playerBalanceCells,
} from "@/lib/admin-player-display";
import {
validateNativePlayerPassword,
validateNativePlayerUsername,
} from "@/lib/admin-input-validation";
import { ADMIN_SELECT_FILTER_ALL, adminSiteSelectLabel } from "@/lib/admin-select-display"; import { ADMIN_SELECT_FILTER_ALL, adminSiteSelectLabel } from "@/lib/admin-select-display";
import { formatAdminMinorUnits } from "@/lib/money"; import { formatAdminMinorUnits } from "@/lib/money";
import { LotteryApiBizError } from "@/types/api/errors"; import { LotteryApiBizError } from "@/types/api/errors";
@@ -84,6 +93,8 @@ const PLAYER_STATUS_OPTIONS = [
{ value: 2, label: "statusBanned" }, { value: 2, label: "statusBanned" },
]; ];
type PlayerFundingModeChoice = "wallet" | "credit";
function parseRiskTagsInput(text: string): string[] { function parseRiskTagsInput(text: string): string[] {
return Array.from( return Array.from(
new Set( new Set(
@@ -138,6 +149,11 @@ export function PlayersConsole(): React.ReactElement {
const [formStatus, setFormStatus] = useState(0); const [formStatus, setFormStatus] = useState(0);
const [formRiskTags, setFormRiskTags] = useState(""); const [formRiskTags, setFormRiskTags] = useState("");
const [formAgentNodeId, setFormAgentNodeId] = useState<number | undefined>(undefined); const [formAgentNodeId, setFormAgentNodeId] = useState<number | undefined>(undefined);
const [formFundingMode, setFormFundingMode] = useState<PlayerFundingModeChoice>("wallet");
const [formPassword, setFormPassword] = useState("");
const [formCreditLimit, setFormCreditLimit] = useState("");
const [parentAvailableCredit, setParentAvailableCredit] = useState<number | null>(null);
const [accountDetailLoading, setAccountDetailLoading] = useState(false);
const [createAgentOptions, setCreateAgentOptions] = useState<FlatAgentOption[]>([]); const [createAgentOptions, setCreateAgentOptions] = useState<FlatAgentOption[]>([]);
const [createAgentLoading, setCreateAgentLoading] = useState(false); const [createAgentLoading, setCreateAgentLoading] = useState(false);
@@ -150,6 +166,17 @@ export function PlayersConsole(): React.ReactElement {
[items, editingAccountId], [items, editingAccountId],
); );
const effectiveAgentNodeId =
accountMode === "edit" &&
editingPlayer?.agent_node_id != null &&
editingPlayer.agent_node_id > 0
? editingPlayer.agent_node_id
: formAgentNodeId != null && formAgentNodeId > 0
? formAgentNodeId
: (boundAgent?.id ?? null);
const editingUsesCredit = editingPlayer != null && isCreditFundingPlayer(editingPlayer);
const showSiteFilter = const showSiteFilter =
isSuperAdmin || (profile?.accessible_sites?.length ?? 0) > 1; isSuperAdmin || (profile?.accessible_sites?.length ?? 0) > 1;
@@ -227,7 +254,11 @@ export function PlayersConsole(): React.ReactElement {
setFormAgentNodeId(boundAgent?.id); setFormAgentNodeId(boundAgent?.id);
setFormSitePlayerId(""); setFormSitePlayerId("");
setFormUsername(""); setFormUsername("");
setFormPassword("");
setFormNickname(""); setFormNickname("");
setFormFundingMode("wallet");
setFormCreditLimit("");
setParentAvailableCredit(null);
setFormDefaultCurrency("NPR"); setFormDefaultCurrency("NPR");
setFormStatus(0); setFormStatus(0);
setFormRiskTags(""); setFormRiskTags("");
@@ -280,9 +311,43 @@ export function PlayersConsole(): React.ReactElement {
}; };
}, [accountOpen, accountMode, canPickAgentOnCreate, formSiteCode, siteOptions]); }, [accountOpen, accountMode, canPickAgentOnCreate, formSiteCode, siteOptions]);
function openEditAccount(row: AdminPlayerRow): void { useAsyncEffect(() => {
setAccountMode("edit"); const needsCreditCap =
setEditingAccountId(row.id); accountOpen &&
((accountMode === "create" && formFundingMode === "credit") ||
(accountMode === "edit" && editingUsesCredit));
if (!needsCreditCap || effectiveAgentNodeId === null) {
if (!needsCreditCap) {
setParentAvailableCredit(null);
}
return;
}
let cancelled = false;
void getAgentNodeProfile(effectiveAgentNodeId)
.then((profile) => {
if (!cancelled) {
setParentAvailableCredit(profile.available_credit ?? null);
}
})
.catch(() => {
if (!cancelled) {
setParentAvailableCredit(null);
}
});
return () => {
cancelled = true;
};
}, [
accountMode,
accountOpen,
editingUsesCredit,
effectiveAgentNodeId,
formFundingMode,
]);
function applyEditFormFromPlayer(row: AdminPlayerRow): void {
setFormSiteCode(row.site_code); setFormSiteCode(row.site_code);
setFormSitePlayerId(row.site_player_id); setFormSitePlayerId(row.site_player_id);
setFormUsername(row.username ?? ""); setFormUsername(row.username ?? "");
@@ -290,7 +355,26 @@ export function PlayersConsole(): React.ReactElement {
setFormDefaultCurrency(row.default_currency); setFormDefaultCurrency(row.default_currency);
setFormStatus(row.status); setFormStatus(row.status);
setFormRiskTags((row.risk_tags ?? []).join(", ")); setFormRiskTags((row.risk_tags ?? []).join(", "));
setFormCreditLimit(String(row.credit_limit ?? 0));
}
function openEditAccount(row: AdminPlayerRow): void {
setAccountMode("edit");
setEditingAccountId(row.id);
applyEditFormFromPlayer(row);
setAccountOpen(true); setAccountOpen(true);
setAccountDetailLoading(true);
void getAdminPlayer(row.id)
.then((full) => {
setItems((prev) => prev.map((item) => (item.id === full.id ? full : item)));
applyEditFormFromPlayer(full);
})
.catch(() => {
toast.error(t("loadFailed"));
})
.finally(() => {
setAccountDetailLoading(false);
});
} }
function handleAccountDialogOpenChange(open: boolean): void { function handleAccountDialogOpenChange(open: boolean): void {
@@ -310,30 +394,84 @@ export function PlayersConsole(): React.ReactElement {
toast.error(t("siteCodeRequired")); toast.error(t("siteCodeRequired"));
return; return;
} }
if (formSitePlayerId.trim() === "") {
toast.error(t("sitePlayerIdRequired"));
return;
}
if (canPickAgentOnCreate && (formAgentNodeId === undefined || formAgentNodeId <= 0)) { if (canPickAgentOnCreate && (formAgentNodeId === undefined || formAgentNodeId <= 0)) {
toast.error(t("createAgentRequired")); toast.error(t("createAgentRequired"));
return; return;
} }
const isCreditCreate = formFundingMode === "credit";
if (!isCreditCreate && formSitePlayerId.trim() === "") {
toast.error(t("sitePlayerIdRequired"));
return;
}
if (isCreditCreate) {
if (formUsername.trim() === "" || formPassword.trim() === "") {
toast.error(t("createLoginRequired"));
return;
}
const usernameIssue = validateNativePlayerUsername(formUsername);
if (usernameIssue === "invalid_charset") {
toast.error(t("usernameInvalidCharset"));
return;
}
const passwordIssue = validateNativePlayerPassword(formPassword);
if (passwordIssue === "too_short") {
toast.error(t("passwordMinLength"));
return;
}
}
const parsedCreditLimit =
formCreditLimit.trim() === "" ? 0 : Number.parseInt(formCreditLimit, 10);
if (
isCreditCreate &&
(Number.isNaN(parsedCreditLimit) ||
parsedCreditLimit < 0 ||
!Number.isInteger(parsedCreditLimit))
) {
toast.error(t("creditLimitInvalid"));
return;
}
if (
isCreditCreate &&
effectiveAgentNodeId !== null &&
parentAvailableCredit === null
) {
toast.error(t("creditCapLoading"));
return;
}
if (
isCreditCreate &&
parentAvailableCredit !== null &&
parsedCreditLimit > parentAvailableCredit
) {
toast.error(t("creditLimitExceeded"));
return;
}
setAccountSaving(true); setAccountSaving(true);
try { try {
const created = await postAdminPlayer({ const created = await postAdminPlayer({
site_code: formSiteCode.trim(), site_code: formSiteCode.trim(),
site_player_id: formSitePlayerId.trim(), ...(formSitePlayerId.trim() !== "" ? { site_player_id: formSitePlayerId.trim() } : {}),
username: formUsername.trim() || null, username: isCreditCreate ? formUsername.trim() : formUsername.trim() || null,
...(isCreditCreate ? { password: formPassword } : {}),
nickname: formNickname.trim() || null, nickname: formNickname.trim() || null,
default_currency: formDefaultCurrency, default_currency: formDefaultCurrency,
status: formStatus, status: formStatus,
...(canPickAgentOnCreate && formAgentNodeId ...(canPickAgentOnCreate && formAgentNodeId
? { agent_node_id: formAgentNodeId } ? { agent_node_id: formAgentNodeId }
: {}), : {}),
...(isCreditCreate ? { credit_limit: parsedCreditLimit } : {}),
}); });
setItems((prev) => [created, ...prev]); setItems((prev) => [created, ...prev]);
setTotal((t) => t + 1); setTotal((t) => t + 1);
toast.success(t("createSuccess", { name: created.username ?? created.site_player_id })); toast.success(
isCreditCreate
? t("createSuccessNative", { name: created.username ?? created.site_player_id })
: t("createSuccess", { name: created.username ?? created.site_player_id }),
);
handleAccountDialogOpenChange(false); handleAccountDialogOpenChange(false);
} catch (e) { } catch (e) {
const msg = e instanceof LotteryApiBizError ? e.message : t("createFailed"); const msg = e instanceof LotteryApiBizError ? e.message : t("createFailed");
@@ -364,6 +502,38 @@ export function PlayersConsole(): React.ReactElement {
body.risk_tags = nextRiskTags; body.risk_tags = nextRiskTags;
} }
if (editingUsesCredit) {
const baselineCredit = editingPlayer?.credit_limit ?? 0;
const parsedCreditLimit =
formCreditLimit.trim() === "" ? baselineCredit : Number.parseInt(formCreditLimit, 10);
if (
Number.isNaN(parsedCreditLimit) ||
parsedCreditLimit < 0 ||
!Number.isInteger(parsedCreditLimit)
) {
toast.error(t("creditLimitInvalid"));
return;
}
if (
parsedCreditLimit !== baselineCredit &&
effectiveAgentNodeId !== null &&
parentAvailableCredit === null
) {
toast.error(t("creditCapLoading"));
return;
}
if (
parentAvailableCredit !== null &&
parsedCreditLimit > baselineCredit + parentAvailableCredit
) {
toast.error(t("creditLimitExceeded"));
return;
}
if (parsedCreditLimit !== baselineCredit) {
body.credit_limit = parsedCreditLimit;
}
}
if (Object.keys(body).length === 0) { if (Object.keys(body).length === 0) {
toast.success(t("noChanges")); toast.success(t("noChanges"));
handleAccountDialogOpenChange(false); handleAccountDialogOpenChange(false);
@@ -704,6 +874,32 @@ export function PlayersConsole(): React.ReactElement {
<div className="space-y-3"> <div className="space-y-3">
{accountMode === "create" && ( {accountMode === "create" && (
<> <>
<div className="space-y-1.5">
<Label htmlFor="player-funding-mode">{t("createFundingMode")}</Label>
<Select
value={formFundingMode}
onValueChange={(value) => {
const mode = value === "credit" ? "credit" : "wallet";
setFormFundingMode(mode);
if (mode === "credit") {
setFormPassword("");
setFormCreditLimit("");
}
}}
>
<SelectTrigger id="player-funding-mode">
<SelectValue>
{formFundingMode === "credit"
? t("createFundingCredit")
: t("createFundingWallet")}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="wallet">{t("createFundingWallet")}</SelectItem>
<SelectItem value="credit">{t("createFundingCredit")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="player-site-code">{t("siteCode")}</Label> <Label htmlFor="player-site-code">{t("siteCode")}</Label>
{boundAgent && boundAgent.site_code ? ( {boundAgent && boundAgent.site_code ? (
@@ -778,22 +974,68 @@ export function PlayersConsole(): React.ReactElement {
<Input <Input
id="player-site-id" id="player-site-id"
value={formSitePlayerId} value={formSitePlayerId}
placeholder={t("sitePlayerIdPlaceholder")} placeholder={
formFundingMode === "credit"
? t("createSitePlayerIdOptional")
: t("sitePlayerIdPlaceholder")
}
onChange={(e) => setFormSitePlayerId(e.target.value)} onChange={(e) => setFormSitePlayerId(e.target.value)}
/> />
</div> </div>
{formFundingMode === "credit" ? (
<>
<div className="space-y-1.5">
<Label htmlFor="player-username">{t("username")}</Label>
<Input
id="player-username"
value={formUsername}
autoComplete="off"
placeholder={t("createUsernameRequired")}
onChange={(e) => setFormUsername(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="player-password">{t("createLoginPassword")}</Label>
<Input
id="player-password"
type="password"
value={formPassword}
autoComplete="new-password"
placeholder={t("createLoginPasswordPlaceholder")}
onChange={(e) => setFormPassword(e.target.value)}
/>
</div>
<PlayerCreditLimitField
id="player-credit-limit"
value={formCreditLimit}
onValueChange={setFormCreditLimit}
parentAvailableCredit={parentAvailableCredit}
/>
</>
) : (
<div className="space-y-1.5">
<Label htmlFor="player-username">{t("username")}</Label>
<Input
id="player-username"
value={formUsername}
placeholder={t("usernamePlaceholderOptional")}
onChange={(e) => setFormUsername(e.target.value)}
/>
</div>
)}
</> </>
)} )}
<div className="space-y-1.5"> {accountMode === "edit" ? (
<Label htmlFor="player-username">{t("username")}</Label> <div className="space-y-1.5">
<Input <Label htmlFor="player-username">{t("username")}</Label>
id="player-username" <Input
value={formUsername} id="player-username"
disabled={accountMode === "edit"} value={formUsername}
placeholder={accountMode === "create" ? t("usernamePlaceholderOptional") : ""} disabled
onChange={(e) => setFormUsername(e.target.value)} onChange={(e) => setFormUsername(e.target.value)}
/> />
</div> </div>
) : null}
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="player-nickname">{t("nickname")}</Label> <Label htmlFor="player-nickname">{t("nickname")}</Label>
<Input <Input
@@ -871,6 +1113,19 @@ export function PlayersConsole(): React.ReactElement {
onChange={(e) => setFormRiskTags(e.target.value)} onChange={(e) => setFormRiskTags(e.target.value)}
/> />
</div> </div>
{editingUsesCredit ? (
accountDetailLoading ? (
<p className="text-xs text-muted-foreground">{t("editDetailLoading")}</p>
) : (
<PlayerCreditLimitField
id="player-edit-credit-limit"
value={formCreditLimit}
onValueChange={setFormCreditLimit}
parentAvailableCredit={parentAvailableCredit}
baselineCreditLimit={editingPlayer?.credit_limit ?? 0}
/>
)
) : null}
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="player-edit-status">{t("status")}</Label> <Label htmlFor="player-edit-status">{t("status")}</Label>
<Select <Select
@@ -902,7 +1157,7 @@ export function PlayersConsole(): React.ReactElement {
</Button> </Button>
<Button <Button
type="button" type="button"
disabled={accountSaving} disabled={accountSaving || accountDetailLoading}
onClick={() => void submitAccount()} onClick={() => void submitAccount()}
> >
{accountSaving ? t("saving") : t("save")} {accountSaving ? t("saving") : t("save")}

View File

@@ -69,6 +69,7 @@ export function AgentBillDetail({
const paymentRef = useRef<HTMLDivElement>(null); const paymentRef = useRef<HTMLDivElement>(null);
const paymentIdempotencyKeyRef = useRef<string | null>(null); const paymentIdempotencyKeyRef = useRef<string | null>(null);
const adjustmentIdempotencyKeyRef = useRef<string | null>(null); const adjustmentIdempotencyKeyRef = useRef<string | null>(null);
const badDebtIdempotencyKeyRef = useRef<string | null>(null);
const { request: requestConfirm, ConfirmDialog, busy: confirmBusy } = useConfirmAction(); const { request: requestConfirm, ConfirmDialog, busy: confirmBusy } = useConfirmAction();
const createIdempotencyKey = useCallback((prefix: string): string => { const createIdempotencyKey = useCallback((prefix: string): string => {
@@ -82,6 +83,7 @@ export function AgentBillDetail({
useEffect(() => { useEffect(() => {
paymentIdempotencyKeyRef.current = null; paymentIdempotencyKeyRef.current = null;
adjustmentIdempotencyKeyRef.current = null; adjustmentIdempotencyKeyRef.current = null;
badDebtIdempotencyKeyRef.current = null;
}, [billId]); }, [billId]);
const load = useCallback(async () => { const load = useCallback(async () => {
@@ -257,6 +259,10 @@ export function AgentBillDetail({
return; return;
} }
if (!badDebtIdempotencyKeyRef.current) {
badDebtIdempotencyKeyRef.current = createIdempotencyKey("bd");
}
const idempotencyKey = badDebtIdempotencyKeyRef.current;
requestConfirm({ requestConfirm({
title: t("settlementBills.badDebtConfirmTitle", { defaultValue: "确认核销坏账?" }), title: t("settlementBills.badDebtConfirmTitle", { defaultValue: "确认核销坏账?" }),
description: t("settlementBills.badDebtConfirmDescription", { description: t("settlementBills.badDebtConfirmDescription", {
@@ -266,7 +272,8 @@ export function AgentBillDetail({
confirmVariant: "destructive", confirmVariant: "destructive",
onConfirm: async () => { onConfirm: async () => {
try { try {
await postSettlementBillBadDebtWriteOff(billId, { reason }); await postSettlementBillBadDebtWriteOff(billId, { reason, idempotency_key: idempotencyKey });
badDebtIdempotencyKeyRef.current = null;
await load(); await load();
await onUpdated?.(); await onUpdated?.();
toast.success(t("settlementBills.badDebtDone", { defaultValue: "已核销坏账" })); toast.success(t("settlementBills.badDebtDone", { defaultValue: "已核销坏账" }));

View File

@@ -357,7 +357,7 @@ export function SettlementCenterShell(): React.ReactElement {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [activePeriod, activePeriodId, activeView, periodsReady, router, siteId]); }, [activeBillScope, activePeriod, activePeriodId, activeView, periodsReady, router, siteId]);
const shellBootstrapping = const shellBootstrapping =
!sitesReady || (siteId !== null && !periodsReady); !sitesReady || (siteId !== null && !periodsReady);

View File

@@ -145,6 +145,7 @@ export function SettlementCreditLedgerPanel({
const [appliedReason, setAppliedReason] = useState<ReasonFilter>("all"); const [appliedReason, setAppliedReason] = useState<ReasonFilter>("all");
const [rows, setRows] = useState<SettlementCreditLedgerRow[]>([]); const [rows, setRows] = useState<SettlementCreditLedgerRow[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [truncated, setTruncated] = useState(false);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(20); const [perPage, setPerPage] = useState(20);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -162,6 +163,7 @@ export function SettlementCreditLedgerPanel({
}); });
setRows(res.items ?? []); setRows(res.items ?? []);
setTotal(res.total ?? 0); setTotal(res.total ?? 0);
setTruncated(res.truncated === true);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -311,6 +313,14 @@ export function SettlementCreditLedgerPanel({
</div> </div>
</div> </div>
{truncated ? (
<p className="rounded-lg border border-amber-200/70 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-200">
{t("ledgerPanel.truncated", {
defaultValue: "流水条数较多,当前列表可能未包含全部记录。请缩小筛选范围或按玩家账号查询。",
})}
</p>
) : null}
{loading && rows.length === 0 ? <AdminLoadingState /> : null} {loading && rows.length === 0 ? <AdminLoadingState /> : null}
<div className="admin-table-shell overflow-x-auto"> <div className="admin-table-shell overflow-x-auto">

View File

@@ -6,10 +6,8 @@ import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
getSettlementAdjustments, getSettlementOperations,
getSettlementPayments, type SettlementOperationRow,
type SettlementAdjustmentRow,
type SettlementPaymentRow,
} from "@/api/admin-agent-settlement"; } from "@/api/admin-agent-settlement";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer"; import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { AdminTableLoadingRow } from "@/components/admin/admin-loading-state"; import { AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
@@ -49,17 +47,6 @@ type OperationFilters = {
operationType: OperationTypeFilter; operationType: OperationTypeFilter;
}; };
type SettlementOperationRow = {
key: string;
kind: Exclude<OperationTypeFilter, "all">;
recordId: number;
billId: number;
amount: number;
summary: string;
detail: string | null;
sortAt: string;
};
function defaultFilters(): OperationFilters { function defaultFilters(): OperationFilters {
return { return {
billId: "", billId: "",
@@ -68,74 +55,8 @@ function defaultFilters(): OperationFilters {
}; };
} }
function paymentRow(row: SettlementPaymentRow): SettlementOperationRow { function rowKey(row: SettlementOperationRow): string {
const payer = return `${row.kind}:${row.record_id}`;
row.payer_type === "platform"
? "platform"
: `${row.payer_type}#${row.payer_id}`;
const payee =
row.payee_type === "platform"
? "platform"
: `${row.payee_type}#${row.payee_id}`;
return {
key: `payment:${row.id}`,
kind: "payment",
recordId: row.id,
billId: row.settlement_bill_id,
amount: row.amount,
summary: row.method?.trim() || "—",
detail: `${payer}${payee}${row.proof ? ` · ${row.proof}` : ""}${row.remark ? ` · ${row.remark}` : ""}`,
sortAt: row.confirmed_at ?? row.created_at ?? "",
};
}
function adjustmentRow(row: SettlementAdjustmentRow): SettlementOperationRow {
const kind =
row.adjustment_type === "bad_debt"
? "bad_debt"
: row.adjustment_type === "reversal"
? "reversal"
: "adjustment";
return {
key: `adjustment:${row.id}`,
kind,
recordId: row.id,
billId: row.original_bill_id ?? 0,
amount: row.amount,
summary: row.reason?.trim() || "—",
detail: null,
sortAt: row.created_at ?? "",
};
}
function matchesFilters(row: SettlementOperationRow, filters: OperationFilters): boolean {
if (filters.operationType !== "all" && row.kind !== filters.operationType) {
return false;
}
const billId = Number(filters.billId.trim());
if (filters.billId.trim() !== "" && (!Number.isInteger(billId) || billId <= 0 || row.billId !== billId)) {
return false;
}
const keyword = filters.keyword.trim().toLowerCase();
if (keyword === "") {
return true;
}
const haystack = [
String(row.billId),
String(row.recordId),
row.summary,
row.detail ?? "",
row.kind,
]
.join(" ")
.toLowerCase();
return haystack.includes(keyword);
} }
type SettlementOperationsPanelProps = { type SettlementOperationsPanelProps = {
@@ -159,7 +80,9 @@ export function SettlementOperationsPanel({
const [draft, setDraft] = useState<OperationFilters>(initialFilters); const [draft, setDraft] = useState<OperationFilters>(initialFilters);
const [applied, setApplied] = useState<OperationFilters>(initialFilters); const [applied, setApplied] = useState<OperationFilters>(initialFilters);
const [allRows, setAllRows] = useState<SettlementOperationRow[]>([]); const [rows, setRows] = useState<SettlementOperationRow[]>([]);
const [total, setTotal] = useState(0);
const [lastPage, setLastPage] = useState(1);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(20); const [perPage, setPerPage] = useState(20);
@@ -167,25 +90,27 @@ export function SettlementOperationsPanel({
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const [paymentData, adjustmentData] = await Promise.all([ const billId = Number(applied.billId.trim());
getSettlementPayments({ const data = await getSettlementOperations({
admin_site_id: adminSiteId, admin_site_id: adminSiteId,
settlement_period_id: settlementPeriodId, settlement_period_id: settlementPeriodId,
}), bill_id:
getSettlementAdjustments({ applied.billId.trim() !== "" && Number.isInteger(billId) && billId > 0
admin_site_id: adminSiteId, ? billId
settlement_period_id: settlementPeriodId, : undefined,
}), keyword: applied.keyword.trim() || undefined,
]); operation_type: applied.operationType,
page,
per_page: perPage,
});
const merged = [ setRows(data.items ?? []);
...(paymentData.items ?? []).map(paymentRow), setTotal(data.total ?? 0);
...(adjustmentData.items ?? []).map(adjustmentRow), setLastPage(Math.max(1, data.last_page ?? 1));
].sort((a, b) => b.sortAt.localeCompare(a.sortAt) || b.key.localeCompare(a.key));
setAllRows(merged);
} catch (err: unknown) { } catch (err: unknown) {
setAllRows([]); setRows([]);
setTotal(0);
setLastPage(1);
toast.error( toast.error(
err instanceof LotteryApiBizError err instanceof LotteryApiBizError
? err.message ? err.message
@@ -194,7 +119,7 @@ export function SettlementOperationsPanel({
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [adminSiteId, settlementPeriodId, t]); }, [adminSiteId, applied, page, perPage, settlementPeriodId, t]);
useEffect(() => { useEffect(() => {
void load(); void load();
@@ -206,14 +131,11 @@ export function SettlementOperationsPanel({
setPage(1); setPage(1);
}, [initialFilters, settlementPeriodId]); }, [initialFilters, settlementPeriodId]);
const filteredRows = useMemo( useEffect(() => {
() => allRows.filter((row) => matchesFilters(row, applied)), if (page > lastPage) {
[allRows, applied], setPage(lastPage);
); }
}, [lastPage, page]);
const total = filteredRows.length;
const lastPage = Math.max(1, Math.ceil(total / perPage));
const pageRows = filteredRows.slice((page - 1) * perPage, page * perPage);
const operationTypeLabel = (value: OperationTypeFilter): string => { const operationTypeLabel = (value: OperationTypeFilter): string => {
if (value === "all") { if (value === "all") {
@@ -347,14 +269,14 @@ export function SettlementOperationsPanel({
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{loading ? <AdminTableLoadingRow colSpan={colSpan} /> : null} {loading ? <AdminTableLoadingRow colSpan={colSpan} /> : null}
{!loading && pageRows.length === 0 ? ( {!loading && rows.length === 0 ? (
<AdminTableNoResourceRow colSpan={colSpan} cellClassName="py-12 text-center" /> <AdminTableNoResourceRow colSpan={colSpan} cellClassName="py-12 text-center" />
) : null} ) : null}
{!loading {!loading
? pageRows.map((row) => ( ? rows.map((row) => (
<TableRow key={row.key}> <TableRow key={rowKey(row)}>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground"> <TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{formatTs(row.sortAt)} {formatTs(row.sort_at)}
</TableCell> </TableCell>
<TableCell> <TableCell>
<span <span
@@ -367,7 +289,7 @@ export function SettlementOperationsPanel({
</span> </span>
</TableCell> </TableCell>
<TableCell className="tabular-nums"> <TableCell className="tabular-nums">
{row.billId > 0 ? `#${row.billId}` : "—"} {row.bill_id > 0 ? `#${row.bill_id}` : "—"}
</TableCell> </TableCell>
<TableCell className={adminMoneyCellClassName("text-right font-medium")}> <TableCell className={adminMoneyCellClassName("text-right font-medium")}>
<AdminTableMoney> <AdminTableMoney>
@@ -382,14 +304,14 @@ export function SettlementOperationsPanel({
className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]" className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{row.billId > 0 ? ( {row.bill_id > 0 ? (
<AdminRowActionsMenu <AdminRowActionsMenu
actions={[ actions={[
{ {
key: "detail", key: "detail",
label: t("actions.detail", { defaultValue: "详情" }), label: t("actions.detail", { defaultValue: "详情" }),
icon: Eye, icon: Eye,
onClick: () => onOpenBill(row.billId), onClick: () => onOpenBill(row.bill_id),
}, },
]} ]}
/> />