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(
|
||||
billId: number,
|
||||
body?: { reason?: string },
|
||||
body?: { reason?: string; idempotency_key?: string },
|
||||
): Promise<{ original_bill_id: number; bad_debt_bill_id: number; bill: SettlementBillRow }> {
|
||||
return adminRequest.post(`${A}/settlement-bills/${billId}/bad-debt-write-off`, body ?? {});
|
||||
}
|
||||
@@ -188,10 +188,39 @@ export async function getCreditLedger(params: {
|
||||
page: number;
|
||||
per_page: number;
|
||||
ledger_source: string;
|
||||
truncated?: boolean;
|
||||
}> {
|
||||
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?: {
|
||||
settlement_period_id?: number;
|
||||
admin_site_id?: number;
|
||||
@@ -243,7 +272,15 @@ export type SettlementBillPaymentRow = {
|
||||
export async function getSettlementPayments(params?: {
|
||||
settlement_period_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 });
|
||||
}
|
||||
|
||||
@@ -267,7 +304,15 @@ export async function getSettlementAdjustments(params?: {
|
||||
settlement_period_id?: number;
|
||||
admin_site_id?: number;
|
||||
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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -14,8 +14,10 @@
|
||||
"balanceAdjustmentHint": "A reason is required; each change is recorded in the adjustment ledger. Balance cannot be edited via Save.",
|
||||
"balanceAdjustmentTitle": "Balance adjustment",
|
||||
"cancel": "Cancel",
|
||||
"comboTriggerPlays": "Combo trigger plays (comma separated)",
|
||||
"comboTriggerPlaysPlaceholder": "Enter play codes separated by commas, for example straight,ibox",
|
||||
"comboTriggerPlays": "Combo trigger plays",
|
||||
"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",
|
||||
"confirmAdjustmentDescription": "This writes a ledger entry and updates the pool balance. Verify amount and reason.",
|
||||
"confirmAdjustmentTitle": "Confirm pool balance adjustment?",
|
||||
|
||||
@@ -20,7 +20,23 @@
|
||||
"createAgentNode": "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.",
|
||||
"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",
|
||||
"createFailed": "Failed to create player",
|
||||
"createPlayer": "Create player",
|
||||
|
||||
@@ -45,8 +45,10 @@
|
||||
"forceTriggerGapPlaceholder": "बलपूर्वक ट्रिगर अन्तर प्रविष्ट गर्नुहोस्",
|
||||
"minBetAmount": "न्यूनतम सहभागिता बेट रकम (मुख्य एकाइ)",
|
||||
"minBetAmountPlaceholder": "न्यूनतम बेट रकम प्रविष्ट गर्नुहोस्",
|
||||
"comboTriggerPlays": "कम्बो ट्रिगर प्ले (comma-separated)",
|
||||
"comboTriggerPlaysPlaceholder": "प्ले कोडहरू अल्पविरामले छुट्याएर लेख्नुहोस्, जस्तै straight,ibox",
|
||||
"comboTriggerPlays": "कम्बो ट्रिगर प्ले",
|
||||
"comboTriggerPlaysHint": "छानिएका प्लेमा प्रथम पुरस्कार जित्दा थ्रेसहोल्ड नपुगी पनि पूल बर्स्ट हुन सक्छ। सबै अनचेक राख्दा यो नियम बन्द हुन्छ।",
|
||||
"comboTriggerPlaysEmpty": "कुनै प्ले उपलब्ध छैन। पहिले प्ले कन्फिगरेसनमा सक्षम गर्नुहोस्।",
|
||||
"comboTriggerPlaysUnknown": "अमान्य",
|
||||
"status": "स्थिति",
|
||||
"disabled": "बन्द",
|
||||
"enabled": "खुला",
|
||||
|
||||
@@ -20,7 +20,23 @@
|
||||
"createAgentNode": "एजेन्ट नोड",
|
||||
"createAgentNodePlaceholder": "एजेन्ट नोड छान्नुहोस्",
|
||||
"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": "खेलाडी सिर्जना",
|
||||
"createFailed": "खेलाडी सिर्जना असफल भयो",
|
||||
"createPlayer": "खेलाडी सिर्जना",
|
||||
|
||||
@@ -35,7 +35,9 @@
|
||||
"forceTriggerGap": "强制爆池间隔(期)",
|
||||
"minBetAmount": "最低下注额",
|
||||
"comboTriggerPlays": "组合触发玩法",
|
||||
"comboTriggerPlaysPlaceholder": "straight,ibox",
|
||||
"comboTriggerPlaysHint": "勾选后,该玩法中头奖即可触发奖池(无需达到阈值)。不选表示不启用此条件。",
|
||||
"comboTriggerPlaysEmpty": "暂无可用玩法,请先在玩法配置中启用。",
|
||||
"comboTriggerPlaysUnknown": "已失效",
|
||||
"status": "开关",
|
||||
"disabled": "关闭",
|
||||
"enabled": "开启",
|
||||
|
||||
@@ -80,7 +80,23 @@
|
||||
"delete": "删除",
|
||||
"createDialogTitle": "新建玩家",
|
||||
"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": "编辑玩家信息。",
|
||||
"siteCode": "主站编号",
|
||||
"siteCodePlaceholder": "例如 main_site",
|
||||
|
||||
@@ -37,10 +37,7 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import {
|
||||
percentValueToUi,
|
||||
parsePercentUi,
|
||||
} from "@/lib/admin-rate-percent";
|
||||
import { percentValueToUi } from "@/lib/admin-rate-percent";
|
||||
import {
|
||||
isLineRootAgentNode,
|
||||
isRootProfileEditableByActor,
|
||||
@@ -163,7 +160,7 @@ export function AgentsConsole(): React.ReactElement {
|
||||
const hasUsersManagePermission =
|
||||
isSuperAdmin ||
|
||||
adminHasAnyPermission(profile?.permissions, [PRD_USERS_MANAGE]);
|
||||
const [rootProfile, setRootProfile] = useState<AgentProfileRow | null>(null);
|
||||
|
||||
const selectedNodeIdFromUrl = Number.parseInt(
|
||||
searchParams.get("agent_node_id") ?? searchParams.get("node") ?? "",
|
||||
10,
|
||||
@@ -329,13 +326,10 @@ export function AgentsConsole(): React.ReactElement {
|
||||
return rootNode?.site_code?.trim() ?? "";
|
||||
}, [adminSiteId, boundAgent?.site_code, boundSite, rootNode?.site_code, siteOptions]);
|
||||
|
||||
const selectedNode = useMemo(
|
||||
() =>
|
||||
selectedNodeId !== null
|
||||
? (flatNodes.find((node) => node.id === selectedNodeId) ?? null)
|
||||
: null,
|
||||
[flatNodes, selectedNodeId],
|
||||
);
|
||||
const selectedNode =
|
||||
selectedNodeId !== null
|
||||
? (flatNodes.find((node) => node.id === selectedNodeId) ?? null)
|
||||
: null;
|
||||
|
||||
const lineRootProfileLocked = useMemo(
|
||||
() => isLineRootAgentNode(selectedNode) && !isSuperAdmin,
|
||||
@@ -392,7 +386,7 @@ export function AgentsConsole(): React.ReactElement {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [tRef]);
|
||||
}, [setAdminSiteId, tRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canViewAgents) {
|
||||
@@ -492,17 +486,6 @@ export function AgentsConsole(): React.ReactElement {
|
||||
};
|
||||
}, [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 canShowDownlineTab = selectedNode !== null;
|
||||
@@ -870,13 +853,6 @@ export function AgentsConsole(): React.ReactElement {
|
||||
(hasSiteContext && adminSiteId === null) ||
|
||||
(adminSiteId !== null && loading));
|
||||
|
||||
const openAddAgent = (): void => {
|
||||
const parent = selectedNode ?? rootNode;
|
||||
if (parent !== null) {
|
||||
openCreateChildForNode(parent);
|
||||
}
|
||||
};
|
||||
|
||||
const saveNode = async () => {
|
||||
if (!nodeName.trim()) {
|
||||
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) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
|
||||
@@ -307,6 +307,14 @@ export function AgentsPlayersPanel({
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (effectiveAgentId !== null && parentAvailableCredit === null) {
|
||||
toast.error(
|
||||
t("playersPanel.creditCapLoading", {
|
||||
defaultValue: "代理可下发额度加载中,请稍后再保存",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
parentAvailableCredit !== null &&
|
||||
parsedCreditLimit > parentAvailableCredit
|
||||
@@ -485,6 +493,18 @@ export function AgentsPlayersPanel({
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
parsedCreditLimit !== baselineCredit &&
|
||||
effectiveAgentId !== null &&
|
||||
parentAvailableCredit === null
|
||||
) {
|
||||
toast.error(
|
||||
t("playersPanel.creditCapLoading", {
|
||||
defaultValue: "代理可下发额度加载中,请稍后再保存",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
parentAvailableCredit !== null &&
|
||||
parsedCreditLimit > baselineCredit + parentAvailableCredit
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
postAdminJackpotPoolAdjustment,
|
||||
putAdminJackpotPool,
|
||||
} 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 { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
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 { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -53,7 +56,7 @@ type Draft = {
|
||||
payout_rate: string;
|
||||
force_trigger_draw_gap: string;
|
||||
min_bet_amount: string;
|
||||
combo_trigger_play_codes: string;
|
||||
combo_trigger_play_codes: string[];
|
||||
status: string;
|
||||
manual_burst_draw_id: string;
|
||||
};
|
||||
@@ -71,7 +74,7 @@ function toDraft(p: AdminJackpotPoolRow): Draft {
|
||||
payout_rate: ratioToPercentUi(p.payout_rate),
|
||||
force_trigger_draw_gap: String(p.force_trigger_draw_gap),
|
||||
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),
|
||||
manual_burst_draw_id: "",
|
||||
};
|
||||
@@ -84,6 +87,8 @@ type JackpotPoolsConsoleProps = {
|
||||
export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsoleProps) {
|
||||
const { t } = useTranslation(["jackpot", "common"]);
|
||||
const tRef = useTranslationRef(["jackpot", "common"]);
|
||||
useAdminPlayTypeCatalog();
|
||||
const playOptions = useCachedPlayTypeOptions();
|
||||
const profile = useAdminProfile();
|
||||
const canManageJackpot = adminHasAnyPermission(profile?.permissions, [PRD_JACKPOT_MANAGE]);
|
||||
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>) => {
|
||||
setAdjustmentDrafts((prev) => ({
|
||||
...prev,
|
||||
@@ -164,10 +178,7 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
|
||||
payout_rate: percentUiToRatio(d.payout_rate),
|
||||
force_trigger_draw_gap: Number.parseInt(d.force_trigger_draw_gap, 10),
|
||||
min_bet_amount: parseAdminMajorToMinor(d.min_bet_amount, p.currency_code) ?? 0,
|
||||
combo_trigger_play_codes: d.combo_trigger_play_codes
|
||||
.split(",")
|
||||
.map((v) => v.trim().toLowerCase())
|
||||
.filter(Boolean),
|
||||
combo_trigger_play_codes: d.combo_trigger_play_codes,
|
||||
status: Number.parseInt(d.status, 10),
|
||||
});
|
||||
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 })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5 sm:col-span-2 lg:col-span-3">
|
||||
<Label htmlFor={`combo-${p.id}`}>{t("comboTriggerPlays")}</Label>
|
||||
<Input
|
||||
id={`combo-${p.id}`}
|
||||
className="font-mono"
|
||||
value={d.combo_trigger_play_codes}
|
||||
placeholder={t("comboTriggerPlaysPlaceholder")}
|
||||
onChange={(e) => updateDraft(p.id, { combo_trigger_play_codes: e.target.value })}
|
||||
/>
|
||||
<div className="space-y-2 sm:col-span-2 lg:col-span-3">
|
||||
<div>
|
||||
<Label id={`combo-label-${p.id}`}>{t("comboTriggerPlays")}</Label>
|
||||
<p className="text-xs text-muted-foreground">{t("comboTriggerPlaysHint")}</p>
|
||||
</div>
|
||||
{playOptions.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t("comboTriggerPlaysEmpty")}</p>
|
||||
) : (
|
||||
<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>
|
||||
</fieldset>
|
||||
|
||||
|
||||
@@ -11,15 +11,17 @@ import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import { useTranslationRef } from "@/hooks/use-translation-ref";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { getAgentTree } from "@/api/admin-agents";
|
||||
import { getAgentNodeProfile, getAgentTree } from "@/api/admin-agents";
|
||||
import {
|
||||
deleteAdminPlayer,
|
||||
getAdminPlayer,
|
||||
getAdminPlayers,
|
||||
postAdminPlayer,
|
||||
postAdminPlayerFreeze,
|
||||
postAdminPlayerUnfreeze,
|
||||
putAdminPlayer,
|
||||
} from "@/api/admin-player";
|
||||
import { PlayerCreditLimitField } from "@/components/admin/player-credit-limit-field";
|
||||
import { flattenAgentTree, type FlatAgentOption } from "@/lib/admin-agent-tree";
|
||||
import { useAdminSiteCodeOptions } from "@/hooks/use-admin-site-code-options";
|
||||
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 { useAdminProfile } from "@/stores/admin-session";
|
||||
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 { formatAdminMinorUnits } from "@/lib/money";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
@@ -84,6 +93,8 @@ const PLAYER_STATUS_OPTIONS = [
|
||||
{ value: 2, label: "statusBanned" },
|
||||
];
|
||||
|
||||
type PlayerFundingModeChoice = "wallet" | "credit";
|
||||
|
||||
function parseRiskTagsInput(text: string): string[] {
|
||||
return Array.from(
|
||||
new Set(
|
||||
@@ -138,6 +149,11 @@ export function PlayersConsole(): React.ReactElement {
|
||||
const [formStatus, setFormStatus] = useState(0);
|
||||
const [formRiskTags, setFormRiskTags] = useState("");
|
||||
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 [createAgentLoading, setCreateAgentLoading] = useState(false);
|
||||
|
||||
@@ -150,6 +166,17 @@ export function PlayersConsole(): React.ReactElement {
|
||||
[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 =
|
||||
isSuperAdmin || (profile?.accessible_sites?.length ?? 0) > 1;
|
||||
|
||||
@@ -227,7 +254,11 @@ export function PlayersConsole(): React.ReactElement {
|
||||
setFormAgentNodeId(boundAgent?.id);
|
||||
setFormSitePlayerId("");
|
||||
setFormUsername("");
|
||||
setFormPassword("");
|
||||
setFormNickname("");
|
||||
setFormFundingMode("wallet");
|
||||
setFormCreditLimit("");
|
||||
setParentAvailableCredit(null);
|
||||
setFormDefaultCurrency("NPR");
|
||||
setFormStatus(0);
|
||||
setFormRiskTags("");
|
||||
@@ -280,9 +311,43 @@ export function PlayersConsole(): React.ReactElement {
|
||||
};
|
||||
}, [accountOpen, accountMode, canPickAgentOnCreate, formSiteCode, siteOptions]);
|
||||
|
||||
function openEditAccount(row: AdminPlayerRow): void {
|
||||
setAccountMode("edit");
|
||||
setEditingAccountId(row.id);
|
||||
useAsyncEffect(() => {
|
||||
const needsCreditCap =
|
||||
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);
|
||||
setFormSitePlayerId(row.site_player_id);
|
||||
setFormUsername(row.username ?? "");
|
||||
@@ -290,7 +355,26 @@ export function PlayersConsole(): React.ReactElement {
|
||||
setFormDefaultCurrency(row.default_currency);
|
||||
setFormStatus(row.status);
|
||||
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);
|
||||
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 {
|
||||
@@ -310,30 +394,84 @@ export function PlayersConsole(): React.ReactElement {
|
||||
toast.error(t("siteCodeRequired"));
|
||||
return;
|
||||
}
|
||||
if (formSitePlayerId.trim() === "") {
|
||||
toast.error(t("sitePlayerIdRequired"));
|
||||
return;
|
||||
}
|
||||
if (canPickAgentOnCreate && (formAgentNodeId === undefined || formAgentNodeId <= 0)) {
|
||||
toast.error(t("createAgentRequired"));
|
||||
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);
|
||||
try {
|
||||
const created = await postAdminPlayer({
|
||||
site_code: formSiteCode.trim(),
|
||||
site_player_id: formSitePlayerId.trim(),
|
||||
username: formUsername.trim() || null,
|
||||
...(formSitePlayerId.trim() !== "" ? { site_player_id: formSitePlayerId.trim() } : {}),
|
||||
username: isCreditCreate ? formUsername.trim() : formUsername.trim() || null,
|
||||
...(isCreditCreate ? { password: formPassword } : {}),
|
||||
nickname: formNickname.trim() || null,
|
||||
default_currency: formDefaultCurrency,
|
||||
status: formStatus,
|
||||
...(canPickAgentOnCreate && formAgentNodeId
|
||||
? { agent_node_id: formAgentNodeId }
|
||||
: {}),
|
||||
...(isCreditCreate ? { credit_limit: parsedCreditLimit } : {}),
|
||||
});
|
||||
setItems((prev) => [created, ...prev]);
|
||||
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);
|
||||
} catch (e) {
|
||||
const msg = e instanceof LotteryApiBizError ? e.message : t("createFailed");
|
||||
@@ -364,6 +502,38 @@ export function PlayersConsole(): React.ReactElement {
|
||||
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) {
|
||||
toast.success(t("noChanges"));
|
||||
handleAccountDialogOpenChange(false);
|
||||
@@ -704,6 +874,32 @@ export function PlayersConsole(): React.ReactElement {
|
||||
<div className="space-y-3">
|
||||
{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">
|
||||
<Label htmlFor="player-site-code">{t("siteCode")}</Label>
|
||||
{boundAgent && boundAgent.site_code ? (
|
||||
@@ -778,22 +974,68 @@ export function PlayersConsole(): React.ReactElement {
|
||||
<Input
|
||||
id="player-site-id"
|
||||
value={formSitePlayerId}
|
||||
placeholder={t("sitePlayerIdPlaceholder")}
|
||||
placeholder={
|
||||
formFundingMode === "credit"
|
||||
? t("createSitePlayerIdOptional")
|
||||
: t("sitePlayerIdPlaceholder")
|
||||
}
|
||||
onChange={(e) => setFormSitePlayerId(e.target.value)}
|
||||
/>
|
||||
</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">
|
||||
<Label htmlFor="player-username">{t("username")}</Label>
|
||||
<Input
|
||||
id="player-username"
|
||||
value={formUsername}
|
||||
disabled={accountMode === "edit"}
|
||||
placeholder={accountMode === "create" ? t("usernamePlaceholderOptional") : ""}
|
||||
onChange={(e) => setFormUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{accountMode === "edit" ? (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="player-username">{t("username")}</Label>
|
||||
<Input
|
||||
id="player-username"
|
||||
value={formUsername}
|
||||
disabled
|
||||
onChange={(e) => setFormUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="player-nickname">{t("nickname")}</Label>
|
||||
<Input
|
||||
@@ -871,6 +1113,19 @@ export function PlayersConsole(): React.ReactElement {
|
||||
onChange={(e) => setFormRiskTags(e.target.value)}
|
||||
/>
|
||||
</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">
|
||||
<Label htmlFor="player-edit-status">{t("status")}</Label>
|
||||
<Select
|
||||
@@ -902,7 +1157,7 @@ export function PlayersConsole(): React.ReactElement {
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={accountSaving}
|
||||
disabled={accountSaving || accountDetailLoading}
|
||||
onClick={() => void submitAccount()}
|
||||
>
|
||||
{accountSaving ? t("saving") : t("save")}
|
||||
|
||||
@@ -69,6 +69,7 @@ export function AgentBillDetail({
|
||||
const paymentRef = useRef<HTMLDivElement>(null);
|
||||
const paymentIdempotencyKeyRef = useRef<string | null>(null);
|
||||
const adjustmentIdempotencyKeyRef = useRef<string | null>(null);
|
||||
const badDebtIdempotencyKeyRef = useRef<string | null>(null);
|
||||
const { request: requestConfirm, ConfirmDialog, busy: confirmBusy } = useConfirmAction();
|
||||
|
||||
const createIdempotencyKey = useCallback((prefix: string): string => {
|
||||
@@ -82,6 +83,7 @@ export function AgentBillDetail({
|
||||
useEffect(() => {
|
||||
paymentIdempotencyKeyRef.current = null;
|
||||
adjustmentIdempotencyKeyRef.current = null;
|
||||
badDebtIdempotencyKeyRef.current = null;
|
||||
}, [billId]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
@@ -257,6 +259,10 @@ export function AgentBillDetail({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!badDebtIdempotencyKeyRef.current) {
|
||||
badDebtIdempotencyKeyRef.current = createIdempotencyKey("bd");
|
||||
}
|
||||
const idempotencyKey = badDebtIdempotencyKeyRef.current;
|
||||
requestConfirm({
|
||||
title: t("settlementBills.badDebtConfirmTitle", { defaultValue: "确认核销坏账?" }),
|
||||
description: t("settlementBills.badDebtConfirmDescription", {
|
||||
@@ -266,7 +272,8 @@ export function AgentBillDetail({
|
||||
confirmVariant: "destructive",
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await postSettlementBillBadDebtWriteOff(billId, { reason });
|
||||
await postSettlementBillBadDebtWriteOff(billId, { reason, idempotency_key: idempotencyKey });
|
||||
badDebtIdempotencyKeyRef.current = null;
|
||||
await load();
|
||||
await onUpdated?.();
|
||||
toast.success(t("settlementBills.badDebtDone", { defaultValue: "已核销坏账" }));
|
||||
|
||||
@@ -357,7 +357,7 @@ export function SettlementCenterShell(): React.ReactElement {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [activePeriod, activePeriodId, activeView, periodsReady, router, siteId]);
|
||||
}, [activeBillScope, activePeriod, activePeriodId, activeView, periodsReady, router, siteId]);
|
||||
|
||||
const shellBootstrapping =
|
||||
!sitesReady || (siteId !== null && !periodsReady);
|
||||
|
||||
@@ -145,6 +145,7 @@ export function SettlementCreditLedgerPanel({
|
||||
const [appliedReason, setAppliedReason] = useState<ReasonFilter>("all");
|
||||
const [rows, setRows] = useState<SettlementCreditLedgerRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [truncated, setTruncated] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(20);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -162,6 +163,7 @@ export function SettlementCreditLedgerPanel({
|
||||
});
|
||||
setRows(res.items ?? []);
|
||||
setTotal(res.total ?? 0);
|
||||
setTruncated(res.truncated === true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -311,6 +313,14 @@ export function SettlementCreditLedgerPanel({
|
||||
</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}
|
||||
|
||||
<div className="admin-table-shell overflow-x-auto">
|
||||
|
||||
@@ -6,10 +6,8 @@ import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
getSettlementAdjustments,
|
||||
getSettlementPayments,
|
||||
type SettlementAdjustmentRow,
|
||||
type SettlementPaymentRow,
|
||||
getSettlementOperations,
|
||||
type SettlementOperationRow,
|
||||
} from "@/api/admin-agent-settlement";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
@@ -49,17 +47,6 @@ type OperationFilters = {
|
||||
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 {
|
||||
return {
|
||||
billId: "",
|
||||
@@ -68,74 +55,8 @@ function defaultFilters(): OperationFilters {
|
||||
};
|
||||
}
|
||||
|
||||
function paymentRow(row: SettlementPaymentRow): SettlementOperationRow {
|
||||
const payer =
|
||||
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);
|
||||
function rowKey(row: SettlementOperationRow): string {
|
||||
return `${row.kind}:${row.record_id}`;
|
||||
}
|
||||
|
||||
type SettlementOperationsPanelProps = {
|
||||
@@ -159,7 +80,9 @@ export function SettlementOperationsPanel({
|
||||
|
||||
const [draft, setDraft] = 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 [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(20);
|
||||
@@ -167,25 +90,27 @@ export function SettlementOperationsPanel({
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [paymentData, adjustmentData] = await Promise.all([
|
||||
getSettlementPayments({
|
||||
admin_site_id: adminSiteId,
|
||||
settlement_period_id: settlementPeriodId,
|
||||
}),
|
||||
getSettlementAdjustments({
|
||||
admin_site_id: adminSiteId,
|
||||
settlement_period_id: settlementPeriodId,
|
||||
}),
|
||||
]);
|
||||
const billId = Number(applied.billId.trim());
|
||||
const data = await getSettlementOperations({
|
||||
admin_site_id: adminSiteId,
|
||||
settlement_period_id: settlementPeriodId,
|
||||
bill_id:
|
||||
applied.billId.trim() !== "" && Number.isInteger(billId) && billId > 0
|
||||
? billId
|
||||
: undefined,
|
||||
keyword: applied.keyword.trim() || undefined,
|
||||
operation_type: applied.operationType,
|
||||
page,
|
||||
per_page: perPage,
|
||||
});
|
||||
|
||||
const merged = [
|
||||
...(paymentData.items ?? []).map(paymentRow),
|
||||
...(adjustmentData.items ?? []).map(adjustmentRow),
|
||||
].sort((a, b) => b.sortAt.localeCompare(a.sortAt) || b.key.localeCompare(a.key));
|
||||
|
||||
setAllRows(merged);
|
||||
setRows(data.items ?? []);
|
||||
setTotal(data.total ?? 0);
|
||||
setLastPage(Math.max(1, data.last_page ?? 1));
|
||||
} catch (err: unknown) {
|
||||
setAllRows([]);
|
||||
setRows([]);
|
||||
setTotal(0);
|
||||
setLastPage(1);
|
||||
toast.error(
|
||||
err instanceof LotteryApiBizError
|
||||
? err.message
|
||||
@@ -194,7 +119,7 @@ export function SettlementOperationsPanel({
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [adminSiteId, settlementPeriodId, t]);
|
||||
}, [adminSiteId, applied, page, perPage, settlementPeriodId, t]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
@@ -206,14 +131,11 @@ export function SettlementOperationsPanel({
|
||||
setPage(1);
|
||||
}, [initialFilters, settlementPeriodId]);
|
||||
|
||||
const filteredRows = useMemo(
|
||||
() => allRows.filter((row) => matchesFilters(row, applied)),
|
||||
[allRows, applied],
|
||||
);
|
||||
|
||||
const total = filteredRows.length;
|
||||
const lastPage = Math.max(1, Math.ceil(total / perPage));
|
||||
const pageRows = filteredRows.slice((page - 1) * perPage, page * perPage);
|
||||
useEffect(() => {
|
||||
if (page > lastPage) {
|
||||
setPage(lastPage);
|
||||
}
|
||||
}, [lastPage, page]);
|
||||
|
||||
const operationTypeLabel = (value: OperationTypeFilter): string => {
|
||||
if (value === "all") {
|
||||
@@ -347,14 +269,14 @@ export function SettlementOperationsPanel({
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? <AdminTableLoadingRow colSpan={colSpan} /> : null}
|
||||
{!loading && pageRows.length === 0 ? (
|
||||
{!loading && rows.length === 0 ? (
|
||||
<AdminTableNoResourceRow colSpan={colSpan} cellClassName="py-12 text-center" />
|
||||
) : null}
|
||||
{!loading
|
||||
? pageRows.map((row) => (
|
||||
<TableRow key={row.key}>
|
||||
? rows.map((row) => (
|
||||
<TableRow key={rowKey(row)}>
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
{formatTs(row.sortAt)}
|
||||
{formatTs(row.sort_at)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
@@ -367,7 +289,7 @@ export function SettlementOperationsPanel({
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="tabular-nums">
|
||||
{row.billId > 0 ? `#${row.billId}` : "—"}
|
||||
{row.bill_id > 0 ? `#${row.bill_id}` : "—"}
|
||||
</TableCell>
|
||||
<TableCell className={adminMoneyCellClassName("text-right font-medium")}>
|
||||
<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)]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{row.billId > 0 ? (
|
||||
{row.bill_id > 0 ? (
|
||||
<AdminRowActionsMenu
|
||||
actions={[
|
||||
{
|
||||
key: "detail",
|
||||
label: t("actions.detail", { defaultValue: "详情" }),
|
||||
icon: Eye,
|
||||
onClick: () => onOpenBill(row.billId),
|
||||
onClick: () => onOpenBill(row.bill_id),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user