fix(settlement): 操作记录服务端分页、流水提示与坏账幂等键
Some checks failed
lotteryadmin CI / build (push) Has been cancelled
Some checks failed
lotteryadmin CI / build (push) Has been cancelled
- 操作记录 Tab 改用 settlement-operations API 服务端筛选分页 - 流水面板展示 truncated 截断提示 - 坏账核销对齐收付/补差的幂等键生成与复用 - 结算中心 URL 同步、代理控制台 lint 与玩家/奖池模块更新
This commit is contained in:
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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?",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "खुला",
|
||||||
|
|||||||
@@ -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": "खेलाडी सिर्जना",
|
||||||
|
|||||||
@@ -35,7 +35,9 @@
|
|||||||
"forceTriggerGap": "强制爆池间隔(期)",
|
"forceTriggerGap": "强制爆池间隔(期)",
|
||||||
"minBetAmount": "最低下注额",
|
"minBetAmount": "最低下注额",
|
||||||
"comboTriggerPlays": "组合触发玩法",
|
"comboTriggerPlays": "组合触发玩法",
|
||||||
"comboTriggerPlaysPlaceholder": "straight,ibox",
|
"comboTriggerPlaysHint": "勾选后,该玩法中头奖即可触发奖池(无需达到阈值)。不选表示不启用此条件。",
|
||||||
|
"comboTriggerPlaysEmpty": "暂无可用玩法,请先在玩法配置中启用。",
|
||||||
|
"comboTriggerPlaysUnknown": "已失效",
|
||||||
"status": "开关",
|
"status": "开关",
|
||||||
"disabled": "关闭",
|
"disabled": "关闭",
|
||||||
"enabled": "开启",
|
"enabled": "开启",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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")}
|
||||||
|
|||||||
@@ -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: "已核销坏账" }));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@@ -421,4 +343,4 @@ export function SettlementOperationsPanel({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user