From 24fd7c10bdca58c730017a5b7fa5a8bdfdc3b2af Mon Sep 17 00:00:00 2001 From: kang Date: Fri, 12 Jun 2026 16:01:42 +0800 Subject: [PATCH] feat(settlement, admin): introduce new types and functions for downline share and settlement period hints Added new types for downline share breakdown and settlement period open hints to enhance the agent settlement API. Updated the admin console components to support these new features, improving the user experience with better data presentation and interaction. Additionally, refined the date range field to accommodate new calendar markers and hints, ensuring a more intuitive interface for managing settlement periods. --- src/api/admin-agent-settlement.ts | 26 ++ src/components/admin/admin-agent-columns.tsx | 24 +- .../admin/admin-date-range-field.tsx | 70 ++- .../admin/admin-player-identity-columns.tsx | 33 +- src/i18n/locales/en/agents.json | 7 + src/i18n/locales/en/dashboard.json | 5 + src/i18n/locales/en/draws.json | 6 +- src/i18n/locales/en/settlementCenter.json | 33 +- src/i18n/locales/en/wallet.json | 5 + src/i18n/locales/ne/draws.json | 6 +- src/i18n/locales/ne/wallet.json | 5 + src/i18n/locales/zh/agents.json | 20 +- src/i18n/locales/zh/dashboard.json | 5 + src/i18n/locales/zh/draws.json | 6 +- src/i18n/locales/zh/settlementCenter.json | 41 +- src/i18n/locales/zh/wallet.json | 5 + src/lib/admin-datetime.ts | 68 ++- src/lib/agent-default-role-permissions.ts | 2 - src/lib/agent-settlement-period-range.ts | 213 +++++---- src/lib/money.ts | 23 + .../agents/agents-directory-console.tsx | 83 ++-- src/modules/agents/agents-players-panel.tsx | 54 ++- .../dashboard/agent-dashboard-console.tsx | 15 +- .../dashboard/dashboard-analytics-panel.tsx | 19 +- .../dashboard/dashboard-current-draw-card.tsx | 10 +- .../dashboard/use-dashboard-analytics.ts | 2 + src/modules/draws/draw-create-dialog.tsx | 17 +- src/modules/draws/draw-detail-console.tsx | 13 +- src/modules/draws/draw-edit-dialog.tsx | 36 +- src/modules/draws/draws-index-console.tsx | 12 +- src/modules/settlement/agent-bill-detail.tsx | 147 +++--- .../agent-settlement-reports-panel.tsx | 15 +- .../settlement-adjustments-table.tsx | 5 +- .../settlement/settlement-bill-breakdown.tsx | 270 +++++++----- .../settlement/settlement-bill-display.ts | 96 +++- .../settlement/settlement-bill-operable.ts | 59 +++ .../settlement/settlement-center-nav.ts | 34 +- .../settlement-center-period-detail.tsx | 20 +- .../settlement/settlement-center-shell.tsx | 131 +++++- .../settlement/settlement-main-panel.tsx | 13 +- .../settlement-operations-panel.tsx | 417 ++++++++++++++++++ .../settlement/settlement-payments-table.tsx | 9 +- .../settlement-period-workbench.tsx | 234 ++++++---- .../settlement/settlement-signed-money.ts | 10 + .../settlement/settlement-status-label.ts | 8 + src/modules/wallet/wallet-console.tsx | 100 +++-- src/types/api/admin-agent.ts | 2 + src/types/api/admin-dashboard-analytics.ts | 2 + src/types/api/admin-dashboard.ts | 2 + src/types/api/admin-wallet.ts | 1 + 50 files changed, 1821 insertions(+), 618 deletions(-) create mode 100644 src/modules/settlement/settlement-bill-operable.ts create mode 100644 src/modules/settlement/settlement-operations-panel.tsx diff --git a/src/api/admin-agent-settlement.ts b/src/api/admin-agent-settlement.ts index 981047f..bf754ef 100644 --- a/src/api/admin-agent-settlement.ts +++ b/src/api/admin-agent-settlement.ts @@ -43,6 +43,17 @@ export type AgentSettlementReportType = | "platform_pnl" | "draw_period"; +export type DownlineShareItem = { + owner_id: number; + owner_label: string; + share_profit: number; +}; + +export type DownlineShareBreakdown = { + total: number; + items: DownlineShareItem[]; +}; + export type SettlementBillRow = { id: number; settlement_period_id: number; @@ -88,6 +99,20 @@ export async function postSettlementPeriod(body: { return adminRequest.post(`${A}/settlement-periods`, body); } +export type SettlementPeriodOpenHints = { + suggested_start: string; + suggested_end: string; + occupied_period_dates: string[]; + pending_activity_dates: string[]; + unpaid_bill_dates: string[]; +}; + +export async function getSettlementPeriodOpenHints(params: { + admin_site_id: number; +}): Promise { + return adminRequest.get(`${A}/settlement-periods/open-hints`, { params }); +} + export type SettlementPeriodCloseResult = { period_id: number; unsettled_ticket_count?: number; @@ -262,6 +287,7 @@ export async function getSettlementBill(billId: number): Promise<{ rebate_allocations: RebateAllocationRow[]; adjustments: Array<{ id: number; amount: number; adjustment_type: string; reason: string | null }>; tier_edge?: string | null; + downline_shares?: DownlineShareBreakdown; }> { return adminRequest.get(`${A}/settlement-bills/${billId}`); } diff --git a/src/components/admin/admin-agent-columns.tsx b/src/components/admin/admin-agent-columns.tsx index 9a7df2d..dfda0ce 100644 --- a/src/components/admin/admin-agent-columns.tsx +++ b/src/components/admin/admin-agent-columns.tsx @@ -31,19 +31,31 @@ type CellProps = { row: AdminAgentFields; className?: string }; export function AdminAgentHead({ className }: HeadProps): React.ReactElement { const { t } = useTranslation("common"); return ( - + {t("agentColumns.agent")} ); } export function AdminAgentCell({ row, className }: CellProps): React.ReactElement { + const name = cellText(row.agent_name); + const code = row.agent_code?.trim() ?? ""; + return ( - - {cellText(row.agent_name)} - {row.agent_code ? ( - {row.agent_code} - ) : null} + +
+ + {name} + + {code !== "" ? ( + + {code} + + ) : null} +
); } diff --git a/src/components/admin/admin-date-range-field.tsx b/src/components/admin/admin-date-range-field.tsx index 724f9db..7c8a939 100644 --- a/src/components/admin/admin-date-range-field.tsx +++ b/src/components/admin/admin-date-range-field.tsx @@ -14,6 +14,15 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover import { useIsMobile } from "@/hooks/use-mobile"; import { cn } from "@/lib/utils"; +export type AdminDateRangeCalendarMarkers = { + /** 本地日历 `yyyy-MM-dd` — 已有账期,不可选 */ + occupiedPeriod?: string[]; + /** 本地日历 `yyyy-MM-dd` */ + pendingActivity?: string[]; + /** 本地日历 `yyyy-MM-dd` — 已有账期内的未结清日 */ + unpaidBill?: string[]; +}; + function parseYmd(value: string): Date | undefined { if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { return undefined; @@ -45,6 +54,9 @@ export function AdminDateRangeField({ to: toProp, onRangeChange, placeholder, + rangeHint, + calendarMarkers, + disabled, }: { id: string; label?: string; @@ -52,6 +64,9 @@ export function AdminDateRangeField({ to: string; onRangeChange: (next: { from: string; to: string }) => void; placeholder?: string; + rangeHint?: string; + calendarMarkers?: AdminDateRangeCalendarMarkers; + disabled?: boolean; }) { const { t } = useTranslation(["common"]); const [open, setOpen] = React.useState(false); @@ -77,6 +92,28 @@ export function AdminDateRangeField({ const hasSelection = Boolean(parseYmd(fromProp) || parseYmd(toProp)); const defaultMonth = selected?.from ?? selected?.to ?? new Date(); + const pendingActivityDates = React.useMemo( + () => + (calendarMarkers?.pendingActivity ?? []) + .map((ymd) => parseYmd(ymd)) + .filter((d): d is Date => d instanceof Date), + [calendarMarkers?.pendingActivity], + ); + const occupiedPeriodDates = React.useMemo( + () => + (calendarMarkers?.occupiedPeriod ?? []) + .map((ymd) => parseYmd(ymd)) + .filter((d): d is Date => d instanceof Date), + [calendarMarkers?.occupiedPeriod], + ); + const unpaidBillDates = React.useMemo( + () => + (calendarMarkers?.unpaidBill ?? []) + .map((ymd) => parseYmd(ymd)) + .filter((d): d is Date => d instanceof Date), + [calendarMarkers?.unpaidBill], + ); + return (
{label ? ( @@ -94,6 +131,7 @@ export function AdminDateRangeField({ -

- {t("date.rangeHint", { - ns: "common", - defaultValue: - "Select a start date, then an end date. For a single day, click the same date twice. Click Done to close.", - })} -

+ {rangeHint ? ( +

{rangeHint}

+ ) : ( +

+ {t("date.rangeHint", { + ns: "common", + defaultValue: + "Select a start date, then an end date. For a single day, click the same date twice. Click Done to close.", + })} +

+ )} button]:cursor-not-allowed [&>button]:bg-muted/80 [&>button]:text-muted-foreground [&>button]:line-through [&>button]:opacity-70", + pendingActivity: + "[&:not([data-disabled=true])>button]:relative [&:not([data-disabled=true])>button]:after:absolute [&:not([data-disabled=true])>button]:after:bottom-0.5 [&:not([data-disabled=true])>button]:after:left-1/2 [&:not([data-disabled=true])>button]:after:size-1 [&:not([data-disabled=true])>button]:after:-translate-x-1/2 [&:not([data-disabled=true])>button]:after:rounded-full [&:not([data-disabled=true])>button]:after:bg-amber-500 [&:not([data-disabled=true])>button]:after:content-['']", + unpaidBill: + "[&>button]:relative [&>button]:before:absolute [&>button]:before:top-0.5 [&>button]:before:right-0.5 [&>button]:before:size-1.5 [&>button]:before:rounded-full [&>button]:before:bg-rose-500 [&>button]:before:content-['']", + }} onSelect={(range) => { if (!range?.from && !range?.to) { onRangeChange({ from: "", to: "" }); diff --git a/src/components/admin/admin-player-identity-columns.tsx b/src/components/admin/admin-player-identity-columns.tsx index ef983f0..e186695 100644 --- a/src/components/admin/admin-player-identity-columns.tsx +++ b/src/components/admin/admin-player-identity-columns.tsx @@ -36,7 +36,7 @@ type CellProps = { row: AdminPlayerIdentityFields; className?: string }; export function AdminPlayerSiteHead({ className }: HeadProps): React.ReactElement { const { t } = useTranslation("common"); return ( - + {t("playerColumns.site")} ); @@ -45,7 +45,7 @@ export function AdminPlayerSiteHead({ className }: HeadProps): React.ReactElemen export function AdminPlayerDisplayHead({ className }: HeadProps): React.ReactElement { const { t } = useTranslation("common"); return ( - + {t("playerColumns.display")} ); @@ -54,7 +54,7 @@ export function AdminPlayerDisplayHead({ className }: HeadProps): React.ReactEle export function AdminPlayerSiteIdHead({ className }: HeadProps): React.ReactElement { const { t } = useTranslation("common"); return ( - + {t("playerColumns.sitePlayerId")} ); @@ -71,25 +71,40 @@ export function AdminPlayerIdentityHeads({ className }: { className?: string }): } export function AdminPlayerSiteCell({ row, className }: CellProps): React.ReactElement { + const site = cellText(row.site_code); + return ( - - {cellText(row.site_code)} + + + {site} + ); } export function AdminPlayerDisplayCell({ row, className }: CellProps): React.ReactElement { + const label = adminPlayerDisplayName(row); + return ( - - {adminPlayerDisplayName(row)} + + + {label} + ); } export function AdminPlayerSiteIdCell({ row, className }: CellProps): React.ReactElement { + const sitePlayerId = cellText(row.site_player_id); + return ( - - {cellText(row.site_player_id)} + + + {sitePlayerId} + ); } diff --git a/src/i18n/locales/en/agents.json b/src/i18n/locales/en/agents.json index f8846d0..efeaf6c 100644 --- a/src/i18n/locales/en/agents.json +++ b/src/i18n/locales/en/agents.json @@ -164,6 +164,13 @@ "presetThisWeek": "This week", "presetLastWeek": "Last week", "presetThisMonth": "This month", + "openHint": "Local dates ({{tz}})", + "startDate": "Start date", + "endDate": "End date", + "open": "Open period", + "openFailed": "Failed to open period", + "datesRequired": "Enter start and end dates", + "invalidRange": "End date cannot be before start date", "statusOpen": "Open", "statusClosed": "Closed" }, diff --git a/src/i18n/locales/en/dashboard.json b/src/i18n/locales/en/dashboard.json index 3a72088..16d9e5f 100644 --- a/src/i18n/locales/en/dashboard.json +++ b/src/i18n/locales/en/dashboard.json @@ -25,6 +25,8 @@ "summaryBet": "Period bet", "summaryPayout": "Period payout", "summaryProfit": "Period profit", + "summaryShareProfit": "Own share profit", + "shareProfitHint": "Your share after split — not platform or full team gross P/L", "dailyTrend": "Period trend", "granularityDay": "By day", "playBreakdown": "Play breakdown", @@ -177,15 +179,18 @@ "todayBet": "Today's bet", "todayPayout": "Today's payout", "todayProfit": "Today's profit", + "todayShareProfit": "Today's share profit", "sevenDayTitle": "Last 7 days", "sevenDayPayout": "Payout {{amount}}", "sevenDayProfit": "Profit {{amount}}", + "sevenDayShareProfit": "Share profit {{amount}}", "pendingBills": "Open agent bills", "pendingUnpaid": "Unpaid total {{amount}}", "latestBetAt": "Latest bet {{time}}", "noBetToday": "No bets yet today", "topMomentum": "Today's bet focus", "topMomentumHint": "Profit {{profit}}", + "topMomentumPayout": "Payout {{amount}}", "managementFocus": "Management focus", "focusBet": "Watch today's bet volume", "focusPlayers": "Today's active players", diff --git a/src/i18n/locales/en/draws.json b/src/i18n/locales/en/draws.json index b19484c..841017f 100644 --- a/src/i18n/locales/en/draws.json +++ b/src/i18n/locales/en/draws.json @@ -5,11 +5,11 @@ "generating": "Generating…", "generateSuccess": "Generated {{created}} draws, buffer {{upcoming}}/{{target}}", "generateFailed": "Generation failed", - "scheduleTimezoneHint": "Times in server timezone {{tz}} (GMT, per UI spec); draw interval {{interval}} min (LOTTERY_DRAW_INTERVAL_MINUTES).", + "scheduleTimezoneHint": "List times shown in your local timezone {{tz}}; server schedule is stored in UTC. Draw interval {{interval}} min.", "createDraw": { "open": "New draw", "title": "Create draw manually", - "description": "Enter date and time in {{tz}} (not your browser local zone). If only draw time is set, start/close are derived from server config.", + "description": "Enter date and time in your local timezone {{tz}}. If only draw time is set, start/close are derived from server config.", "hint": "Start < close < draw. Draw number optional; sequence auto-assigned by UTC business date.", "drawNoPlaceholder": "Enter draw number, for example 20260526-008", "drawTimeRequired": "Draw time is required", @@ -34,7 +34,7 @@ "editDraw": { "action": "Edit draw", "title": "Edit draw", - "description": "Draw {{drawNo}} · edit times in {{tz}}", + "description": "Draw {{drawNo}} · edit times in local timezone {{tz}}", "drawNoPlaceholder": "Enter draw number, for example 20260526-008", "submit": "Save", "saving": "Saving…", diff --git a/src/i18n/locales/en/settlementCenter.json b/src/i18n/locales/en/settlementCenter.json index 814e078..21ced1a 100644 --- a/src/i18n/locales/en/settlementCenter.json +++ b/src/i18n/locales/en/settlementCenter.json @@ -63,6 +63,7 @@ "nav": { "periods": "Periods", "bills": "Bills", + "operations": "Payments & adjustments", "ledger": "Account ledger", "creditLedger": "Credit ledger", "playerBills": "Player bills", @@ -74,6 +75,16 @@ "badDebt": "Bad debt", "reports": "Period reports" }, + "operations": { + "hint": "Payment registration, bad-debt write-offs, and adjustments. Player credit movements are under Account ledger.", + "adjustmentsTitle": "Adjustments / reversals / bad debt", + "loadFailed": "Failed to load payment and adjustment records", + "operationType": "Operation type", + "filterAllTypes": "All types", + "typePayment": "Payment", + "keyword": "Keyword", + "keywordPh": "Method, reason, proof, payer/payee" + }, "filters": { "period": "Period", "statusAll": "All", @@ -160,6 +171,8 @@ "amount": "Amount", "method": "Method", "time": "Time", + "summary": "Summary", + "detail": "Detail", "adjustmentType": "Type", "originalBill": "Original bill", "reason": "Reason", @@ -183,6 +196,10 @@ "reversal": "Reversal", "bad_debt": "Bad debt" }, + "paymentStatus": { + "pending": "Pending", + "confirmed": "Confirmed" + }, "actions": { "detail": "Detail", "viewBill": "View bill", @@ -193,9 +210,11 @@ "settlementAmount": "Settlement amount", "pays": "Pays", "paysShort": "Pays", - "howAmountWorks": "How the amount is calculated", + "howAmountWorks": "Breakdown", + "payerLabel": "Payer", + "payeeLabel": "Payee", "playerBreakdownIntro": "Players settle only with their direct agent: net = win/loss − rebate.", - "agentBreakdownIntro": "Agents settle only with their upline: net = team net − own share profit.", + "agentBreakdownIntro": "Agents settle only with their upline: net = team net − downline share − own share.", "playerGross": "Game win/loss", "playerLostHint": "Player lost; owes agent", "playerWonHint": "Player won; agent owes player", @@ -208,15 +227,19 @@ "teamRebate": "Team rebate", "teamNet": "Team net", "rebate": "Rebate", - "agentShareKeep": "{{agent}} share kept", + "agentShareKeep": "Share kept at this tier", "agentShareKeepHint": "Profit retained at this tier by share ratio", - "agentNet": "{{agent}} pays upline", - "agentNetReceive": "Upline pays {{agent}}", + "agentDownlineShare": "Downline share", + "agentDownlineShareHint": "Profit retained by downline agents (see breakdown below)", + "agentDownlineShareItem": "{{agent}} kept", + "agentNet": "Pay {{counterparty}}", + "agentNetReceive": "{{counterparty}} pays this tier", "billOwner": "Bill owner", "billCounterparty": "Counterparty", "unpaidPendingConfirm": "Confirm the bill before recording payment", "unpaidAwaitingPayment": "Record offline payment", "fullySettled": "Fully settled this period", + "confirmHint": "Confirm the bill before recording payment.", "recordReceiptFrom": "Record receipt ({{payer}} → {{payee}})", "recordPayoutTo": "Record payout ({{payer}} → {{payee}})", "rebateAllocationsHint": "How rebate is allocated across agent tiers.", diff --git a/src/i18n/locales/en/wallet.json b/src/i18n/locales/en/wallet.json index 9473a48..c7b37a3 100644 --- a/src/i18n/locales/en/wallet.json +++ b/src/i18n/locales/en/wallet.json @@ -24,6 +24,11 @@ "transferIn": "Main site transfer in", "transferOut": "Main site transfer out", "transferOutRefund": "Transfer-out refund", + "bizBetDeduct": "Bet debit", + "bizBetReverse": "Bet reversal", + "bizSettlePayout": "Settlement payout", + "bizJackpotPayout": "Jackpot payout", + "bizSettlementAdjustment": "Settlement adjustment", "transferOrders": "Transfer orders", "walletTransactions": "Wallet transactions", "playerWalletQuery": "Player wallet query", diff --git a/src/i18n/locales/ne/draws.json b/src/i18n/locales/ne/draws.json index 677aeec..78d70d8 100644 --- a/src/i18n/locales/ne/draws.json +++ b/src/i18n/locales/ne/draws.json @@ -5,11 +5,11 @@ "generating": "सिर्जना हुँदैछ…", "generateSuccess": "{{created}} ड्रअ सिर्जना भयो, बफर {{upcoming}}/{{target}}", "generateFailed": "सिर्जना असफल भयो", - "scheduleTimezoneHint": "सूची समय सर्भर समयक्षेत्र {{tz}} (GMT); ड्र अन्तराल {{interval}} मिनेट।", + "scheduleTimezoneHint": "सूची समय स्थानीय समयक्षेत्र {{tz}} मा; सर्भर UTC मा भण्डारण। ड्र अन्तराल {{interval}} मिनेट।", "createDraw": { "open": "नयाँ ड्रअ", "title": "म्यानुअल ड्रअ सिर्जना", - "description": "{{tz}} मा मिति र समय प्रविष्ट गर्नुहोस् (ब्राउजर स्थानीय समय होइन)।", + "description": "स्थानीय समयक्षेत्र {{tz}} मा मिति र समय प्रविष्ट गर्नुहोस्।", "hint": "सुरु < बन्द < ड्रअ। ड्रअ नम्बर वैकल्पिक।", "drawNoPlaceholder": "ड्रअ नम्बर प्रविष्ट गर्नुहोस्, जस्तै 20260526-008", "drawTimeRequired": "ड्रअ समय आवश्यक छ", @@ -34,7 +34,7 @@ "editDraw": { "action": "सम्पादन", "title": "ड्रअ सम्पादन", - "description": "ड्रअ {{drawNo}} · {{tz}}", + "description": "ड्रअ {{drawNo}} · स्थानीय समयक्षेत्र {{tz}} मा सम्पादन", "drawNoPlaceholder": "ड्रअ नम्बर प्रविष्ट गर्नुहोस्, जस्तै 20260526-008", "submit": "सेभ", "saving": "सेभ हुँदैछ…", diff --git a/src/i18n/locales/ne/wallet.json b/src/i18n/locales/ne/wallet.json index cc349cb..60b957f 100644 --- a/src/i18n/locales/ne/wallet.json +++ b/src/i18n/locales/ne/wallet.json @@ -18,6 +18,11 @@ "transferIn": "मुख्य साइटबाट भित्र", "transferOut": "मुख्य साइटतर्फ बाहिर", "transferOutRefund": "ट्रान्सफर-आउट फिर्ता", + "bizBetDeduct": "बेट कटौती", + "bizBetReverse": "बेट उल्टाउने", + "bizSettlePayout": "बन्दोबस्त भुक्तानी", + "bizJackpotPayout": "ज्याकपट भुक्तानी", + "bizSettlementAdjustment": "बन्दोबस्त समायोजन", "transferOrders": "ट्रान्सफर अर्डर", "walletTransactions": "वालेट कारोबार", "playerWalletQuery": "खेलाडी वालेट खोज", diff --git a/src/i18n/locales/zh/agents.json b/src/i18n/locales/zh/agents.json index f273a90..7438f49 100644 --- a/src/i18n/locales/zh/agents.json +++ b/src/i18n/locales/zh/agents.json @@ -271,29 +271,33 @@ "settlementPeriods": { "title": "代理账期", "manageTitle": "账期管理", - "manageHint": "平台或负责人开期并关账后,上方账单会自动生成。一般用快捷账期即可,无需手填时间。", + "manageHint": "平台或负责人开账并关账后,上方账单会自动生成。一般用快捷账期即可,无需手填时间。", "presetHint": "快捷账期(推荐)", "presetThisWeek": "本周", "presetLastWeek": "上周", "presetThisMonth": "本月", - "openWithPreset": "按上方时间开期", - "showAdvanced": "自定义起止时间", - "hideAdvanced": "收起自定义时间", + "openHint": "本地日期({{tz}})", + "openWithPreset": "填入快捷账期", + "showAdvanced": "自定义起止日期", + "hideAdvanced": "收起自定义日期", "range": "账期", "statusOpen": "进行中", "statusClosed": "已关账", - "empty": "尚无账期,请选择快捷账期开期。", + "empty": "尚无账期,请选择快捷账期并开账。", + "startDate": "开始日期", + "endDate": "结束日期", "start": "开始", "end": "结束", "status": "状态", - "open": "开期", + "open": "开账", "close": "关账并生成账单", "viewBills": "账单", "opened": "账期已开启", "closed": "账期已关账,账单已生成", - "openFailed": "开期失败", + "openFailed": "开账失败", "closeFailed": "关账失败", - "datesRequired": "请填写账期起止" + "datesRequired": "请填写账期起止日期", + "invalidRange": "结束日期不能早于开始日期" }, "lineProvision": { "title": "创建一级代理", diff --git a/src/i18n/locales/zh/dashboard.json b/src/i18n/locales/zh/dashboard.json index ec6631f..d80b31a 100644 --- a/src/i18n/locales/zh/dashboard.json +++ b/src/i18n/locales/zh/dashboard.json @@ -25,6 +25,8 @@ "summaryBet": "区间下注", "summaryPayout": "区间派彩", "summaryProfit": "区间盈亏", + "summaryShareProfit": "本级占成", + "shareProfitHint": "按占成比例计入本代理的收益/亏损,非平台或团队总输赢", "dailyTrend": "区间趋势", "granularityDay": "按天", "playBreakdown": "玩法拆解 Top", @@ -177,15 +179,18 @@ "todayBet": "今日下注", "todayPayout": "今日派彩", "todayProfit": "今日盈亏", + "todayShareProfit": "今日本级占成", "sevenDayTitle": "近 7 天走势", "sevenDayPayout": "派彩 {{amount}}", "sevenDayProfit": "盈亏 {{amount}}", + "sevenDayShareProfit": "本级占成 {{amount}}", "pendingBills": "待结代理账单", "pendingUnpaid": "未结合计 {{amount}}", "latestBetAt": "最近下注 {{time}}", "noBetToday": "今日暂时没有下注", "topMomentum": "今日投注焦点", "topMomentumHint": "对应盈亏 {{profit}}", + "topMomentumPayout": "派彩 {{amount}}", "managementFocus": "经营重点", "focusBet": "今天先盯下注额", "focusPlayers": "今天活跃人数", diff --git a/src/i18n/locales/zh/draws.json b/src/i18n/locales/zh/draws.json index 98da4ec..d23adb5 100644 --- a/src/i18n/locales/zh/draws.json +++ b/src/i18n/locales/zh/draws.json @@ -5,11 +5,11 @@ "generating": "生成中…", "generateSuccess": "已生成 {{created}} 期,当前缓冲 {{upcoming}}/{{target}}", "generateFailed": "生成失败", - "scheduleTimezoneHint": "列表时间为服务器时区 {{tz}}(GMT,与界面文档一致);开奖间隔 {{interval}} 分钟(LOTTERY_DRAW_INTERVAL_MINUTES)。", + "scheduleTimezoneHint": "列表时间按本地时区 {{tz}} 显示;服务器排期仍按 UTC 存储。开奖间隔 {{interval}} 分钟。", "createDraw": { "open": "新建期号", "title": "手动创建期号", - "description": "日期与时间按 {{tz}} 填写(勿用浏览器本地时区)。仅填开奖时间时,开始/封盘按系统配置自动推算。", + "description": "日期与时间按本地时区 {{tz}} 填写。仅填开奖时间时,开始/封盘按系统配置自动推算。", "hint": "开始 < 封盘 < 开奖。期号可留空,将按 UTC 业务日自动生成流水号。", "drawNoPlaceholder": "请输入期号,如 20260526-008", "drawTimeRequired": "请填写开奖时间", @@ -34,7 +34,7 @@ "editDraw": { "action": "编辑期号", "title": "编辑期号", - "description": "期号 {{drawNo}} · 时间按 {{tz}} 编辑", + "description": "期号 {{drawNo}} · 时间按本地时区 {{tz}} 编辑", "drawNoPlaceholder": "请输入期号,如 20260526-008", "submit": "保存", "saving": "保存中…", diff --git a/src/i18n/locales/zh/settlementCenter.json b/src/i18n/locales/zh/settlementCenter.json index bdf1489..c43453a 100644 --- a/src/i18n/locales/zh/settlementCenter.json +++ b/src/i18n/locales/zh/settlementCenter.json @@ -49,6 +49,7 @@ "nav": { "periods": "账期", "bills": "账单", + "operations": "收付与调账", "ledger": "账务流水", "creditLedger": "信用流水", "playerBills": "玩家账单", @@ -60,6 +61,16 @@ "badDebt": "坏账核销", "reports": "账期报表" }, + "operations": { + "hint": "登记收付、坏账核销与补差/冲正的操作台账。玩家信用变动请查看「账务流水」。", + "adjustmentsTitle": "调账 / 冲正 / 坏账", + "loadFailed": "收付与调账记录加载失败", + "operationType": "操作类型", + "filterAllTypes": "全部类型", + "typePayment": "登记收付", + "keyword": "关键词", + "keywordPh": "方式、原因、凭证、收付方向" + }, "filters": { "period": "账期范围", "statusAll": "全部", @@ -154,6 +165,8 @@ "amount": "金额", "method": "方式", "time": "时间", + "summary": "摘要", + "detail": "说明", "adjustmentType": "调账类型", "originalBill": "原账单", "reason": "原因", @@ -177,6 +190,10 @@ "reversal": "冲正", "bad_debt": "坏账核销" }, + "paymentStatus": { + "pending": "待确认", + "confirmed": "已确认" + }, "actions": { "detail": "详情", "viewBill": "查看账单", @@ -187,9 +204,11 @@ "settlementAmount": "结算金额", "pays": "应付", "paysShort": "应付", - "howAmountWorks": "金额怎么来的", + "howAmountWorks": "结算明细", + "payerLabel": "付款方", + "payeeLabel": "收款方", "playerBreakdownIntro": "玩家只与直属代理结算,净额 = 输赢 − 回水。", - "agentBreakdownIntro": "代理只与直属上级结算,净额 = 团队净额 − 本级占成。", + "agentBreakdownIntro": "代理只与直属上级结算,净额 = 团队净额 − 下级占成 − 本级占成。", "playerGross": "游戏输赢", "playerLostHint": "玩家输了,应付代理", "playerWonHint": "玩家赢了,代理应付玩家", @@ -202,15 +221,19 @@ "teamRebate": "团队回水", "teamNet": "团队净额", "rebate": "回水", - "agentShareKeep": "{{agent}} 本级占成", + "agentShareKeep": "本级占成", "agentShareKeepHint": "本级按占成比例留下的利润", - "agentNet": "{{agent}} 应付上级", - "agentNetReceive": "上级应付 {{agent}}", + "agentDownlineShare": "下级占成", + "agentDownlineShareHint": "下级代理按占成比例保留的利润(明细见下行)", + "agentDownlineShareItem": "{{agent}} 保留", + "agentNet": "应付 {{counterparty}}", + "agentNetReceive": "{{counterparty}} 应付本级", "billOwner": "账单主体", "billCounterparty": "结算对手", "unpaidPendingConfirm": "确认账单后可登记收付", "unpaidAwaitingPayment": "请登记线下收付", "fullySettled": "本期已结清", + "confirmHint": "确认后才可以登记收款或付款。", "recordReceiptFrom": "登记收款({{payer}} 付给 {{payee}})", "recordPayoutTo": "登记付款({{payer}} 付给 {{payee}})", "rebateAllocationsHint": "各层级代理对回水的承担明细。", @@ -293,7 +316,9 @@ "playerUpline": "直属代理的上级", "agentUpline": "本单结算上级" }, - "hierarchyHint": "同一账期会生成多笔账单:玩家先与直属代理结,代理扣除本级占成后再向上级缴纳。因此「输赢」可能相同,但「结算金额」会逐级减少。" + "hierarchyHint": "同一账期会生成多笔账单:玩家先与直属代理结,代理扣除本级占成后再向上级缴纳。因此「输赢」可能相同,但「结算金额」会逐级减少。", + "emptyFiltered": "当前筛选下暂无账单,请改为「全部状态」或重置筛选。", + "emptyClosed": "本期已关账但暂无账单。常见原因:账期内无信用盘玩家的已结算注单,或占成流水不在本账期时间范围内。" }, "panels": { "workbench": { "title": "工作台" }, @@ -310,10 +335,6 @@ "reports": { "title": "账期报表" }, "badDebt": { "title": "坏账核销" } }, - "billsPanel": { - "emptyFiltered": "当前筛选下暂无账单,请改为「全部状态」或重置筛选。", - "emptyClosed": "本期已关账但暂无账单。常见原因:账期内无信用盘玩家的已结算注单,或占成流水不在本账期时间范围内。" - }, "empty": { "noSite": "请选择站点。", "noPeriods": "请先关账当前账期。", diff --git a/src/i18n/locales/zh/wallet.json b/src/i18n/locales/zh/wallet.json index 04bcb68..3312350 100644 --- a/src/i18n/locales/zh/wallet.json +++ b/src/i18n/locales/zh/wallet.json @@ -24,6 +24,11 @@ "transferIn": "主站转入", "transferOut": "主站转出", "transferOutRefund": "转出失败回补", + "bizBetDeduct": "下注扣款", + "bizBetReverse": "下注冲正", + "bizSettlePayout": "派彩入账", + "bizJackpotPayout": "奖池派彩", + "bizSettlementAdjustment": "结算调账", "transferOrders": "转账单", "walletTransactions": "钱包流水", "playerWalletQuery": "玩家钱包查询", diff --git a/src/lib/admin-datetime.ts b/src/lib/admin-datetime.ts index 39c184f..1ce6849 100644 --- a/src/lib/admin-datetime.ts +++ b/src/lib/admin-datetime.ts @@ -52,9 +52,75 @@ export function formatAdminCalendarToday(locale: AdminApiLocale, weekdayLabel: s return `${datePart} ${weekdayLabel}`; } +const NAIVE_SCHEDULE_CLOCK_RE = + /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/; + +/** 浏览器本地时区短标签(如 CST、GMT+8),用于界面说明。 */ +export function getAdminBrowserTimeZoneLabel(date = new Date()): string { + try { + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + const parts = new Intl.DateTimeFormat(undefined, { + timeZone, + timeZoneName: "short", + }).formatToParts(date); + return parts.find((part) => part.type === "timeZoneName")?.value ?? timeZone; + } catch { + return "Local"; + } +} + +/** + * 将接口 ISO 8601 时间转为本地时区的 `YYYY-MM-DD HH:mm:ss`(表单预填、期号展示)。 + */ +export function isoToAdminLocalScheduleValue( + iso: string | null | undefined, +): string { + if (iso == null || iso === "") { + return ""; + } + const ms = Date.parse(iso); + if (Number.isNaN(ms)) { + return ""; + } + return formatParts(new Date(ms)); +} + +/** + * 将本地时区下的计划时刻转为指定排期时区(默认 UTC)的 naive `YYYY-MM-DD HH:mm:ss`,供创建/编辑期号 API。 + */ +export function adminLocalScheduleValueToTimezoneNaive( + clock: string, + scheduleTimezone = "UTC", +): string { + const trimmed = clock.trim(); + if (trimmed === "") { + return ""; + } + const match = NAIVE_SCHEDULE_CLOCK_RE.exec(trimmed); + if (!match) { + return trimmed; + } + const [, y, mo, d, h, mi, s] = match; + const localMs = new Date( + Number(y), + Number(mo) - 1, + Number(d), + Number(h), + Number(mi), + Number(s), + ).getTime(); + if (Number.isNaN(localMs)) { + return trimmed; + } + try { + return formatParts(new Date(localMs), scheduleTimezone); + } catch { + return trimmed; + } +} + /** * 将接口返回的 ISO 时间串格式化为浏览器本地时区下的 `YYYY-MM-DD HH:mm:ss`。 - * 期号相关列表请使用 {@link formatAdminInstantInTimeZone} 并传入 UTC。 */ export function formatAdminInstant( iso: string | null | undefined, diff --git a/src/lib/agent-default-role-permissions.ts b/src/lib/agent-default-role-permissions.ts index 6451bf3..4bd0e9d 100644 --- a/src/lib/agent-default-role-permissions.ts +++ b/src/lib/agent-default-role-permissions.ts @@ -8,7 +8,6 @@ export const AGENT_OWNER_BASE_SLUGS = [ "prd.agent.role.view", "prd.agent.user.view", "prd.tickets.view", - "prd.report.view", "prd.settlement.agent.view", ] as const; @@ -45,7 +44,6 @@ export const AGENT_PERMISSION_PACKAGES: Record< { key: "manage", label: "管理", slugs: ["prd.users.manage"] }, ], tickets: [{ key: "view", label: "查看", slugs: ["prd.tickets.view"] }], - reports: [{ key: "view", label: "查看", slugs: ["prd.report.view"] }], settlement_agent: [ { key: "view", label: "账单·查看", slugs: ["prd.settlement.agent.view"] }, { key: "manage", label: "账单·管理", slugs: ["prd.settlement.agent.manage"] }, diff --git a/src/lib/agent-settlement-period-range.ts b/src/lib/agent-settlement-period-range.ts index 3d4b0cc..c483f9a 100644 --- a/src/lib/agent-settlement-period-range.ts +++ b/src/lib/agent-settlement-period-range.ts @@ -1,113 +1,158 @@ -import type { AgentSettlementCycle } from "@/lib/agent-settlement-cycle"; +const DATE_YMD_RE = /^(\d{4})-(\d{2})-(\d{2})$/; -export type SettlementPeriodPresetKey = "this_week" | "last_week" | "this_month"; - -/** `datetime-local` 控件取值格式 */ -export function toDateTimeLocalValue(date: Date): string { - const pad = (n: number) => String(n).padStart(2, "0"); - - return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; +function pad2(n: number): string { + return String(n).padStart(2, "0"); } -function startOfDay(date: Date): Date { - const d = new Date(date); - d.setHours(0, 0, 0, 0); - - return d; +/** 本地日历 `YYYY-MM-DD` */ +export function toDateYmdValue(date: Date): string { + return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`; } -function endOfDay(date: Date): Date { - const d = new Date(date); - d.setHours(23, 59, 0, 0); - - return d; +function parseDateYmd(value: string): Date | null { + const match = DATE_YMD_RE.exec(value.trim()); + if (!match) { + return null; + } + const [, y, mo, d] = match; + const date = new Date(Number(y), Number(mo) - 1, Number(d)); + return Number.isNaN(date.getTime()) ? null : date; } -/** 周一为一周起始(与产品文档「周结」一致) */ -function startOfWeekMonday(date: Date): Date { - const d = startOfDay(date); - const day = d.getDay(); - const diff = day === 0 ? -6 : 1 - day; - d.setDate(d.getDate() + diff); - - return d; +function formatUtcDateTimeFromMs(ms: number): string { + const date = new Date(ms); + return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())} ${pad2(date.getUTCHours())}:${pad2(date.getUTCMinutes())}:${pad2(date.getUTCSeconds())}`; } -function addDays(date: Date, days: number): Date { - const d = new Date(date); - d.setDate(d.getDate() + days); +/** 本地自然日 00:00:00 → UTC `YYYY-MM-DD HH:mm:ss`(开账 API) */ +export function localDateYmdToUtcPeriodStart(ymd: string): string { + const date = parseDateYmd(ymd); + if (date === null) { + return ymd; + } + const ms = new Date( + date.getFullYear(), + date.getMonth(), + date.getDate(), + 0, + 0, + 0, + 0, + ).getTime(); - return d; + return formatUtcDateTimeFromMs(ms); } -function startOfMonth(date: Date): Date { - const d = startOfDay(date); - d.setDate(1); +/** 本地自然日 23:59:59 → UTC `YYYY-MM-DD HH:mm:ss`(开账 API) */ +export function localDateYmdToUtcPeriodEnd(ymd: string): string { + const date = parseDateYmd(ymd); + if (date === null) { + return ymd; + } + const ms = new Date( + date.getFullYear(), + date.getMonth(), + date.getDate(), + 23, + 59, + 59, + 999, + ).getTime(); - return d; + return formatUtcDateTimeFromMs(ms); } -function endOfMonth(date: Date): Date { - const d = startOfDay(date); - d.setMonth(d.getMonth() + 1); - d.setDate(0); - - return endOfDay(d); -} - -export function settlementPeriodPresetRange( - key: SettlementPeriodPresetKey, - now: Date = new Date(), +export function localDateRangeToUtcPeriodBounds( + startYmd: string, + endYmd: string, ): { period_start: string; period_end: string } { - switch (key) { - case "this_week": { - const start = startOfWeekMonday(now); - const end = endOfDay(addDays(start, 6)); - - return { - period_start: toDateTimeLocalValue(start), - period_end: toDateTimeLocalValue(end), - }; - } - case "last_week": { - const thisStart = startOfWeekMonday(now); - const start = addDays(thisStart, -7); - const end = endOfDay(addDays(start, 6)); - - return { - period_start: toDateTimeLocalValue(start), - period_end: toDateTimeLocalValue(end), - }; - } - case "this_month": { - const start = startOfMonth(now); - const end = endOfMonth(now); - - return { - period_start: toDateTimeLocalValue(start), - period_end: toDateTimeLocalValue(end), - }; - } - } + return { + period_start: localDateYmdToUtcPeriodStart(startYmd), + period_end: localDateYmdToUtcPeriodEnd(endYmd), + }; } -/** 按代理结算周期推荐默认快捷开期(周结优先) */ -export function defaultSettlementPeriodPreset( - cycle: AgentSettlementCycle, -): SettlementPeriodPresetKey { - if (cycle === "monthly") { - return "this_month"; +/** UTC 账期时刻 → 本地日历 `YYYY-MM-DD`(列表展示) */ +export function utcPeriodInstantToLocalYmd(iso: string | null | undefined): string { + if (iso == null || iso === "") { + return "—"; } + const ms = Date.parse(iso); + if (Number.isNaN(ms)) { + return iso.slice(0, 10); + } + return toDateYmdValue(new Date(ms)); +} - return "this_week"; +/** UTC 存储日 `Y-m-d` → 本地日历标记用 `yyyy-MM-dd` */ +export function utcStorageDateToLocalMarkYmd(utcYmd: string): string { + const match = DATE_YMD_RE.exec(utcYmd.trim()); + if (!match) { + return utcYmd; + } + const [, y, mo, d] = match; + const ms = Date.UTC(Number(y), Number(mo) - 1, Number(d), 12, 0, 0, 0); + return toDateYmdValue(new Date(ms)); +} + +export function utcStorageDatesToLocalMarks(dates: string[]): string[] { + return dates.map((day) => utcStorageDateToLocalMarkYmd(day)); +} + +/** 开账建议:UTC 存储日 → 本地表单 `yyyy-MM-dd` */ +export function utcStorageDateToLocalFormYmd(utcYmd: string): string { + return utcStorageDateToLocalMarkYmd(utcYmd); } export function formatSettlementPeriodSpan( periodStart: string | undefined, periodEnd: string | undefined, ): string { - const start = periodStart?.slice(0, 10) ?? "—"; - const end = periodEnd?.slice(0, 10) ?? "—"; + const start = utcPeriodInstantToLocalYmd(periodStart); + const end = utcPeriodInstantToLocalYmd(periodEnd); return `${start} ~ ${end}`; } + +export function isSettlementLocalDateRangeValid(startYmd: string, endYmd: string): boolean { + const start = parseDateYmd(startYmd); + const end = parseDateYmd(endYmd); + if (start === null || end === null) { + return false; + } + + return start.getTime() <= end.getTime(); +} + +function eachLocalYmdInRange(startYmd: string, endYmd: string): string[] { + const start = parseDateYmd(startYmd); + const end = parseDateYmd(endYmd); + if (start === null || end === null || start.getTime() > end.getTime()) { + return []; + } + + const days: string[] = []; + const cursor = new Date(start.getFullYear(), start.getMonth(), start.getDate()); + const endMs = new Date(end.getFullYear(), end.getMonth(), end.getDate()).getTime(); + + while (cursor.getTime() <= endMs) { + days.push(toDateYmdValue(cursor)); + cursor.setDate(cursor.getDate() + 1); + } + + return days; +} + +/** 本地账期范围是否与已有账期日期重叠 */ +export function settlementRangeOverlapsOccupiedDates( + startYmd: string, + endYmd: string, + occupiedLocalYmds: string[], +): boolean { + if (occupiedLocalYmds.length === 0) { + return false; + } + + const occupied = new Set(occupiedLocalYmds); + return eachLocalYmdInRange(startYmd, endYmd).some((day) => occupied.has(day)); +} diff --git a/src/lib/money.ts b/src/lib/money.ts index da4c4bf..a3bd028 100644 --- a/src/lib/money.ts +++ b/src/lib/money.ts @@ -115,3 +115,26 @@ export function parseAdminMajorToMinor( const minor = Math.round(n * factor); return Number.isSafeInteger(minor) ? minor : null; } + +export function parseSignedAdminMajorToMinor( + raw: string, + currencyCode = "NPR", + decimalPlaces?: number, +): number | null { + const cleaned = raw.replace(/,/g, "").trim(); + if (!cleaned) { + return null; + } + + const n = Number(cleaned); + if (!Number.isFinite(n) || n === 0) { + return null; + } + + const absMinor = parseAdminMajorToMinor(String(Math.abs(n)), currencyCode, decimalPlaces); + if (absMinor === null) { + return null; + } + + return n < 0 ? -absMinor : absMinor; +} diff --git a/src/modules/agents/agents-directory-console.tsx b/src/modules/agents/agents-directory-console.tsx index d5abda2..27de7a9 100644 --- a/src/modules/agents/agents-directory-console.tsx +++ b/src/modules/agents/agents-directory-console.tsx @@ -1,7 +1,6 @@ "use client"; -import Link from "next/link"; -import { RefreshCw, Search } from "lucide-react"; +import { Eye, RefreshCw, Search } from "lucide-react"; import { useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -9,11 +8,10 @@ import { getAgentNodes } from "@/api/admin-agents"; import { AdminPageCard } from "@/components/admin/admin-page-card"; import { AdminTableLoadingRow } from "@/components/admin/admin-loading-state"; import { AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state"; +import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu"; import { AdminStatusBadge } from "@/components/admin/admin-status-badge"; -import { Button, buttonVariants } from "@/components/ui/button"; -import { Checkbox } from "@/components/ui/checkbox"; +import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; import { Select, SelectContent, @@ -32,7 +30,6 @@ import { import { useAsyncEffect } from "@/hooks/use-async-effect"; import { useTranslationRef } from "@/hooks/use-translation-ref"; import type { AgentNodeRow } from "@/types/api/admin-agent"; -import { cn } from "@/lib/utils"; function formatPercent(value: number | null | undefined): string { if (value == null || Number.isNaN(value)) { @@ -56,6 +53,22 @@ function statusLabel(status: number, t: (key: string, options?: { defaultValue?: : t("statusDisabled", { defaultValue: "停用" }); } +type DirectoryStatusFilter = "all" | "enabled" | "disabled"; + +function directoryStatusLabel( + value: DirectoryStatusFilter, + t: (key: string, options?: { defaultValue?: string }) => string, +): string { + switch (value) { + case "enabled": + return t("directoryStatus.enabled", { defaultValue: "仅启用" }); + case "disabled": + return t("directoryStatus.disabled", { defaultValue: "仅停用" }); + default: + return t("directoryStatus.all", { defaultValue: "全部状态" }); + } +} + export function AgentsDirectoryConsole(): React.ReactElement { const { t } = useTranslation(["agents", "common"]); const tRef = useTranslationRef(["agents", "common"]); @@ -64,8 +77,7 @@ export function AgentsDirectoryConsole(): React.ReactElement { const [loading, setLoading] = useState(true); const [err, setErr] = useState(null); const [keyword, setKeyword] = useState(""); - const [status, setStatus] = useState<"all" | "enabled" | "disabled">("all"); - const [includeRoots, setIncludeRoots] = useState(false); + const [status, setStatus] = useState("all"); const [reloadKey, setReloadKey] = useState(0); const parentNameMap = useMemo( @@ -96,9 +108,6 @@ export function AgentsDirectoryConsole(): React.ReactElement { const normalized = keyword.trim().toLowerCase(); return items.filter((item) => { - if (!includeRoots && item.is_root) { - return false; - } if (status === "enabled" && item.status !== 1) { return false; } @@ -115,7 +124,7 @@ export function AgentsDirectoryConsole(): React.ReactElement { .toLowerCase() .includes(normalized); }); - }, [includeRoots, items, keyword, parentNameMap, status]); + }, [items, keyword, parentNameMap, status]); const totalOperatingAgents = useMemo( () => items.filter((item) => !item.is_root).length, @@ -175,29 +184,21 @@ export function AgentsDirectoryConsole(): React.ReactElement { />
- setStatus((value ?? "all") as DirectoryStatusFilter)} + > - + {() => directoryStatusLabel(status, t)} - - {t("directoryStatus.all", { defaultValue: "全部状态" })} - - - {t("directoryStatus.enabled", { defaultValue: "仅启用" })} - - - {t("directoryStatus.disabled", { defaultValue: "仅停用" })} - + {(["all", "enabled", "disabled"] as DirectoryStatusFilter[]).map((value) => ( + + {directoryStatusLabel(value, t)} + + ))} -
@@ -229,8 +230,8 @@ export function AgentsDirectoryConsole(): React.ReactElement { {t("lineUi.availableCredit", { defaultValue: "可下发" })} - - {t("common:actions.title", { defaultValue: "操作" })} + + {t("common:table.actions", { defaultValue: "操作" })} @@ -287,13 +288,17 @@ export function AgentsDirectoryConsole(): React.ReactElement { {formatCredit(profile?.available_credit)} - - - {t("common:actions.view", { defaultValue: "查看" })} - + + ); diff --git a/src/modules/agents/agents-players-panel.tsx b/src/modules/agents/agents-players-panel.tsx index 87ea4c9..da55a5a 100644 --- a/src/modules/agents/agents-players-panel.tsx +++ b/src/modules/agents/agents-players-panel.tsx @@ -56,7 +56,7 @@ import { useConfirmAction } from "@/hooks/use-confirm-action"; import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"; import { PlayerFundingModeBadge } from "@/components/admin/player-funding-badges"; import { formatPlayerCreditAmount, playerBalanceCells } from "@/lib/admin-player-display"; -import { formatAdminMinorUnits } from "@/lib/money"; +import { formatAdminMinorDecimal, formatAdminMinorUnits, parseAdminMajorToMinor } from "@/lib/money"; import { parsePercentUi, percentValueToUi } from "@/lib/admin-rate-percent"; import { adminPlayerDetailPath } from "@/lib/admin-player-paths"; import { adminHasAnyPermission } from "@/lib/admin-permissions"; @@ -503,6 +503,8 @@ export function AgentsPlayersPanel({ () => billingBills.find((bill) => bill.id === selectedBillId) ?? null, [billingBills, selectedBillId], ); + const billingCurrency = + selectedBill?.currency_code ?? billingPlayer?.default_currency ?? "NPR"; const projectedCreditLimit = useMemo(() => { const delta = editCreditDelta.trim() === "" ? 0 : Number.parseInt(editCreditDelta, 10); if (Number.isNaN(delta) || delta <= 0) { @@ -540,7 +542,9 @@ export function AgentsPlayersPanel({ setBillingBills(items); const first = items[0] ?? null; setSelectedBillId(first?.id ?? null); - setPayAmount(first ? String(first.unpaid_amount ?? 0) : ""); + setPayAmount( + first ? formatAdminMinorDecimal(first.unpaid_amount ?? 0, row.default_currency ?? "NPR") : "", + ); } catch (e) { toast.error( e instanceof LotteryApiBizError @@ -576,7 +580,11 @@ export function AgentsPlayersPanel({ async function handlePayBill(): Promise { if (selectedBill === null) return; - const amount = parseBillingAmount(payAmount || String(selectedBill.unpaid_amount ?? 0)); + const fallbackAmount = formatAdminMinorDecimal( + selectedBill.unpaid_amount ?? 0, + billingCurrency, + ); + const amount = parseAdminMajorToMinor(payAmount || fallbackAmount, billingCurrency); if (amount === null || amount <= 0 || amount > Number(selectedBill.unpaid_amount ?? 0)) { toast.error(t("playersPanel.paymentAmountInvalid", { defaultValue: "请输入有效的收付金额" })); return; @@ -632,14 +640,6 @@ export function AgentsPlayersPanel({ } } - function parseBillingAmount(raw: string): number | null { - const value = Number(raw); - if (!Number.isFinite(value) || !Number.isInteger(value)) { - return null; - } - return value; - } - function requestConfirmBillAction(): void { if (selectedBill === null) return; requestConfirm({ @@ -655,9 +655,13 @@ export function AgentsPlayersPanel({ function requestPayBillAction(): void { if (selectedBill === null) return; - const amount = parseBillingAmount(payAmount || String(selectedBill.unpaid_amount ?? 0)); + const fallbackAmount = formatAdminMinorDecimal( + selectedBill.unpaid_amount ?? 0, + billingCurrency, + ); + const amount = parseAdminMajorToMinor(payAmount || fallbackAmount, billingCurrency); if (amount === null || amount <= 0) { - toast.error(t("playersPanel.paymentAmountInvalid", { defaultValue: "请输入大于 0 的整数金额" })); + toast.error(t("playersPanel.paymentAmountInvalid", { defaultValue: "请输入大于 0 的有效金额" })); return; } if (amount > Number(selectedBill.unpaid_amount ?? 0)) { @@ -1044,7 +1048,11 @@ export function AgentsPlayersPanel({ onValueChange={(value) => { const next = billingBills.find((bill) => bill.id === Number(value)) ?? null; setSelectedBillId(next?.id ?? null); - setPayAmount(next ? String(next.unpaid_amount ?? 0) : ""); + setPayAmount( + next + ? formatAdminMinorDecimal(next.unpaid_amount ?? 0, billingCurrency) + : "", + ); setPayMethod(""); setPayProof(""); setBadDebtReason(""); @@ -1056,7 +1064,7 @@ export function AgentsPlayersPanel({ {billingBills.map((bill) => ( - {`#${bill.id} · ${bill.status} · ${bill.player_site_player_id ?? bill.owner_id} · ${bill.unpaid_amount ?? 0}`} + {`#${bill.id} · ${bill.status} · ${bill.player_site_player_id ?? bill.owner_id} · ${formatAdminMinorUnits(bill.unpaid_amount ?? 0, bill.currency_code ?? billingPlayer?.default_currency ?? "NPR")}`} ))} @@ -1076,7 +1084,7 @@ export function AgentsPlayersPanel({ {t("playersPanel.billUnpaid", { defaultValue: "未结" })}: {" "} - {selectedBill.unpaid_amount ?? 0} + {formatAdminMinorUnits(selectedBill.unpaid_amount ?? 0, billingCurrency)} @@ -1090,7 +1098,15 @@ export function AgentsPlayersPanel({
- setPayAmount(e.target.value)} /> + setPayAmount(e.target.value)} + inputMode="decimal" + placeholder={formatAdminMinorDecimal( + selectedBill.unpaid_amount ?? 0, + billingCurrency, + )} + />
@@ -1116,6 +1132,8 @@ export function AgentsPlayersPanel({ {t("agents:settlementBills.paid", { defaultValue: "登记收付" })} + {boundAgent === null ? ( + <>
{t("agents:settlementBills.confirmBadDebt", { defaultValue: "确认核销" })} + + ) : null}
) : null}
diff --git a/src/modules/dashboard/agent-dashboard-console.tsx b/src/modules/dashboard/agent-dashboard-console.tsx index 5ecbef4..c9cc869 100644 --- a/src/modules/dashboard/agent-dashboard-console.tsx +++ b/src/modules/dashboard/agent-dashboard-console.tsx @@ -43,6 +43,7 @@ import { DashboardAnalyticsPanel } from "@/modules/dashboard/dashboard-analytics import { formatDashboardCreditMajor, formatDashboardMoneyMinor, + formatDashboardSignedMoneyMinor, } from "@/modules/dashboard/use-dashboard-analytics"; import type { AdminDashboardAgentOverview } from "@/types/api/admin-dashboard"; import type { DrawCurrentSnapshot } from "@/types/api/public-draw"; @@ -229,9 +230,9 @@ export function AgentDashboardConsole(): ReactElement {

-

{t("agent.todayProfit")}

+

{t("agent.todayShareProfit")}

- {formatDashboardMoneyMinor(overview.today_profit_minor, displayCurrency)} + {formatDashboardSignedMoneyMinor(overview.today_profit_minor, displayCurrency)}

@@ -316,8 +317,8 @@ export function AgentDashboardConsole(): ReactElement { })}

- {t("agent.sevenDayProfit", { - amount: formatDashboardMoneyMinor(overview.seven_day_profit_minor, displayCurrency), + {t("agent.sevenDayShareProfit", { + amount: formatDashboardSignedMoneyMinor(overview.seven_day_profit_minor, displayCurrency), })}

@@ -384,9 +385,9 @@ export function AgentDashboardConsole(): ReactElement { {formatDashboardMoneyMinor(overview.top_agent_today.total_bet_minor, displayCurrency)}

- {t("agent.topMomentumHint", { - profit: formatDashboardMoneyMinor( - overview.top_agent_today.approx_house_gross_minor, + {t("agent.topMomentumPayout", { + amount: formatDashboardMoneyMinor( + overview.top_agent_today.total_payout_minor, displayCurrency, ), })} diff --git a/src/modules/dashboard/dashboard-analytics-panel.tsx b/src/modules/dashboard/dashboard-analytics-panel.tsx index 09cecf6..0f77da4 100644 --- a/src/modules/dashboard/dashboard-analytics-panel.tsx +++ b/src/modules/dashboard/dashboard-analytics-panel.tsx @@ -90,6 +90,7 @@ export function DashboardAnalyticsMain({ analytics }: { analytics: DashboardAnal sparklines, formatMoney, formatSignedMoney, + profitScope, } = analytics; if (!enabled) { @@ -234,14 +235,20 @@ export function DashboardAnalyticsMain({ analytics }: { analytics: DashboardAnal } /> 0 - ? t("marginRate", { - rate: ((summary.approx_house_gross_minor / summary.total_bet_minor) * 100).toFixed(1), - }) - : undefined + profitScope === "share_profit" + ? t("analytics.shareProfitHint") + : summary.total_bet_minor > 0 + ? t("marginRate", { + rate: ((summary.approx_house_gross_minor / summary.total_bet_minor) * 100).toFixed(1), + }) + : undefined } icon={} sparklineValues={sparklines.profit} diff --git a/src/modules/dashboard/dashboard-current-draw-card.tsx b/src/modules/dashboard/dashboard-current-draw-card.tsx index 9852bb9..ed0a8fe 100644 --- a/src/modules/dashboard/dashboard-current-draw-card.tsx +++ b/src/modules/dashboard/dashboard-current-draw-card.tsx @@ -9,9 +9,7 @@ import { buttonVariants } from "@/components/ui/button"; import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state"; import { Card, CardContent } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; -import { formatAdminInstantInTimeZone } from "@/lib/admin-datetime"; -import { getAdminRequestLocale } from "@/lib/admin-locale"; -import { LOTTERY_SCHEDULE_TIMEZONE } from "@/lib/lottery-schedule-timezone"; +import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"; import { cn } from "@/lib/utils"; import type { DrawCurrentSnapshot } from "@/types/api/public-draw"; @@ -64,11 +62,7 @@ export function DashboardCurrentDrawCard({ loading = false, }: DashboardCurrentDrawCardProps): ReactElement { const { t } = useTranslation(["dashboard", "draws"]); - const formatDt = (iso: string | null | undefined): string => - formatAdminInstantInTimeZone(iso, { - locale: getAdminRequestLocale(), - timeZone: LOTTERY_SCHEDULE_TIMEZONE, - }); + const formatDt = useAdminDateTimeFormatter(); if (loading) { return ( diff --git a/src/modules/dashboard/use-dashboard-analytics.ts b/src/modules/dashboard/use-dashboard-analytics.ts index 571e557..278855d 100644 --- a/src/modules/dashboard/use-dashboard-analytics.ts +++ b/src/modules/dashboard/use-dashboard-analytics.ts @@ -140,6 +140,7 @@ export function useDashboardAnalytics({ const currency = data?.currency_code ?? null; const summary = data?.summary; + const profitScope = data?.profit_scope ?? "house_gross"; const periodRangeLabel = useMemo(() => { if (!data) { @@ -230,6 +231,7 @@ export function useDashboardAnalytics({ data, currency, summary, + profitScope, periodRangeLabel, playFilterLabel, playOptions, diff --git a/src/modules/draws/draw-create-dialog.tsx b/src/modules/draws/draw-create-dialog.tsx index 370877b..07e6f6b 100644 --- a/src/modules/draws/draw-create-dialog.tsx +++ b/src/modules/draws/draw-create-dialog.tsx @@ -17,6 +17,10 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { + adminLocalScheduleValueToTimezoneNaive, + getAdminBrowserTimeZoneLabel, +} from "@/lib/admin-datetime"; import { LOTTERY_SCHEDULE_TIMEZONE } from "@/lib/lottery-schedule-timezone"; import { LotteryApiBizError } from "@/types/api/errors"; @@ -61,10 +65,15 @@ export function DrawCreateDialog({ } setSaving(true); try { + const scheduleTz = scheduleTimezone ?? LOTTERY_SCHEDULE_TIMEZONE; await postAdminCreateDraw({ - draw_time: form.drawTime.trim(), - close_time: form.closeTime.trim() || undefined, - start_time: form.startTime.trim() || undefined, + draw_time: adminLocalScheduleValueToTimezoneNaive(form.drawTime.trim(), scheduleTz), + close_time: form.closeTime.trim() + ? adminLocalScheduleValueToTimezoneNaive(form.closeTime.trim(), scheduleTz) + : undefined, + start_time: form.startTime.trim() + ? adminLocalScheduleValueToTimezoneNaive(form.startTime.trim(), scheduleTz) + : undefined, draw_no: form.drawNo.trim() || undefined, }); toast.success(t("createDraw.success")); @@ -84,7 +93,7 @@ export function DrawCreateDialog({ {t("createDraw.title")} - {t("createDraw.description", { tz: scheduleTimezone ?? LOTTERY_SCHEDULE_TIMEZONE })} + {t("createDraw.description", { tz: getAdminBrowserTimeZoneLabel() })} diff --git a/src/modules/draws/draw-detail-console.tsx b/src/modules/draws/draw-detail-console.tsx index 0986d5c..1e8b6a4 100644 --- a/src/modules/draws/draw-detail-console.tsx +++ b/src/modules/draws/draw-detail-console.tsx @@ -20,9 +20,7 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state"; import { AdminLoadingState } from "@/components/admin/admin-loading-state"; -import { formatAdminInstantInTimeZone } from "@/lib/admin-datetime"; -import { getAdminRequestLocale } from "@/lib/admin-locale"; -import { LOTTERY_SCHEDULE_TIMEZONE } from "@/lib/lottery-schedule-timezone"; +import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"; import { useConfirmAction } from "@/hooks/use-confirm-action"; import { LotteryApiBizError } from "@/types/api/errors"; import type { AdminDrawFinanceSummaryData } from "@/types/api/admin-draw-finance"; @@ -48,14 +46,7 @@ type ScheduleStep = { }; function ScheduleTimeline({ steps }: { steps: ScheduleStep[] }) { - const formatDt = useCallback( - (iso: string | null | undefined) => - formatAdminInstantInTimeZone(iso, { - locale: getAdminRequestLocale(), - timeZone: LOTTERY_SCHEDULE_TIMEZONE, - }), - [], - ); + const formatDt = useAdminDateTimeFormatter(); return (

    diff --git a/src/modules/draws/draw-edit-dialog.tsx b/src/modules/draws/draw-edit-dialog.tsx index b690908..af2a6e3 100644 --- a/src/modules/draws/draw-edit-dialog.tsx +++ b/src/modules/draws/draw-edit-dialog.tsx @@ -17,8 +17,11 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { formatAdminInstantInTimeZone } from "@/lib/admin-datetime"; -import { getAdminRequestLocale } from "@/lib/admin-locale"; +import { + adminLocalScheduleValueToTimezoneNaive, + getAdminBrowserTimeZoneLabel, + isoToAdminLocalScheduleValue, +} from "@/lib/admin-datetime"; import { LOTTERY_SCHEDULE_TIMEZONE } from "@/lib/lottery-schedule-timezone"; import { LotteryApiBizError } from "@/types/api/errors"; import type { AdminDrawListItem } from "@/types/api/admin-draws"; @@ -31,13 +34,6 @@ type DrawEditDialogProps = { onSaved: () => void | Promise; }; -function isoToScheduleValue(iso: string | null, timeZone: string): string { - return formatAdminInstantInTimeZone(iso, { - locale: getAdminRequestLocale(), - timeZone, - }); -} - export function DrawEditDialog({ open, onOpenChange, @@ -57,13 +53,12 @@ export function DrawEditDialog({ if (!open || draw == null) { return; } - const tz = scheduleTimezone ?? LOTTERY_SCHEDULE_TIMEZONE; - setDrawTime(isoToScheduleValue(draw.draw_time, tz)); - setCloseTime(isoToScheduleValue(draw.close_time, tz)); - setStartTime(isoToScheduleValue(draw.start_time, tz)); + setDrawTime(isoToAdminLocalScheduleValue(draw.draw_time)); + setCloseTime(isoToAdminLocalScheduleValue(draw.close_time)); + setStartTime(isoToAdminLocalScheduleValue(draw.start_time)); setDrawNo(draw.draw_no); }); - }, [open, draw, scheduleTimezone]); + }, [open, draw]); async function submit(): Promise { if (draw == null) { @@ -75,10 +70,15 @@ export function DrawEditDialog({ } setSaving(true); try { + const scheduleTz = scheduleTimezone ?? LOTTERY_SCHEDULE_TIMEZONE; await putAdminUpdateDraw(draw.id, { - draw_time: drawTime.trim(), - close_time: closeTime.trim() || undefined, - start_time: startTime.trim() || undefined, + draw_time: adminLocalScheduleValueToTimezoneNaive(drawTime.trim(), scheduleTz), + close_time: closeTime.trim() + ? adminLocalScheduleValueToTimezoneNaive(closeTime.trim(), scheduleTz) + : undefined, + start_time: startTime.trim() + ? adminLocalScheduleValueToTimezoneNaive(startTime.trim(), scheduleTz) + : undefined, draw_no: drawNo.trim() || undefined, }); toast.success(t("editDraw.success")); @@ -98,7 +98,7 @@ export function DrawEditDialog({ {t("editDraw.title")} {t("editDraw.description", { - tz: scheduleTimezone ?? LOTTERY_SCHEDULE_TIMEZONE, + tz: getAdminBrowserTimeZoneLabel(), drawNo: draw?.draw_no ?? "", })} diff --git a/src/modules/draws/draws-index-console.tsx b/src/modules/draws/draws-index-console.tsx index 82818b1..f35f7a5 100644 --- a/src/modules/draws/draws-index-console.tsx +++ b/src/modules/draws/draws-index-console.tsx @@ -14,8 +14,7 @@ import { postAdminCancelDraw, postAdminGenerateDrawPlan, } from "@/api/admin-draws"; -import { formatAdminInstantInTimeZone } from "@/lib/admin-datetime"; -import { getAdminRequestLocale } from "@/lib/admin-locale"; +import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"; import { LOTTERY_SCHEDULE_TIMEZONE } from "@/lib/lottery-schedule-timezone"; import { AdminTableLoadingRow } from "@/components/admin/admin-loading-state"; import { AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state"; @@ -99,14 +98,7 @@ export function DrawsIndexConsole() { const canViewFinance = canViewDrawFinance(profile?.permissions); const { request: requestConfirm, ConfirmDialog } = useConfirmAction(); const [data, setData] = useState(null); - const formatDt = useCallback( - (iso: string | null | undefined) => - formatAdminInstantInTimeZone(iso, { - locale: getAdminRequestLocale(), - timeZone: LOTTERY_SCHEDULE_TIMEZONE, - }), - [], - ); + const formatDt = useAdminDateTimeFormatter(); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [draftDrawNo, setDraftDrawNo] = useState(""); diff --git a/src/modules/settlement/agent-bill-detail.tsx b/src/modules/settlement/agent-bill-detail.tsx index 96f8fa1..275bed3 100644 --- a/src/modules/settlement/agent-bill-detail.tsx +++ b/src/modules/settlement/agent-bill-detail.tsx @@ -14,15 +14,17 @@ import { type RebateAllocationRow, type SettlementBillRow, type SettlementBillPaymentRow, + type DownlineShareBreakdown, } from "@/api/admin-agent-settlement"; import { AdminLoadingState } from "@/components/admin/admin-loading-state"; +import { formatAdminMinorDecimal, parseAdminMajorToMinor, parseSignedAdminMajorToMinor } from "@/lib/money"; import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics"; import { SettlementBillAmountBreakdown, - SettlementBillPartiesRow, SettlementBillSummaryHeader, } from "@/modules/settlement/settlement-bill-breakdown"; import { describeBillPaymentDirection } from "@/modules/settlement/settlement-bill-display"; +import { settlementBillOperableByBoundAgent } from "@/modules/settlement/settlement-bill-operable"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -33,6 +35,8 @@ type AgentBillDetailProps = { billId: number; currencyCode: string; canManage?: boolean; + canFinanceAdjustments?: boolean; + boundAgent?: { id: number } | null; onUpdated?: () => void; }; @@ -40,12 +44,15 @@ export function AgentBillDetail({ billId, currencyCode, canManage = true, + canFinanceAdjustments = false, + boundAgent = null, onUpdated, }: AgentBillDetailProps): React.ReactElement { const { t } = useTranslation(["agents", "settlementCenter", "common"]); const [bill, setBill] = useState(null); const [payments, setPayments] = useState([]); const [rebateAllocations, setRebateAllocations] = useState([]); + const [downlineShares, setDownlineShares] = useState(null); const [loading, setLoading] = useState(true); const [payAmount, setPayAmount] = useState(""); const [payMethod, setPayMethod] = useState(""); @@ -63,11 +70,12 @@ export function AgentBillDetail({ setBill(data.bill); setPayments(data.payments ?? []); setRebateAllocations(data.rebate_allocations ?? []); - setPayAmount(String(data.bill.unpaid_amount ?? 0)); + setDownlineShares(data.downline_shares ?? null); + setPayAmount(formatAdminMinorDecimal(data.bill.unpaid_amount ?? 0, currencyCode)); } finally { setLoading(false); } - }, [billId]); + }, [billId, currencyCode]); useEffect(() => { void load(); @@ -78,6 +86,7 @@ export function AgentBillDetail({ } const direction = describeBillPaymentDirection(bill, t); + const canOperateBill = canManage && settlementBillOperableByBoundAgent(bill, boundAgent); const locked = ["confirmed", "partial_paid", "settled", "overdue"].includes(bill.status); const paymentTitle = direction.ownerOwes ? t("settlementBills.submitReceipt", { defaultValue: "登记收款" }) @@ -86,7 +95,7 @@ export function AgentBillDetail({ ? t("settlementBills.submitReceipt", { defaultValue: "确认收款" }) : t("settlementBills.submitPayout", { defaultValue: "确认付款" }); const canWriteOff = - canManage && + canFinanceAdjustments && bill.unpaid_amount > 0 && ["confirmed", "partial_paid", "overdue"].includes(bill.status) && !["adjustment", "reversal", "bad_debt"].includes(bill.bill_type); @@ -119,14 +128,6 @@ export function AgentBillDetail({ toast.error(err instanceof LotteryApiBizError ? err.message : fallback); }; - const parseWholeAmount = (raw: string): number | null => { - const value = Number(raw); - if (!Number.isFinite(value) || !Number.isInteger(value)) { - return null; - } - return value; - }; - const requestConfirmBill = (): void => { requestConfirm({ title: t("settlementBills.confirmBillTitle", { defaultValue: "确认账单?" }), @@ -152,9 +153,9 @@ export function AgentBillDetail({ }; const requestPayment = (): void => { - const amount = parseWholeAmount(payAmount); + const amount = parseAdminMajorToMinor(payAmount, currencyCode); if (amount === null || amount <= 0) { - toast.error(t("settlementBills.paymentAmountInvalid", { defaultValue: "请输入大于 0 的整数金额" })); + toast.error(t("settlementBills.paymentAmountInvalid", { defaultValue: "请输入大于 0 的有效金额" })); return; } if (amount > bill.unpaid_amount) { @@ -220,10 +221,10 @@ export function AgentBillDetail({ }; const requestAdjustment = (): void => { - const amount = parseWholeAmount(adjustAmount); + const amount = parseSignedAdminMajorToMinor(adjustAmount, currencyCode); const reason = adjustReason.trim(); if (amount === null || amount === 0) { - toast.error(t("settlementBills.adjustmentAmountInvalid", { defaultValue: "请输入非 0 的整数调整金额" })); + toast.error(t("settlementBills.adjustmentAmountInvalid", { defaultValue: "请输入非 0 的有效金额" })); return; } if (!reason) { @@ -262,38 +263,38 @@ export function AgentBillDetail({ return ( <> -
    -
    - - - +
    + + - {payments.length > 0 ? ( -
    -

    - {t("settlementBills.paymentsHistory", { defaultValue: "收付记录" })} -

    -
      - {payments.map((p) => ( -
    • - - {p.method - ? `${p.method}` - : t("settlementCenter:billDisplay.payment", { defaultValue: "收付" })} - {p.remark ? ` · ${p.remark}` : ""} - - - {formatDashboardMoneyMinor(p.amount, currencyCode)} - -
    • - ))} -
    -
    - ) : null} -
    + {payments.length > 0 ? ( +
    +

    + {t("settlementBills.paymentsHistory", { defaultValue: "收付记录" })} +

    +
      + {payments.map((p) => ( +
    • + + {p.method + ? `${p.method}` + : t("settlementCenter:billDisplay.payment", { defaultValue: "收付" })} + {p.remark ? ` · ${p.remark}` : ""} + + + {formatDashboardMoneyMinor(p.amount, currencyCode)} + +
    • + ))} +
    +
    + ) : null} -
    - {rebateAllocations.length > 0 ? ( + {rebateAllocations.length > 0 ? (

    {t("settlementBills.rebateAllocations", { defaultValue: "回水分摊" })} @@ -353,30 +354,30 @@ export function AgentBillDetail({

    ) : null} - {canManage && bill.status === "pending_confirm" ? ( -
    -
    -

    - {t("settlementBills.confirm", { defaultValue: "确认账单" })} -

    -

    - {t("settlementCenter:billDisplay.confirmHint", { - defaultValue: "确认后才可以登记收款或付款。", - })} -

    -
    - +

    +

    + {t("settlementCenter:billDisplay.confirmHint", { + defaultValue: "确认后才可以登记收款或付款。", + })} +

    - ) : null} + +
    + ) : null} - {canManage && ["confirmed", "partial_paid", "overdue"].includes(bill.status) && bill.unpaid_amount > 0 ? ( + {canOperateBill && ["confirmed", "partial_paid", "overdue"].includes(bill.status) && bill.unpaid_amount > 0 ? (
    @@ -398,7 +399,8 @@ export function AgentBillDetail({ setPayAmount(e.target.value)} - placeholder={String(bill.unpaid_amount)} + inputMode="decimal" + placeholder={formatAdminMinorDecimal(bill.unpaid_amount, currencyCode)} className="bg-background/50 transition-colors focus:bg-background" />
    @@ -436,7 +438,7 @@ export function AgentBillDetail({
    ) : null} - {canWriteOff ? ( + {canWriteOff ? (

    @@ -471,7 +473,7 @@ export function AgentBillDetail({

    ) : null} - {canManage && locked ? ( + {canFinanceAdjustments && locked ? (

    @@ -488,9 +490,9 @@ export function AgentBillDetail({ setAdjustAmount(e.target.value)} - type="number" + inputMode="decimal" placeholder={t("settlementBills.adjustmentAmountPlaceholder", { - defaultValue: "输入正数或负数", + defaultValue: "例如:35.20 或 -10.00", })} className="bg-background/50 transition-colors focus:bg-background" /> @@ -517,7 +519,6 @@ export function AgentBillDetail({

    ) : null} -
    ); diff --git a/src/modules/settlement/agent-settlement-reports-panel.tsx b/src/modules/settlement/agent-settlement-reports-panel.tsx index f72b1ca..e6437c7 100644 --- a/src/modules/settlement/agent-settlement-reports-panel.tsx +++ b/src/modules/settlement/agent-settlement-reports-panel.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { @@ -18,8 +18,9 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { useAdminProfile } from "@/stores/admin-session"; -const REPORT_TYPES: AgentSettlementReportType[] = [ +const ALL_REPORT_TYPES: AgentSettlementReportType[] = [ "summary", "player_win_loss", "agent_share", @@ -43,6 +44,14 @@ export function AgentSettlementReportsPanel({ currencyCode, }: AgentSettlementReportsPanelProps): React.ReactElement { const { t } = useTranslation(["agents", "common"]); + const profile = useAdminProfile(); + const reportTypes = useMemo( + () => + profile?.agent != null + ? ALL_REPORT_TYPES.filter((type) => type !== "platform_pnl") + : ALL_REPORT_TYPES, + [profile?.agent], + ); const [reportType, setReportType] = useState("summary"); const [response, setResponse] = useState(null); const [loading, setLoading] = useState(false); @@ -82,7 +91,7 @@ export function AgentSettlementReportsPanel({ {() => reportTypeLabel(reportType)} - {REPORT_TYPES.map((key) => ( + {reportTypes.map((key) => ( {reportTypeLabel(key)} diff --git a/src/modules/settlement/settlement-adjustments-table.tsx b/src/modules/settlement/settlement-adjustments-table.tsx index f781c47..6764760 100644 --- a/src/modules/settlement/settlement-adjustments-table.tsx +++ b/src/modules/settlement/settlement-adjustments-table.tsx @@ -8,6 +8,7 @@ import { AdminLoadingState } from "@/components/admin/admin-loading-state"; import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"; import { formatSettlementPeriodSpan } from "@/lib/agent-settlement-period-range"; import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics"; +import { settlementAdjustmentTypeLabel } from "@/modules/settlement/settlement-status-label"; import { Table, TableBody, @@ -62,9 +63,7 @@ export function SettlementAdjustmentsTable({ {formatSettlementPeriodSpan(row.period_start, row.period_end)} - {t(`adjustmentType.${row.adjustment_type}`, { - defaultValue: row.adjustment_type, - })} + {settlementAdjustmentTypeLabel(row.adjustment_type, t)} {row.original_bill_id != null ? `#${row.original_bill_id}` : "—"} diff --git a/src/modules/settlement/settlement-bill-breakdown.tsx b/src/modules/settlement/settlement-bill-breakdown.tsx index db1fba5..d4764f0 100644 --- a/src/modules/settlement/settlement-bill-breakdown.tsx +++ b/src/modules/settlement/settlement-bill-breakdown.tsx @@ -10,8 +10,13 @@ import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-ana import { buildBillAmountBreakdown, describeBillPaymentDirection, - resolveBillPartyName, + type BillBreakdownLine, + type DownlineShareBreakdown, } from "@/modules/settlement/settlement-bill-display"; +import { + formatSignedSettlementMoney, + signedSettlementMoneyClass, +} from "@/modules/settlement/settlement-signed-money"; import { settlementBillStatusLabel, settlementBillTypeLabel, @@ -41,67 +46,74 @@ export function SettlementBillSummaryHeader({
    -
    - {direction.payer} - - {t("settlementCenter:billDisplay.pays", { defaultValue: "应付" })} - - - {direction.payee} -
    +
    +
    +
    + + {t("settlementCenter:billDisplay.payerLabel", { defaultValue: "付款方" })} + + {direction.payer} + + + {t("settlementCenter:billDisplay.payeeLabel", { defaultValue: "收款方" })} + + {direction.payee} +
    -
    -

    - {t("settlementCenter:billDisplay.settlementAmount", { defaultValue: "本期结算金额" })} -

    -

    - {formatDashboardMoneyMinor(direction.amount, currencyCode)} -

    -
    - -
    -
    -

    - {t("settlementCenter:columns.paid", { defaultValue: "已收付" })} -

    -

    - {formatDashboardMoneyMinor(bill.paid_amount ?? 0, currencyCode)} -

    +
    +

    + {t("settlementCenter:billDisplay.settlementAmount", { defaultValue: "结算金额" })} +

    +

    + {formatDashboardMoneyMinor(direction.amount, currencyCode)} +

    +
    -
    -

    - {t("settlementCenter:columns.unpaid", { defaultValue: "未结" })} -

    -

    +

    +

    + {t("settlementCenter:columns.paid", { defaultValue: "已收付" })} +

    +

    + {formatDashboardMoneyMinor(bill.paid_amount ?? 0, currencyCode)} +

    +
    +
    - {formatDashboardMoneyMinor(bill.unpaid_amount, currencyCode)} -

    - {unpaid ? ( -

    - {bill.status === "pending_confirm" - ? t("settlementCenter:billDisplay.unpaidPendingConfirm", { - defaultValue: "确认账单后可登记收付", - }) - : t("settlementCenter:billDisplay.unpaidAwaitingPayment", { - defaultValue: "请登记线下收付", - })} +

    + {t("settlementCenter:columns.unpaid", { defaultValue: "未结" })}

    - ) : ( -

    - {t("settlementCenter:billDisplay.fullySettled", { defaultValue: "本期已结清" })} +

    + {formatDashboardMoneyMinor(bill.unpaid_amount, currencyCode)}

    - )} + {unpaid ? ( +

    + {bill.status === "pending_confirm" + ? t("settlementCenter:billDisplay.unpaidPendingConfirm", { + defaultValue: "确认账单后可登记收付", + }) + : t("settlementCenter:billDisplay.unpaidAwaitingPayment", { + defaultValue: "请登记线下收付", + })} +

    + ) : ( +

    + {t("settlementCenter:billDisplay.fullySettled", { defaultValue: "本期已结清" })} +

    + )} +
    @@ -111,85 +123,105 @@ export function SettlementBillSummaryHeader({ type SettlementBillAmountBreakdownProps = { bill: SettlementBillRow; currencyCode: string; + downlineShares?: DownlineShareBreakdown | null; }; +function BreakdownAmountLine({ + line, + currencyCode, + nested = false, +}: { + line: BillBreakdownLine; + currencyCode: string; + nested?: boolean; +}): React.ReactElement { + const prefix = + line.kind === "subtract" + ? "−" + : line.kind === "add" && line.key !== "gross" + ? "+" + : line.kind === "subtotal" || line.kind === "total" + ? "=" + : ""; + + return ( + <> +
    +
    + + {prefix ? {prefix} : null} + {line.label} + + {line.hint ? ( +

    {line.hint}

    + ) : null} +
    + + {formatSignedSettlementMoney(line.signedAmount, currencyCode)} + +
    + {line.children?.map((child) => ( + + ))} + + ); +} + export function SettlementBillAmountBreakdown({ bill, currencyCode, + downlineShares = null, }: SettlementBillAmountBreakdownProps): React.ReactElement | null { const { t } = useTranslation(["settlementCenter", "agents"]); - const lines = buildBillAmountBreakdown(bill, t); + const lines = buildBillAmountBreakdown(bill, t, downlineShares); if (lines.length === 0) { return null; } + const breakdownIntro = + bill.bill_type === "player" + ? t("settlementCenter:billDisplay.playerBreakdownIntro", { + defaultValue: "玩家只与直属代理结算,净额 = 输赢 − 回水。", + }) + : bill.bill_type === "agent" + ? t("settlementCenter:billDisplay.agentBreakdownIntro", { + defaultValue: "代理只与直属上级结算,净额 = 团队净额 − 下级占成 − 本级占成。", + }) + : null; + return (
    -

    - {t("settlementCenter:billDisplay.howAmountWorks", { defaultValue: "金额怎么来的" })} -

    +
    +

    + {t("settlementCenter:billDisplay.howAmountWorks", { defaultValue: "结算明细" })} +

    + {breakdownIntro ? ( +

    {breakdownIntro}

    + ) : null} +
    -
    - {lines.map((line) => { - const prefix = - line.kind === "subtract" - ? "−" - : line.kind === "add" && lines.indexOf(line) > 0 - ? "+" - : line.kind === "subtotal" || line.kind === "total" - ? "=" - : ""; - - return ( -
    -
    - - {prefix ? {prefix} : null} - {line.label} - -
    - - {formatDashboardMoneyMinor(line.amount, currencyCode)} - -
    - ); - })} -
    -
    - ); -} - -type SettlementBillPartiesRowProps = { - bill: SettlementBillRow; -}; - -export function SettlementBillPartiesRow({ bill }: SettlementBillPartiesRowProps): React.ReactElement { - const { t } = useTranslation(["settlementCenter", "agents"]); - const owner = resolveBillPartyName(bill, "owner", t); - const counterparty = resolveBillPartyName(bill, "counterparty", t); - - return ( -
    -
    -

    - {t("settlementCenter:billDisplay.billOwner", { defaultValue: "账单主体" })} -

    -

    {owner}

    -
    -
    -

    - {t("settlementCenter:billDisplay.billCounterparty", { defaultValue: "结算对手" })} -

    -

    {counterparty}

    +
    + {lines.map((line) => ( + + ))}
    ); diff --git a/src/modules/settlement/settlement-bill-display.ts b/src/modules/settlement/settlement-bill-display.ts index 9b0acc2..cbff404 100644 --- a/src/modules/settlement/settlement-bill-display.ts +++ b/src/modules/settlement/settlement-bill-display.ts @@ -2,6 +2,20 @@ import type { TFunction } from "i18next"; import type { SettlementBillRow } from "@/api/admin-agent-settlement"; +function billDisplayLabel( + t: TFunction<["agents", "settlementCenter"]>, + key: string, + defaultValue: string, + vars: Record = {}, +): string { + const rendered = t(`settlementCenter:billDisplay.${key}`, { defaultValue, ...vars }); + if (!/\{\{\w+\}\}/.test(rendered)) { + return rendered; + } + + return defaultValue.replace(/\{\{(\w+)\}\}/g, (_, name: string) => vars[name] ?? ""); +} + export type BillPartyRole = "owner" | "counterparty"; export type BillPaymentDirection = { @@ -93,12 +107,25 @@ export function billDirectionHint( }); } +export type DownlineShareItem = { + owner_id: number; + owner_label: string; + share_profit: number; +}; + +export type DownlineShareBreakdown = { + total: number; + items: DownlineShareItem[]; +}; + export type BillBreakdownLine = { key: string; label: string; amount: number; + signedAmount: number; kind: "add" | "subtract" | "subtotal" | "total"; hint?: string; + children?: BillBreakdownLine[]; }; export function parseBillMeta(metaJson: SettlementBillRow["meta_json"]): { @@ -181,6 +208,7 @@ export function describeBillPaymentDirection( export function buildBillAmountBreakdown( bill: SettlementBillRow, t: TFunction<["agents", "settlementCenter"]>, + downlineShares?: DownlineShareBreakdown | null, ): BillBreakdownLine[] { const meta = parseBillMeta(bill.meta_json); const gross = bill.gross_win_loss ?? 0; @@ -195,7 +223,8 @@ export function buildBillAmountBreakdown( lines.push({ key: "gross", label: t("settlementCenter:billDisplay.playerGross", { defaultValue: "游戏输赢" }), - amount: gross, + amount: Math.abs(gross), + signedAmount: gross, kind: "add", hint: gross > 0 @@ -209,7 +238,8 @@ export function buildBillAmountBreakdown( lines.push({ key: "rebate", label: t("settlementCenter:billDisplay.rebate", { defaultValue: "回水" }), - amount: rebate, + amount: Math.abs(rebate), + signedAmount: -Math.abs(rebate), kind: "subtract", }); } @@ -217,7 +247,8 @@ export function buildBillAmountBreakdown( lines.push({ key: "rounding", label: t("agents:settlementBills.platformRounding", { defaultValue: "平台尾差" }), - amount: rounding, + amount: Math.abs(rounding), + signedAmount: rounding > 0 ? -Math.abs(rounding) : Math.abs(rounding), kind: rounding > 0 ? "subtract" : "add", }); } @@ -228,19 +259,21 @@ export function buildBillAmountBreakdown( ? t("settlementCenter:billDisplay.playerNet", { defaultValue: "玩家应付净额" }) : t("settlementCenter:billDisplay.playerNetReceive", { defaultValue: "代理应付玩家" }), amount: Math.abs(bill.net_amount), + signedAmount: bill.net_amount, kind: "total", }); return lines; } if (bill.bill_type === "agent") { - const owner = resolveBillPartyName(bill, "owner", t); + const counterparty = resolveBillPartyName(bill, "counterparty", t); const lines: BillBreakdownLine[] = []; if (bill.gross_win_loss != null) { lines.push({ key: "gross", label: t("settlementCenter:billDisplay.teamGross", { defaultValue: "团队游戏输赢" }), - amount: gross, + amount: Math.abs(gross), + signedAmount: gross, kind: "add", hint: t("settlementCenter:billDisplay.teamGrossHint", { defaultValue: "含本级及下级玩家的合计", @@ -251,7 +284,8 @@ export function buildBillAmountBreakdown( lines.push({ key: "rebate", label: t("settlementCenter:billDisplay.teamRebate", { defaultValue: "团队回水" }), - amount: rebate, + amount: Math.abs(rebate), + signedAmount: -Math.abs(rebate), kind: "subtract", }); } @@ -260,28 +294,49 @@ export function buildBillAmountBreakdown( key: "team-net", label: t("settlementCenter:billDisplay.teamNet", { defaultValue: "团队净额" }), amount: Math.abs(teamNet), + signedAmount: teamNet, kind: "subtotal", }); } + if (downlineShares && downlineShares.total > 0) { + lines.push({ + key: "downline-share", + label: billDisplayLabel(t, "agentDownlineShare", "下级占成"), + amount: Math.abs(downlineShares.total), + signedAmount: -Math.abs(downlineShares.total), + kind: "subtract", + hint: billDisplayLabel( + t, + "agentDownlineShareHint", + "下级代理按占成比例保留的利润(明细见下行)", + ), + children: downlineShares.items.map((item) => ({ + key: `downline-share-${item.owner_id}`, + label: billDisplayLabel(t, "agentDownlineShareItem", "{{agent}} 保留", { + agent: item.owner_label, + }), + amount: Math.abs(item.share_profit), + signedAmount: -Math.abs(item.share_profit), + kind: "subtract" as const, + })), + }); + } if (meta.share_profit != null) { lines.push({ key: "share", - label: t("settlementCenter:billDisplay.agentShareKeep", { - defaultValue: "{{agent}} 本级占成", - agent: owner, - }), - amount: shareProfit, + label: billDisplayLabel(t, "agentShareKeep", "本级占成"), + amount: Math.abs(shareProfit), + signedAmount: -Math.abs(shareProfit), kind: "subtract", - hint: t("settlementCenter:billDisplay.agentShareKeepHint", { - defaultValue: "本级按占成比例留下的利润", - }), + hint: billDisplayLabel(t, "agentShareKeepHint", "本级按占成比例留下的利润"), }); } if (rounding !== 0) { lines.push({ key: "rounding", label: t("agents:settlementBills.platformRounding", { defaultValue: "平台尾差" }), - amount: rounding, + amount: Math.abs(rounding), + signedAmount: rounding > 0 ? -Math.abs(rounding) : Math.abs(rounding), kind: rounding > 0 ? "subtract" : "add", }); } @@ -289,15 +344,10 @@ export function buildBillAmountBreakdown( key: "net", label: bill.net_amount > 0 - ? t("settlementCenter:billDisplay.agentNet", { - defaultValue: "{{agent}} 应付上级", - agent: owner, - }) - : t("settlementCenter:billDisplay.agentNetReceive", { - defaultValue: "上级应付 {{agent}}", - agent: owner, - }), + ? billDisplayLabel(t, "agentNet", "应付 {{counterparty}}", { counterparty }) + : billDisplayLabel(t, "agentNetReceive", "{{counterparty}} 应付本级", { counterparty }), amount: Math.abs(bill.net_amount), + signedAmount: bill.net_amount, kind: "total", }); return lines; diff --git a/src/modules/settlement/settlement-bill-operable.ts b/src/modules/settlement/settlement-bill-operable.ts new file mode 100644 index 0000000..30066d3 --- /dev/null +++ b/src/modules/settlement/settlement-bill-operable.ts @@ -0,0 +1,59 @@ +import type { SettlementBillRow } from "@/api/admin-agent-settlement"; + +type BoundAgentRef = { id: number } | null | undefined; + +function billPayeeParty( + bill: Pick, +): { type: string; id: number } { + if ((bill.net_amount ?? 0) < 0) { + return { type: bill.owner_type, id: bill.owner_id }; + } + + return { type: bill.counterparty_type, id: bill.counterparty_id }; +} + +function agentBillOnDirectEdge( + actorId: number, + bill: Pick, +): boolean { + if (bill.owner_id === actorId) { + return true; + } + if (bill.counterparty_type === "agent" && bill.counterparty_id === actorId) { + return true; + } + if (bill.counterparty_type === "platform" && bill.owner_id === actorId) { + return true; + } + + return false; +} + +/** 绑定代理仅可操作直属边账单,且代理账单仅收款方可登记(与后端 AdminAgentSettlementScope 一致)。 */ +export function settlementBillOperableByBoundAgent( + bill: Pick< + SettlementBillRow, + "bill_type" | "owner_type" | "owner_id" | "counterparty_type" | "counterparty_id" | "net_amount" + >, + boundAgent: BoundAgentRef, +): boolean { + if (boundAgent == null) { + return true; + } + + if (bill.owner_type === "player" || bill.bill_type === "player") { + return bill.counterparty_type === "agent" && bill.counterparty_id === boundAgent.id; + } + + if (bill.owner_type === "agent" || bill.bill_type === "agent") { + if (!agentBillOnDirectEdge(boundAgent.id, bill)) { + return false; + } + + const payee = billPayeeParty(bill); + + return payee.type === "agent" && payee.id === boundAgent.id; + } + + return false; +} diff --git a/src/modules/settlement/settlement-center-nav.ts b/src/modules/settlement/settlement-center-nav.ts index 538e799..a1ec5b9 100644 --- a/src/modules/settlement/settlement-center-nav.ts +++ b/src/modules/settlement/settlement-center-nav.ts @@ -1,23 +1,42 @@ -export type SettlementPeriodView = "bills" | "ledger"; +export type SettlementPeriodView = "bills" | "operations" | "ledger"; -const VALID_VIEWS: SettlementPeriodView[] = ["bills", "ledger"]; +const VALID_VIEWS: SettlementPeriodView[] = ["bills", "operations", "ledger"]; -export function settlementCenterListHref(): string { +function parsePositiveInt(raw: string | null): number | null { + if (raw === null || raw === "") { + return null; + } + const value = Number(raw); + return Number.isInteger(value) && value > 0 ? value : null; +} + +export function settlementCenterListHref(adminSiteId?: number | null): string { + if (adminSiteId != null && adminSiteId > 0) { + return `/admin/settlement-center?site=${adminSiteId}`; + } return "/admin/settlement-center"; } export function settlementPeriodViewHref( periodId: number, view: SettlementPeriodView = "bills", + adminSiteId?: number | null, ): string { - return `/admin/settlement-center?period=${periodId}&view=${view}`; + const params = new URLSearchParams({ + period: String(periodId), + view, + }); + if (adminSiteId != null && adminSiteId > 0) { + params.set("site", String(adminSiteId)); + } + return `/admin/settlement-center?${params.toString()}`; } export function parseSettlementCenterView( + siteRaw: string | null, periodRaw: string | null, viewRaw: string | null, -): { periodId: number | null; view: SettlementPeriodView } { - const periodId = periodRaw !== null && periodRaw !== "" ? Number(periodRaw) : NaN; +): { siteId: number | null; periodId: number | null; view: SettlementPeriodView } { const normalizedView = viewRaw === "reports" ? "bills" : viewRaw; const view = normalizedView !== null && VALID_VIEWS.includes(normalizedView as SettlementPeriodView) @@ -25,7 +44,8 @@ export function parseSettlementCenterView( : "bills"; return { - periodId: Number.isInteger(periodId) && periodId > 0 ? periodId : null, + siteId: parsePositiveInt(siteRaw), + periodId: parsePositiveInt(periodRaw), view, }; } diff --git a/src/modules/settlement/settlement-center-period-detail.tsx b/src/modules/settlement/settlement-center-period-detail.tsx index 31ee3ce..8291e6e 100644 --- a/src/modules/settlement/settlement-center-period-detail.tsx +++ b/src/modules/settlement/settlement-center-period-detail.tsx @@ -8,6 +8,7 @@ import type { SettlementPeriodRow } from "@/api/admin-agent-settlement"; import { AdminStatusBadge } from "@/components/admin/admin-status-badge"; import { SettlementCreditLedgerPanel } from "@/modules/settlement/settlement-credit-ledger-panel"; import { SettlementMainPanel } from "@/modules/settlement/settlement-main-panel"; +import { SettlementOperationsPanel } from "@/modules/settlement/settlement-operations-panel"; import { settlementCenterListHref, settlementPeriodViewHref, @@ -25,6 +26,7 @@ type SettlementCenterPeriodDetailProps = { adminSiteId: number; currencyCode: string; canOperateBills: boolean; + boundAgentId?: number | null; refreshKey: number; onOpenBillDetail: (billId: number) => void; }; @@ -35,6 +37,7 @@ export function SettlementCenterPeriodDetail({ adminSiteId, currencyCode, canOperateBills, + boundAgentId = null, refreshKey, onOpenBillDetail, }: SettlementCenterPeriodDetailProps): React.ReactElement { @@ -42,6 +45,7 @@ export function SettlementCenterPeriodDetail({ const subViews: { key: SettlementPeriodView; label: string }[] = [ { key: "bills", label: t("nav.bills", { defaultValue: "账单" }) }, + { key: "operations", label: t("nav.operations", { defaultValue: "收付与调账" }) }, { key: "ledger", label: t("nav.ledger", { defaultValue: "账务流水" }) }, ]; @@ -53,7 +57,7 @@ export function SettlementCenterPeriodDetail({
    @@ -74,7 +78,7 @@ export function SettlementCenterPeriodDetail({ {subViews.map((item) => ( {item.label} @@ -93,6 +97,18 @@ export function SettlementCenterPeriodDetail({ pendingConfirm={pendingConfirm} awaitingPayment={awaitingPayment} selectedPeriodStatus={period.status} + boundAgentId={boundAgentId} + /> + ) : null} + + {view === "operations" ? ( + ) : null} diff --git a/src/modules/settlement/settlement-center-shell.tsx b/src/modules/settlement/settlement-center-shell.tsx index d71b57a..e0e13e2 100644 --- a/src/modules/settlement/settlement-center-shell.tsx +++ b/src/modules/settlement/settlement-center-shell.tsx @@ -1,6 +1,7 @@ "use client"; import { Check, ChevronDown, Search } from "lucide-react"; +import Link from "next/link"; import { useCallback, useEffect, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { useTranslation } from "react-i18next"; @@ -8,10 +9,12 @@ import { toast } from "sonner"; import { getSettlementPeriods, type SettlementPeriodRow } from "@/api/admin-agent-settlement"; import { getAdminIntegrationSites } from "@/api/admin-integration-sites"; +import { AdminLoadingState } from "@/components/admin/admin-loading-state"; import { AgentBillDetail } from "@/modules/settlement/agent-bill-detail"; import { SettlementCenterPeriodDetail } from "@/modules/settlement/settlement-center-period-detail"; import { parseSettlementCenterView, + settlementCenterListHref, settlementPeriodViewHref, type SettlementPeriodView, } from "@/modules/settlement/settlement-center-nav"; @@ -25,7 +28,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; +import { Button, buttonVariants } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { ScrollArea } from "@/components/ui/scroll-area"; @@ -41,7 +44,8 @@ export function SettlementCenterShell(): React.ReactElement { const profile = useAdminProfile(); const boundAgent = profile?.agent ?? null; - const { periodId: activePeriodId, view: activeView } = parseSettlementCenterView( + const { siteId: siteFromUrl, periodId: activePeriodId, view: activeView } = parseSettlementCenterView( + searchParams.get("site"), searchParams.get("period"), searchParams.get("view"), ); @@ -50,6 +54,7 @@ export function SettlementCenterShell(): React.ReactElement { profile?.is_super_admin === true || adminHasAnyPermission(profile?.permissions, [PRD_SETTLEMENT_AGENT_MANAGE]); const canManagePeriods = canOperateBills && boundAgent === null; + const canFinanceAdjustments = canOperateBills && boundAgent === null; const [siteOptions, setSiteOptions] = useState([]); const [adminSiteId, setAdminSiteId] = useState(null); @@ -59,10 +64,14 @@ export function SettlementCenterShell(): React.ReactElement { const [periodsReady, setPeriodsReady] = useState(false); const [detailBillId, setDetailBillId] = useState(null); const [refreshKey, setRefreshKey] = useState(0); + const [periodLookupDone, setPeriodLookupDone] = useState(false); useEffect(() => { if (boundAgent?.admin_site_id) { - const label = formatAdminSiteLabel(boundAgent.name, boundAgent.site_code ?? boundAgent.code); + const label = formatAdminSiteLabel( + boundAgent.admin_site_name, + boundAgent.site_code ?? boundAgent.code, + ); setSiteOptions([{ id: boundAgent.admin_site_id, label, @@ -81,11 +90,17 @@ export function SettlementCenterShell(): React.ReactElement { currency_code: site.currency_code ?? "NPR", })); setSiteOptions(options); - if (adminSiteId === null && options[0]) { - setAdminSiteId(options[0].id); - } + setAdminSiteId((current) => { + if (siteFromUrl !== null && options.some((site) => site.id === siteFromUrl)) { + return siteFromUrl; + } + if (current !== null && options.some((site) => site.id === current)) { + return current; + } + return options[0]?.id ?? null; + }); }); - }, [adminSiteId, boundAgent]); + }, [boundAgent, siteFromUrl]); const siteId = adminSiteId ?? siteOptions[0]?.id ?? null; const selectedSite = siteOptions.find((s) => s.id === siteId) ?? null; @@ -95,6 +110,17 @@ export function SettlementCenterShell(): React.ReactElement { ? siteOptions.filter((site) => site.label.toLowerCase().includes(siteKeyword.trim().toLowerCase())) : siteOptions; + const boundAgentIdentity = + boundAgent !== null ? ( +

    + {t("boundAgentIdentity", { + defaultValue: "经营身份:{{agent}} · 账号 {{username}}", + agent: boundAgent.name || boundAgent.code, + username: profile?.username ?? "—", + })} +

    + ) : null; + const siteSelector = siteOptions.length > 0 && siteId !== null ? ( @@ -143,6 +169,7 @@ export function SettlementCenterShell(): React.ReactElement { setAdminSiteId(site.id); setSitePickerOpen(false); setSiteKeyword(""); + router.replace(settlementCenterListHref(site.id)); }} >
    @@ -164,6 +191,14 @@ export function SettlementCenterShell(): React.ReactElement { ) : null; + const headerActions = + siteSelector !== null || boundAgentIdentity !== null ? ( +
    + {siteSelector} + {boundAgentIdentity} +
    + ) : null; + const loadPeriods = useCallback(async (): Promise => { if (siteId === null) { return []; @@ -191,22 +226,75 @@ export function SettlementCenterShell(): React.ReactElement { activePeriodId !== null ? (periods.find((row) => row.id === activePeriodId) ?? null) : null; const openPeriodView = (periodId: number, view: SettlementPeriodView): void => { - router.push(settlementPeriodViewHref(periodId, view)); + router.push(settlementPeriodViewHref(periodId, view, siteId)); }; const isListMode = activePeriodId === null; + useEffect(() => { + if (boundAgent !== null || siteId === null) { + return; + } + if (isListMode) { + if (siteFromUrl === siteId) { + return; + } + router.replace(settlementCenterListHref(siteId)); + return; + } + if (activePeriodId !== null && siteFromUrl !== siteId) { + router.replace(settlementPeriodViewHref(activePeriodId, activeView, siteId)); + } + }, [activePeriodId, activeView, boundAgent, isListMode, router, siteFromUrl, siteId]); + + useEffect(() => { + setPeriodLookupDone(false); + }, [activePeriodId, siteId]); + + useEffect(() => { + if (!periodsReady || activePeriodId === null || siteId === null) { + return; + } + + if (activePeriod !== null) { + setPeriodLookupDone(true); + return; + } + + let cancelled = false; + void getSettlementPeriods().then((data) => { + if (cancelled) { + return; + } + + const match = (data.items ?? []).find((row) => row.id === activePeriodId); + if (match?.admin_site_id && match.admin_site_id !== siteId) { + setAdminSiteId(match.admin_site_id); + router.replace(settlementPeriodViewHref(activePeriodId, activeView, match.admin_site_id)); + return; + } + + setPeriodLookupDone(true); + }); + + return () => { + cancelled = true; + }; + }, [activePeriod, activePeriodId, activeView, periodsReady, router, siteId]); + return (
    - {siteId === null || !periodsReady ? ( + {siteId === null ? (

    {t("empty.noSite", { defaultValue: "请选择站点。" })}

    + ) : !periodsReady ? ( + ) : isListMode ? ( openPeriodView(id, "bills")} onReloadPeriods={loadPeriods} onPeriodOpened={() => { @@ -226,9 +314,21 @@ export function SettlementCenterShell(): React.ReactElement { }} /> ) : activePeriod === null ? ( -

    - {t("periodDetail.notFound", { defaultValue: "账期不存在或已切换站点,请返回列表。" })} -

    + !periodLookupDone ? ( + + ) : ( +
    +

    + {t("periodDetail.notFound", { defaultValue: "账期不存在或已切换站点,请返回列表。" })} +

    + + {t("periodDetail.back", { defaultValue: "返回账期列表" })} + +
    + ) ) : ( @@ -243,7 +344,7 @@ export function SettlementCenterShell(): React.ReactElement { !open && setDetailBillId(null)}> {t("actions.billDetail", { defaultValue: "账单详情" })} @@ -254,6 +355,8 @@ export function SettlementCenterShell(): React.ReactElement { billId={detailBillId} currencyCode={currency} canManage={canOperateBills} + boundAgent={boundAgent} + canFinanceAdjustments={canFinanceAdjustments} onUpdated={() => { void loadPeriods(); setRefreshKey((n) => n + 1); diff --git a/src/modules/settlement/settlement-main-panel.tsx b/src/modules/settlement/settlement-main-panel.tsx index 476d9ce..4288f8a 100644 --- a/src/modules/settlement/settlement-main-panel.tsx +++ b/src/modules/settlement/settlement-main-panel.tsx @@ -37,11 +37,11 @@ type BillFilters = { statusScope: BillStatusFilter; }; -function filtersForPeriod(): BillFilters { +function filtersForPeriod(boundAgentId: number | null): BillFilters { return { billId: "", ownerKeyword: "", - billType: "all", + billType: boundAgentId !== null ? "agent" : "all", statusScope: "all", }; } @@ -86,6 +86,7 @@ export type SettlementMainPanelProps = { pendingConfirm: number; awaitingPayment: number; selectedPeriodStatus?: string | null; + boundAgentId?: number | null; }; export function SettlementMainPanel({ @@ -97,12 +98,13 @@ export function SettlementMainPanel({ pendingConfirm, awaitingPayment, selectedPeriodStatus, + boundAgentId = null, }: SettlementMainPanelProps): React.ReactElement { const { t } = useTranslation("settlementCenter"); const periodId = periodFilter === "all" ? undefined : periodFilter; const periodOpen = selectedPeriodStatus === "open"; - const initialFilters = useMemo(() => filtersForPeriod(), []); + const initialFilters = useMemo(() => filtersForPeriod(boundAgentId), [boundAgentId]); const [draft, setDraft] = useState(initialFilters); const [applied, setApplied] = useState(initialFilters); @@ -197,6 +199,9 @@ export function SettlementMainPanel({ }); }, [applied.statusScope, periodOpen, t]); + const billTypeOptions: BillTypeFilter[] = + boundAgentId !== null ? ["agent"] : ["all", "player", "agent"]; + const billTypeLabel = (value: BillTypeFilter): string => { switch (value) { case "player": @@ -291,7 +296,7 @@ export function SettlementMainPanel({ {() => billTypeLabel(draft.billType)} - {(["all", "player", "agent"] as BillTypeFilter[]).map((value) => ( + {billTypeOptions.map((value) => ( {billTypeLabel(value)} diff --git a/src/modules/settlement/settlement-operations-panel.tsx b/src/modules/settlement/settlement-operations-panel.tsx new file mode 100644 index 0000000..30cff2a --- /dev/null +++ b/src/modules/settlement/settlement-operations-panel.tsx @@ -0,0 +1,417 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Eye } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; + +import { + getSettlementAdjustments, + getSettlementPayments, + type SettlementAdjustmentRow, + type SettlementPaymentRow, +} from "@/api/admin-agent-settlement"; +import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer"; +import { AdminTableLoadingRow } from "@/components/admin/admin-loading-state"; +import { AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state"; +import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"; +import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics"; +import { LotteryApiBizError } from "@/types/api/errors"; +import { settlementAdjustmentTypeLabel } from "@/modules/settlement/settlement-status-label"; +import { cn } from "@/lib/utils"; + +const OPERATION_TYPES = ["all", "payment", "adjustment", "reversal", "bad_debt"] as const; +type OperationTypeFilter = (typeof OPERATION_TYPES)[number]; + +type OperationFilters = { + billId: string; + keyword: string; + operationType: OperationTypeFilter; +}; + +type SettlementOperationRow = { + key: string; + kind: Exclude; + recordId: number; + billId: number; + amount: number; + summary: string; + detail: string | null; + sortAt: string; +}; + +function defaultFilters(): OperationFilters { + return { + billId: "", + keyword: "", + operationType: "all", + }; +} + +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); +} + +type SettlementOperationsPanelProps = { + adminSiteId: number; + settlementPeriodId: number; + currencyCode: string; + refreshKey?: number; + onOpenBill: (billId: number) => void; +}; + +export function SettlementOperationsPanel({ + adminSiteId, + settlementPeriodId, + currencyCode, + refreshKey = 0, + onOpenBill, +}: SettlementOperationsPanelProps): React.ReactElement { + const { t } = useTranslation(["settlementCenter", "agents", "common"]); + const formatTs = useAdminDateTimeFormatter(); + const initialFilters = useMemo(() => defaultFilters(), []); + + const [draft, setDraft] = useState(initialFilters); + const [applied, setApplied] = useState(initialFilters); + const [allRows, setAllRows] = useState([]); + const [loading, setLoading] = useState(true); + const [page, setPage] = useState(1); + const [perPage, setPerPage] = useState(20); + + 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 merged = [ + ...(paymentData.items ?? []).map(paymentRow), + ...(adjustmentData.items ?? []).map(adjustmentRow), + ].sort((a, b) => b.sortAt.localeCompare(a.sortAt) || b.key.localeCompare(a.key)); + + setAllRows(merged); + } catch (err: unknown) { + setAllRows([]); + toast.error( + err instanceof LotteryApiBizError + ? err.message + : t("settlementCenter:operations.loadFailed", { defaultValue: "收付与调账记录加载失败" }), + ); + } finally { + setLoading(false); + } + }, [adminSiteId, settlementPeriodId, t]); + + useEffect(() => { + void load(); + }, [load, refreshKey]); + + useEffect(() => { + setDraft(initialFilters); + setApplied(initialFilters); + setPage(1); + }, [initialFilters, settlementPeriodId]); + + const filteredRows = useMemo( + () => allRows.filter((row) => matchesFilters(row, applied)), + [allRows, applied], + ); + + const total = filteredRows.length; + const pageRows = filteredRows.slice((page - 1) * perPage, page * perPage); + + const operationTypeLabel = (value: OperationTypeFilter): string => { + if (value === "all") { + return t("operations.filterAllTypes", { defaultValue: "全部类型" }); + } + if (value === "payment") { + return t("operations.typePayment", { defaultValue: "登记收付" }); + } + return settlementAdjustmentTypeLabel(value, t); + }; + + const kindBadgeClass = (kind: SettlementOperationRow["kind"]): string => { + if (kind === "payment") { + return "border-emerald-200/60 bg-emerald-50 text-emerald-700 dark:border-emerald-800/60 dark:bg-emerald-950/30 dark:text-emerald-400"; + } + if (kind === "bad_debt") { + return "border-rose-200/60 bg-rose-50 text-rose-700 dark:border-rose-800/60 dark:bg-rose-950/30 dark:text-rose-400"; + } + if (kind === "reversal") { + return "border-amber-200/60 bg-amber-50 text-amber-700 dark:border-amber-800/60 dark:bg-amber-950/30 dark:text-amber-400"; + } + return "border-blue-200/60 bg-blue-50 text-blue-700 dark:border-blue-800/60 dark:bg-blue-950/30 dark:text-blue-400"; + }; + + const runSearch = (): void => { + setPage(1); + setApplied({ ...draft }); + }; + + const resetFilters = (): void => { + setDraft(initialFilters); + setApplied(initialFilters); + setPage(1); + }; + + const colSpan = 7; + + return ( +
    +
    +
    +
    + + setDraft((d) => ({ ...d, billId: e.target.value }))} + onKeyDown={(e) => { + if (e.key === "Enter") { + runSearch(); + } + }} + className="bg-background/50 transition-colors focus:bg-background" + /> +
    +
    + + setDraft((d) => ({ ...d, keyword: e.target.value }))} + onKeyDown={(e) => { + if (e.key === "Enter") { + runSearch(); + } + }} + className="bg-background/50 transition-colors focus:bg-background" + /> +
    +
    + + +
    +
    + +
    + + +
    +
    + +
    + + + + {t("columns.time", { defaultValue: "时间" })} + {t("operations.operationType", { defaultValue: "操作类型" })} + {t("columns.billId", { defaultValue: "账单 ID" })} + {t("columns.amount", { defaultValue: "金额" })} + {t("columns.summary", { defaultValue: "摘要" })} + {t("columns.detail", { defaultValue: "说明" })} + + {t("common:table.actions", { defaultValue: "操作" })} + + + + + {loading ? : null} + {!loading && pageRows.length === 0 ? ( + + ) : null} + {!loading + ? pageRows.map((row) => ( + + + {formatTs(row.sortAt)} + + + + {operationTypeLabel(row.kind)} + + + + {row.billId > 0 ? `#${row.billId}` : "—"} + + + {formatDashboardMoneyMinor(row.amount, currencyCode)} + + {row.summary} + + {row.detail ?? "—"} + + e.stopPropagation()} + > + {row.billId > 0 ? ( + onOpenBill(row.billId), + }, + ]} + /> + ) : ( + "—" + )} + + + )) + : null} + +
    +
    + + {!loading && total > 0 ? ( + { + setPerPage(value); + setPage(1); + }} + /> + ) : null} +
    + ); +} diff --git a/src/modules/settlement/settlement-payments-table.tsx b/src/modules/settlement/settlement-payments-table.tsx index 0056567..d46a6c2 100644 --- a/src/modules/settlement/settlement-payments-table.tsx +++ b/src/modules/settlement/settlement-payments-table.tsx @@ -8,7 +8,10 @@ import { AdminLoadingState } from "@/components/admin/admin-loading-state"; import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"; import { formatSettlementPeriodSpan } from "@/lib/agent-settlement-period-range"; import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics"; -import { settlementBillTypeLabel } from "@/modules/settlement/settlement-status-label"; +import { + settlementBillTypeLabel, + settlementPaymentStatusLabel, +} from "@/modules/settlement/settlement-status-label"; import { Table, TableBody, @@ -80,9 +83,7 @@ export function SettlementPaymentsTable({ {row.method ?? "—"} - {t(`paymentStatus.${row.status}`, { - defaultValue: row.status === "confirmed" ? "已确认" : row.status, - })} + {settlementPaymentStatusLabel(row.status, t)} {formatTs(row.confirmed_at ?? row.created_at)} diff --git a/src/modules/settlement/settlement-period-workbench.tsx b/src/modules/settlement/settlement-period-workbench.tsx index 482d0b7..716c07f 100644 --- a/src/modules/settlement/settlement-period-workbench.tsx +++ b/src/modules/settlement/settlement-period-workbench.tsx @@ -6,11 +6,14 @@ import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { + getSettlementPeriodOpenHints, postSettlementPeriod, postSettlementPeriodClose, type SettlementPeriodCloseResult, + type SettlementPeriodOpenHints, type SettlementPeriodRow, } from "@/api/admin-agent-settlement"; +import { AdminDateRangeField } from "@/components/admin/admin-date-range-field"; import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer"; import { AdminPageCard } from "@/components/admin/admin-page-card"; import { SettlementPeriodsTable } from "@/modules/settlement/settlement-periods-table"; @@ -23,7 +26,6 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, @@ -34,14 +36,15 @@ import { } from "@/components/ui/select"; import { formatSettlementPeriodSpan, - settlementPeriodPresetRange, - type SettlementPeriodPresetKey, + isSettlementLocalDateRangeValid, + localDateRangeToUtcPeriodBounds, + settlementRangeOverlapsOccupiedDates, + utcStorageDateToLocalFormYmd, + utcStorageDatesToLocalMarks, } from "@/lib/agent-settlement-period-range"; import { settlementPeriodStatusLabel } from "@/modules/settlement/settlement-status-label"; import { LotteryApiBizError } from "@/types/api/errors"; -const PRESET_KEYS: SettlementPeriodPresetKey[] = ["this_week", "last_week", "this_month"]; - type PeriodStatusFilter = "all" | "open" | "closed" | "completed"; const STATUS_FILTER_OPTIONS: PeriodStatusFilter[] = ["all", "open", "closed", "completed"]; @@ -77,6 +80,8 @@ export function SettlementPeriodWorkbench({ const [openDialogOpen, setOpenDialogOpen] = useState(false); const [customStart, setCustomStart] = useState(""); const [customEnd, setCustomEnd] = useState(""); + const [openHints, setOpenHints] = useState(null); + const [hintsLoading, setHintsLoading] = useState(false); const [busy, setBusy] = useState(false); const [reloading, setReloading] = useState(false); const [closeDialogOpen, setCloseDialogOpen] = useState(false); @@ -112,16 +117,32 @@ export function SettlementPeriodWorkbench({ } }, [page, lastPage]); - const presetLabel = (key: SettlementPeriodPresetKey): string => { - switch (key) { - case "this_week": - return t("agents:settlementPeriods.presetThisWeek", { defaultValue: "本周" }); - case "last_week": - return t("agents:settlementPeriods.presetLastWeek", { defaultValue: "上周" }); - case "this_month": - return t("agents:settlementPeriods.presetThisMonth", { defaultValue: "本月" }); + const calendarMarkers = useMemo(() => { + if (openHints === null) { + return undefined; } - }; + return { + occupiedPeriod: utcStorageDatesToLocalMarks(openHints.occupied_period_dates), + pendingActivity: utcStorageDatesToLocalMarks(openHints.pending_activity_dates), + unpaidBill: utcStorageDatesToLocalMarks(openHints.unpaid_bill_dates), + }; + }, [openHints]); + + const occupiedLocalDates = useMemo( + () => utcStorageDatesToLocalMarks(openHints?.occupied_period_dates ?? []), + [openHints], + ); + + const selectedRangeOverlapsOccupied = useMemo(() => { + if (!customStart.trim() || !customEnd.trim()) { + return false; + } + return settlementRangeOverlapsOccupiedDates( + customStart.trim(), + customEnd.trim(), + occupiedLocalDates, + ); + }, [customEnd, customStart, occupiedLocalDates]); const statusFilterLabel = (value: PeriodStatusFilter): string => { if (value === "all") { @@ -130,45 +151,97 @@ export function SettlementPeriodWorkbench({ return settlementPeriodStatusLabel(value, t); }; - async function openWithRange(periodStart: string, periodEnd: string): Promise { + async function openWithRange(startYmd: string, endYmd: string): Promise { if (!canManage) { return; } + if (!isSettlementLocalDateRangeValid(startYmd, endYmd)) { + toast.error( + t("agents:settlementPeriods.invalidRange", { + defaultValue: "结束日期不能早于开始日期", + }), + ); + return; + } + if (settlementRangeOverlapsOccupiedDates(startYmd, endYmd, occupiedLocalDates)) { + toast.error( + t("agents:settlementPeriods.overlapsOccupied", { + defaultValue: "所选范围与已有账期重叠,请避开灰色删除线的日期。", + }), + ); + return; + } + const bounds = localDateRangeToUtcPeriodBounds(startYmd, endYmd); setBusy(true); try { const row = await postSettlementPeriod({ admin_site_id: adminSiteId, - period_start: periodStart, - period_end: periodEnd, + period_start: bounds.period_start, + period_end: bounds.period_end, }); await onReloadPeriods(); onPeriodOpened?.(row.id); setOpenDialogOpen(false); setCustomStart(""); setCustomEnd(""); + setOpenHints(null); toast.success(t("agents:settlementPeriods.opened", { defaultValue: "账期已开启" })); } catch (err: unknown) { toast.error( err instanceof LotteryApiBizError ? err.message - : t("agents:settlementPeriods.openFailed", { defaultValue: "开期失败" }), + : t("agents:settlementPeriods.openFailed", { defaultValue: "开账失败" }), ); } finally { setBusy(false); } } - async function openWithPreset(key: SettlementPeriodPresetKey): Promise { - const range = settlementPeriodPresetRange(key); - await openWithRange(range.period_start, range.period_end); - } - async function openCustom(): Promise { if (!customStart.trim() || !customEnd.trim()) { toast.error(t("agents:settlementPeriods.datesRequired", { defaultValue: "请填写账期起止" })); return; } - await openWithRange(customStart, customEnd); + await openWithRange(customStart.trim(), customEnd.trim()); + } + + async function loadOpenHints(): Promise { + setHintsLoading(true); + try { + const hints = await getSettlementPeriodOpenHints({ admin_site_id: adminSiteId }); + setOpenHints(hints); + setCustomStart(""); + setCustomEnd(""); + if (hints.suggested_start && hints.suggested_end) { + const from = utcStorageDateToLocalFormYmd(hints.suggested_start); + const to = utcStorageDateToLocalFormYmd(hints.suggested_end); + const occupied = utcStorageDatesToLocalMarks(hints.occupied_period_dates); + if (!settlementRangeOverlapsOccupiedDates(from, to, occupied)) { + setCustomStart(from); + setCustomEnd(to); + } + } + } catch (err: unknown) { + setOpenHints(null); + toast.error( + err instanceof LotteryApiBizError + ? err.message + : t("agents:settlementPeriods.hintsFailed", { defaultValue: "无法加载开账建议" }), + ); + } finally { + setHintsLoading(false); + } + } + + function handleOpenDialog(open: boolean): void { + setOpenDialogOpen(open); + if (open) { + void loadOpenHints(); + return; + } + setCustomStart(""); + setCustomEnd(""); + setOpenHints(null); } function requestClose(row: SettlementPeriodRow): void { @@ -299,7 +372,7 @@ export function SettlementPeriodWorkbench({ type="button" size="sm" disabled={busy} - onClick={() => setOpenDialogOpen(true)} + onClick={() => handleOpenDialog(true)} > {t("period.openBtn", { defaultValue: "开账" })} @@ -370,76 +443,81 @@ export function SettlementPeriodWorkbench({ /> - { - setOpenDialogOpen(open); - if (!open) { - setCustomStart(""); - setCustomEnd(""); - } - }} - > + {t("period.openTitle", { defaultValue: "开账" })} - {t("agents:settlementPeriods.openHint", { - defaultValue: "选择快捷账期或自定义起止时间。", + {t("agents:settlementPeriods.openDesc", { + defaultValue: + "选择账期起止日期;灰色删除线为已有账期不可再开,琥珀点为待入账,右上角红点为未结清。", })} -
    -
    - {PRESET_KEYS.map((key) => ( - - ))} -
    -
    -
    - - setCustomStart(e.target.value)} - /> -
    -
    - - setCustomEnd(e.target.value)} - /> -
    + + + {t("agents:settlementPeriods.markerUnpaid", { defaultValue: "未结清账期" })} +
    + {selectedRangeOverlapsOccupied ? ( +

    + {t("agents:settlementPeriods.overlapsOccupied", { + defaultValue: "所选范围与已有账期重叠,请避开灰色删除线的日期。", + })} +

    + ) : null}
    -
    diff --git a/src/modules/settlement/settlement-signed-money.ts b/src/modules/settlement/settlement-signed-money.ts index 4fa6a47..d965a4c 100644 --- a/src/modules/settlement/settlement-signed-money.ts +++ b/src/modules/settlement/settlement-signed-money.ts @@ -1,3 +1,4 @@ +import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics"; import { cn } from "@/lib/utils"; /** 结算金额正负着色:负红、正绿、零灰 */ @@ -11,3 +12,12 @@ export function signedSettlementMoneyClass(amount: number, emphasize = false): s return "text-muted-foreground"; } + +export function formatSignedSettlementMoney(amount: number, currencyCode: string): string { + if (amount === 0) { + return formatDashboardMoneyMinor(0, currencyCode); + } + + const prefix = amount < 0 ? "−" : "+"; + return `${prefix}${formatDashboardMoneyMinor(Math.abs(amount), currencyCode)}`; +} diff --git a/src/modules/settlement/settlement-status-label.ts b/src/modules/settlement/settlement-status-label.ts index d6888a2..6db9946 100644 --- a/src/modules/settlement/settlement-status-label.ts +++ b/src/modules/settlement/settlement-status-label.ts @@ -74,3 +74,11 @@ export function settlementAdjustmentTypeLabel( const key = `adjustmentType.${type}` as const; return t(key, { defaultValue: type }); } + +export function settlementPaymentStatusLabel( + status: string, + t: TFunction<"settlementCenter">, +): string { + const key = `paymentStatus.${status}` as const; + return t(key, { defaultValue: status }); +} diff --git a/src/modules/wallet/wallet-console.tsx b/src/modules/wallet/wallet-console.tsx index 28a66a1..499197d 100644 --- a/src/modules/wallet/wallet-console.tsx +++ b/src/modules/wallet/wallet-console.tsx @@ -110,6 +110,38 @@ function CellMonoId({ ); } +function walletTxnBizTypeLabel( + bizType: string, + ledgerSource: string | null | undefined, + t: (key: string) => string, + tSettlement: (key: string, opts?: { defaultValue?: string }) => string, +): string { + if (ledgerSource === "credit_ledger") { + return creditLedgerReasonLabel(bizType, tSettlement); + } + + switch (bizType) { + case "transfer_in": + return t("transferIn"); + case "transfer_out": + return t("transferOut"); + case "transfer_out_refund": + return t("transferOutRefund"); + case "bet_deduct": + return t("bizBetDeduct"); + case "bet_reverse": + return t("bizBetReverse"); + case "settle_payout": + return t("bizSettlePayout"); + case "jackpot_manual_payout": + return t("bizJackpotPayout"); + case "settlement_adjustment": + return t("bizSettlementAdjustment"); + default: + return bizType; + } +} + function statusLabelT(status: string, t: (key: string) => string): string { switch (status) { case "processing": @@ -551,8 +583,8 @@ export function TransferOrdersPanel(): React.ReactElement { {err ?

    {err}

    : null} {(loading && !data) || data ? ( <> -
    - +
    +
    {t("localTransferNo")} @@ -713,6 +745,10 @@ export function WalletTxnsPanel(): React.ReactElement { setPage(1); }; + const showLedgerColumn = + data?.items.some((row) => row.ledger_source === "credit_ledger") ?? false; + const txnTableColSpan = showLedgerColumn ? 12 : 11; + return ( @@ -863,56 +899,66 @@ export function WalletTxnsPanel(): React.ReactElement { {err ?

    {err}

    : null} {(loading && !data) || data ? ( <> -
    -
    +
    +
    - {t("txnNo")} - {t("externalRefNo")} + {t("txnNo")} + {t("externalRefNo")} - {t("ledgerChannel", { defaultValue: "账本" })} - {t("type")} - {t("amount")} + {showLedgerColumn ? ( + {t("ledgerChannel", { defaultValue: "账本" })} + ) : null} + {t("type")} + {t("amount")} {t("status")} - {t("requestTime")} - {t("finishedTime")} + {t("requestTime")} + {t("finishedTime")} {loading && !data ? ( - + ) : !data || data.items.length === 0 ? ( - + ) : ( data.items.map((row) => ( - + - + - - + {showLedgerColumn ? ( + + + + ) : null} + + + {walletTxnBizTypeLabel(row.biz_type, row.ledger_source, t, tSettlement)} + - - {row.ledger_source === "credit_ledger" - ? creditLedgerReasonLabel(row.biz_type, tSettlement) - : row.biz_type} + + {row.amount_formatted ?? formatAdminMinorUnits(row.amount)} + + ({row.direction === 1 ? t("in") : t("out")}) + - - {row.amount} ({row.direction === 1 ? t("in") : t("out")}) - - + {statusLabelT(row.status, t)} - + {formatTs(row.created_at)} - + {formatTs(row.updated_at)} diff --git a/src/types/api/admin-agent.ts b/src/types/api/admin-agent.ts index 268ace6..43798a0 100644 --- a/src/types/api/admin-agent.ts +++ b/src/types/api/admin-agent.ts @@ -3,6 +3,8 @@ import type { AdminRoleRow, AdminUserPermissionRow } from "@/types/api/admin-use export type AdminAgentContext = { id: number; admin_site_id: number; + /** 主站名称(admin_sites.name),展示用 */ + admin_site_name?: string; /** 主站编号(admin_sites.code),创建玩家时预填 */ site_code: string; path: string; diff --git a/src/types/api/admin-dashboard-analytics.ts b/src/types/api/admin-dashboard-analytics.ts index a5df3e0..e2e4cb4 100644 --- a/src/types/api/admin-dashboard-analytics.ts +++ b/src/types/api/admin-dashboard-analytics.ts @@ -49,6 +49,8 @@ export type AdminDashboardAnalyticsData = { play_code: string | null; date_from: string; date_to: string; + /** 绑定代理时为 share_profit(本级占成),平台账号为 house_gross */ + profit_scope?: "share_profit" | "house_gross"; currency_code: string | null; summary: AdminDashboardAnalyticsSummary; daily_series: AdminReportDailyProfitRow[]; diff --git a/src/types/api/admin-dashboard.ts b/src/types/api/admin-dashboard.ts index 457cc17..2acba98 100644 --- a/src/types/api/admin-dashboard.ts +++ b/src/types/api/admin-dashboard.ts @@ -74,6 +74,8 @@ export type AdminDashboardAgentOverview = { seven_day_bet_minor: number; seven_day_payout_minor: number; seven_day_profit_minor: number; + /** 代理视角盈亏口径:本级占成 */ + profit_scope?: "share_profit"; currency_code: string | null; pending_bill_count: number; pending_unpaid_minor: number; diff --git a/src/types/api/admin-wallet.ts b/src/types/api/admin-wallet.ts index 74f834a..1e66ef9 100644 --- a/src/types/api/admin-wallet.ts +++ b/src/types/api/admin-wallet.ts @@ -53,6 +53,7 @@ export type AdminWalletTxnItem = { biz_no: string; direction: number; amount: number; + amount_formatted?: string; balance_before: number; balance_after: number; status: string;