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 as typeof status)}>
+ 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)}
+
+ ))}
-
- setIncludeRoots(checked === true)}
- />
- {t("includeRoots", { defaultValue: "包含根节点" })}
-
@@ -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({
{t("agents:settlementBills.paymentAmount", { defaultValue: "收付金额" })}
- setPayAmount(e.target.value)} />
+ setPayAmount(e.target.value)}
+ inputMode="decimal"
+ placeholder={formatAdminMinorDecimal(
+ selectedBill.unpaid_amount ?? 0,
+ billingCurrency,
+ )}
+ />
{t("agents:settlementBills.paymentMethod", { defaultValue: "收付方式" })}
@@ -1116,6 +1132,8 @@ export function AgentsPlayersPanel({
{t("agents:settlementBills.paid", { defaultValue: "登记收付" })}
+ {boundAgent === null ? (
+ <>
{t("agents:settlementBills.badDebtReason", { defaultValue: "核销原因" })}
{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: "确认后才可以登记收款或付款。",
- })}
-
-
-
+ {canOperateBill && bill.status === "pending_confirm" ? (
+
+
+
{t("settlementBills.confirm", { defaultValue: "确认账单" })}
-
+
+
+ {t("settlementCenter:billDisplay.confirmHint", {
+ defaultValue: "确认后才可以登记收款或付款。",
+ })}
+
- ) : null}
+
+ {t("settlementBills.confirm", { defaultValue: "确认账单" })}
+
+
+ ) : 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 ? (
) : null}
- {canWriteOff ? (
+ {canWriteOff ? (
@@ -471,7 +473,7 @@ export function AgentBillDetail({
) : null}
- {canManage && locked ? (
+ {canFinanceAdjustments && locked ? (
>
);
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 (
+
+
+
+
+
+ {t("columns.billId", { defaultValue: "账单 ID" })}
+
+ setDraft((d) => ({ ...d, billId: e.target.value }))}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ runSearch();
+ }
+ }}
+ className="bg-background/50 transition-colors focus:bg-background"
+ />
+
+
+
+ {t("operations.keyword", { defaultValue: "关键词" })}
+
+ setDraft((d) => ({ ...d, keyword: e.target.value }))}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ runSearch();
+ }
+ }}
+ className="bg-background/50 transition-colors focus:bg-background"
+ />
+
+
+
+ {t("operations.operationType", { defaultValue: "操作类型" })}
+
+
+ setDraft((d) => ({
+ ...d,
+ operationType: (v ?? "all") as OperationTypeFilter,
+ }))
+ }
+ >
+
+ {() => operationTypeLabel(draft.operationType)}
+
+
+ {OPERATION_TYPES.map((value) => (
+
+ {operationTypeLabel(value)}
+
+ ))}
+
+
+
+
+
+
+
+ {t("billsPanel.searchBtn", { defaultValue: "搜索" })}
+
+
+ {t("billsPanel.reset", { defaultValue: "重置" })}
+
+
+
+
+
+
+
+
+ {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) => (
-
void openWithPreset(key)}
+
+
{
+ setCustomStart(from);
+ setCustomEnd(to);
+ }}
+ />
+
+
+
+ {t("agents:settlementPeriods.markerOccupied", { defaultValue: "已有账期" })}
+
+
+
+ {t("agents:settlementPeriods.markerPending", { defaultValue: "待入账流水" })}
+
+
+
- {presetLabel(key)}
-
- ))}
-
-
-
-
- {t("agents:settlementPeriods.start", { defaultValue: "开始" })}
-
- setCustomStart(e.target.value)}
- />
-
-
-
- {t("agents:settlementPeriods.end", { defaultValue: "结束" })}
-
- setCustomEnd(e.target.value)}
- />
-
+
+
+ {t("agents:settlementPeriods.markerUnpaid", { defaultValue: "未结清账期" })}
+
+ {selectedRangeOverlapsOccupied ? (
+
+ {t("agents:settlementPeriods.overlapsOccupied", {
+ defaultValue: "所选范围与已有账期重叠,请避开灰色删除线的日期。",
+ })}
+
+ ) : null}
setOpenDialogOpen(false)}
+ disabled={busy || hintsLoading}
+ onClick={() => handleOpenDialog(false)}
>
{t("common:cancel", { defaultValue: "取消" })}
- void openCustom()}>
- {t("agents:settlementPeriods.open", { defaultValue: "开期" })}
+ void openCustom()}
+ >
+ {t("period.openBtn", { defaultValue: "开账" })}
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;