diff --git a/src/api/admin-agent-settlement.ts b/src/api/admin-agent-settlement.ts index 6542eb0..601333e 100644 --- a/src/api/admin-agent-settlement.ts +++ b/src/api/admin-agent-settlement.ts @@ -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 }); } diff --git a/src/i18n/locales/en/jackpot.json b/src/i18n/locales/en/jackpot.json index 347d434..37af062 100644 --- a/src/i18n/locales/en/jackpot.json +++ b/src/i18n/locales/en/jackpot.json @@ -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?", diff --git a/src/i18n/locales/en/players.json b/src/i18n/locales/en/players.json index d0fa86e..ca7655a 100644 --- a/src/i18n/locales/en/players.json +++ b/src/i18n/locales/en/players.json @@ -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", diff --git a/src/i18n/locales/ne/jackpot.json b/src/i18n/locales/ne/jackpot.json index bebc370..1e93b58 100644 --- a/src/i18n/locales/ne/jackpot.json +++ b/src/i18n/locales/ne/jackpot.json @@ -45,8 +45,10 @@ "forceTriggerGapPlaceholder": "बलपूर्वक ट्रिगर अन्तर प्रविष्ट गर्नुहोस्", "minBetAmount": "न्यूनतम सहभागिता बेट रकम (मुख्य एकाइ)", "minBetAmountPlaceholder": "न्यूनतम बेट रकम प्रविष्ट गर्नुहोस्", - "comboTriggerPlays": "कम्बो ट्रिगर प्ले (comma-separated)", - "comboTriggerPlaysPlaceholder": "प्ले कोडहरू अल्पविरामले छुट्याएर लेख्नुहोस्, जस्तै straight,ibox", + "comboTriggerPlays": "कम्बो ट्रिगर प्ले", + "comboTriggerPlaysHint": "छानिएका प्लेमा प्रथम पुरस्कार जित्दा थ्रेसहोल्ड नपुगी पनि पूल बर्स्ट हुन सक्छ। सबै अनचेक राख्दा यो नियम बन्द हुन्छ।", + "comboTriggerPlaysEmpty": "कुनै प्ले उपलब्ध छैन। पहिले प्ले कन्फिगरेसनमा सक्षम गर्नुहोस्।", + "comboTriggerPlaysUnknown": "अमान्य", "status": "स्थिति", "disabled": "बन्द", "enabled": "खुला", diff --git a/src/i18n/locales/ne/players.json b/src/i18n/locales/ne/players.json index aea2d05..862bfde 100644 --- a/src/i18n/locales/ne/players.json +++ b/src/i18n/locales/ne/players.json @@ -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": "खेलाडी सिर्जना", diff --git a/src/i18n/locales/zh/jackpot.json b/src/i18n/locales/zh/jackpot.json index c1578c7..52b2c6e 100644 --- a/src/i18n/locales/zh/jackpot.json +++ b/src/i18n/locales/zh/jackpot.json @@ -35,7 +35,9 @@ "forceTriggerGap": "强制爆池间隔(期)", "minBetAmount": "最低下注额", "comboTriggerPlays": "组合触发玩法", - "comboTriggerPlaysPlaceholder": "straight,ibox", + "comboTriggerPlaysHint": "勾选后,该玩法中头奖即可触发奖池(无需达到阈值)。不选表示不启用此条件。", + "comboTriggerPlaysEmpty": "暂无可用玩法,请先在玩法配置中启用。", + "comboTriggerPlaysUnknown": "已失效", "status": "开关", "disabled": "关闭", "enabled": "开启", diff --git a/src/i18n/locales/zh/players.json b/src/i18n/locales/zh/players.json index 849c7cf..d644872 100644 --- a/src/i18n/locales/zh/players.json +++ b/src/i18n/locales/zh/players.json @@ -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", diff --git a/src/modules/agents/agents-console.tsx b/src/modules/agents/agents-console.tsx index 469176c..de58e97 100644 --- a/src/modules/agents/agents-console.tsx +++ b/src/modules/agents/agents-console.tsx @@ -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(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 (

diff --git a/src/modules/agents/agents-players-panel.tsx b/src/modules/agents/agents-players-panel.tsx index 43c8218..cab2762 100644 --- a/src/modules/agents/agents-players-panel.tsx +++ b/src/modules/agents/agents-players-panel.tsx @@ -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 diff --git a/src/modules/jackpot/jackpot-pools-console.tsx b/src/modules/jackpot/jackpot-pools-console.tsx index f72e3ce..afdf72f 100644 --- a/src/modules/jackpot/jackpot-pools-console.tsx +++ b/src/modules/jackpot/jackpot-pools-console.tsx @@ -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) => { 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 })} /> -

- - updateDraft(p.id, { combo_trigger_play_codes: e.target.value })} - /> +
+
+ +

{t("comboTriggerPlaysHint")}

+
+ {playOptions.length === 0 ? ( +

{t("comboTriggerPlaysEmpty")}

+ ) : ( +
+ {playOptions.map((option) => { + const checked = d.combo_trigger_play_codes.includes(option.code); + return ( + + ); + })} + {d.combo_trigger_play_codes + .filter((code) => !playOptions.some((option) => option.code === code)) + .map((code) => ( + + ))} +
+ )}
diff --git a/src/modules/players/players-console.tsx b/src/modules/players/players-console.tsx index ec0b887..29bad8d 100644 --- a/src/modules/players/players-console.tsx +++ b/src/modules/players/players-console.tsx @@ -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(undefined); + const [formFundingMode, setFormFundingMode] = useState("wallet"); + const [formPassword, setFormPassword] = useState(""); + const [formCreditLimit, setFormCreditLimit] = useState(""); + const [parentAvailableCredit, setParentAvailableCredit] = useState(null); + const [accountDetailLoading, setAccountDetailLoading] = useState(false); const [createAgentOptions, setCreateAgentOptions] = useState([]); 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 {
{accountMode === "create" && ( <> +
+ + +
{boundAgent && boundAgent.site_code ? ( @@ -778,22 +974,68 @@ export function PlayersConsole(): React.ReactElement { setFormSitePlayerId(e.target.value)} />
+ {formFundingMode === "credit" ? ( + <> +
+ + setFormUsername(e.target.value)} + /> +
+
+ + setFormPassword(e.target.value)} + /> +
+ + + ) : ( +
+ + setFormUsername(e.target.value)} + /> +
+ )} )} -
- - setFormUsername(e.target.value)} - /> -
+ {accountMode === "edit" ? ( +
+ + setFormUsername(e.target.value)} + /> +
+ ) : null}
setFormRiskTags(e.target.value)} />
+ {editingUsesCredit ? ( + accountDetailLoading ? ( +

{t("editDetailLoading")}

+ ) : ( + + ) + ) : null}