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;