feat(settlement, admin): introduce new types and functions for downline share and settlement period hints

Added new types for downline share breakdown and settlement period open hints to enhance the agent settlement API. Updated the admin console components to support these new features, improving the user experience with better data presentation and interaction. Additionally, refined the date range field to accommodate new calendar markers and hints, ensuring a more intuitive interface for managing settlement periods.
This commit is contained in:
2026-06-12 16:01:42 +08:00
parent 1eb6702c51
commit 24fd7c10bd
50 changed files with 1821 additions and 618 deletions

View File

@@ -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<SettlementPeriodOpenHints> {
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}`);
}

View File

@@ -31,19 +31,31 @@ type CellProps = { row: AdminAgentFields; className?: string };
export function AdminAgentHead({ className }: HeadProps): React.ReactElement {
const { t } = useTranslation("common");
return (
<TableHead className={cn("whitespace-nowrap", className)}>
<TableHead className={cn("min-w-[7.5rem] whitespace-nowrap", className)}>
{t("agentColumns.agent")}
</TableHead>
);
}
export function AdminAgentCell({ row, className }: CellProps): React.ReactElement {
const name = cellText(row.agent_name);
const code = row.agent_code?.trim() ?? "";
return (
<TableCell className={cn("text-xs", className)}>
<span className="font-medium">{cellText(row.agent_name)}</span>
{row.agent_code ? (
<span className="mt-0.5 block font-mono text-[11px] text-muted-foreground">{row.agent_code}</span>
) : null}
<TableCell className={cn("min-w-[7.5rem] max-w-[10rem] align-top text-xs", className)}>
<div className="min-w-0 space-y-0.5">
<span className="block truncate font-medium" title={name !== "—" ? name : undefined}>
{name}
</span>
{code !== "" ? (
<span
className="block truncate font-mono text-[11px] text-muted-foreground"
title={code}
>
{code}
</span>
) : null}
</div>
</TableCell>
);
}

View File

@@ -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 (
<div className="grid gap-1.5">
{label ? (
@@ -94,6 +131,7 @@ export function AdminDateRangeField({
<PopoverTrigger
type="button"
id={id}
disabled={disabled}
className={cn(
buttonVariants({ variant: "outline", size: "default" }),
"h-8 min-h-8 w-full justify-start gap-2 px-2.5 font-normal md:text-sm",
@@ -106,13 +144,17 @@ export function AdminDateRangeField({
</span>
</PopoverTrigger>
<PopoverContent align="start" sideOffset={6} className="w-auto max-w-[calc(100vw-2rem)] min-w-fit p-0">
<p className="text-muted-foreground border-b px-3 py-2 text-xs leading-relaxed">
{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.",
})}
</p>
{rangeHint ? (
<p className="text-muted-foreground border-b px-3 py-2 text-xs leading-relaxed">{rangeHint}</p>
) : (
<p className="text-muted-foreground border-b px-3 py-2 text-xs leading-relaxed">
{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.",
})}
</p>
)}
<Calendar
mode="range"
locale={enUS}
@@ -120,6 +162,20 @@ export function AdminDateRangeField({
selected={selected}
defaultMonth={defaultMonth}
numberOfMonths={isMobile ? 1 : 2}
disabled={occupiedPeriodDates}
modifiers={{
occupiedPeriod: occupiedPeriodDates,
pendingActivity: pendingActivityDates,
unpaidBill: unpaidBillDates,
}}
modifiersClassNames={{
occupiedPeriod:
"[&>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: "" });

View File

@@ -36,7 +36,7 @@ type CellProps = { row: AdminPlayerIdentityFields; className?: string };
export function AdminPlayerSiteHead({ className }: HeadProps): React.ReactElement {
const { t } = useTranslation("common");
return (
<TableHead className={cn("whitespace-nowrap", className)}>
<TableHead className={cn("min-w-[5.5rem] whitespace-nowrap", className)}>
{t("playerColumns.site")}
</TableHead>
);
@@ -45,7 +45,7 @@ export function AdminPlayerSiteHead({ className }: HeadProps): React.ReactElemen
export function AdminPlayerDisplayHead({ className }: HeadProps): React.ReactElement {
const { t } = useTranslation("common");
return (
<TableHead className={cn("whitespace-nowrap", className)}>
<TableHead className={cn("min-w-[4.5rem] whitespace-nowrap", className)}>
{t("playerColumns.display")}
</TableHead>
);
@@ -54,7 +54,7 @@ export function AdminPlayerDisplayHead({ className }: HeadProps): React.ReactEle
export function AdminPlayerSiteIdHead({ className }: HeadProps): React.ReactElement {
const { t } = useTranslation("common");
return (
<TableHead className={cn("whitespace-nowrap", className)}>
<TableHead className={cn("min-w-[6.5rem] whitespace-nowrap", className)}>
{t("playerColumns.sitePlayerId")}
</TableHead>
);
@@ -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 (
<TableCell className={cn("text-xs", className)}>
<span className="font-mono text-xs">{cellText(row.site_code)}</span>
<TableCell className={cn("min-w-[5.5rem] max-w-[8rem] align-top text-xs", className)}>
<span className="block truncate font-mono text-xs" title={site !== "—" ? site : undefined}>
{site}
</span>
</TableCell>
);
}
export function AdminPlayerDisplayCell({ row, className }: CellProps): React.ReactElement {
const label = adminPlayerDisplayName(row);
return (
<TableCell className={cn("text-xs", className)}>
{adminPlayerDisplayName(row)}
<TableCell className={cn("min-w-[4.5rem] max-w-[8rem] align-top text-xs", className)}>
<span className="block truncate" title={label !== "—" ? label : undefined}>
{label}
</span>
</TableCell>
);
}
export function AdminPlayerSiteIdCell({ row, className }: CellProps): React.ReactElement {
const sitePlayerId = cellText(row.site_player_id);
return (
<TableCell className={cn("text-xs", className)}>
<span className="font-mono text-xs">{cellText(row.site_player_id)}</span>
<TableCell className={cn("min-w-[6.5rem] max-w-[9rem] align-top text-xs", className)}>
<span
className="block truncate font-mono text-xs"
title={sitePlayerId !== "—" ? sitePlayerId : undefined}
>
{sitePlayerId}
</span>
</TableCell>
);
}

View File

@@ -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"
},

View File

@@ -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",

View File

@@ -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…",

View File

@@ -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.",

View File

@@ -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",

View File

@@ -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": "सेभ हुँदैछ…",

View File

@@ -18,6 +18,11 @@
"transferIn": "मुख्य साइटबाट भित्र",
"transferOut": "मुख्य साइटतर्फ बाहिर",
"transferOutRefund": "ट्रान्सफर-आउट फिर्ता",
"bizBetDeduct": "बेट कटौती",
"bizBetReverse": "बेट उल्टाउने",
"bizSettlePayout": "बन्दोबस्त भुक्तानी",
"bizJackpotPayout": "ज्याकपट भुक्तानी",
"bizSettlementAdjustment": "बन्दोबस्त समायोजन",
"transferOrders": "ट्रान्सफर अर्डर",
"walletTransactions": "वालेट कारोबार",
"playerWalletQuery": "खेलाडी वालेट खोज",

View File

@@ -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": "创建一级代理",

View File

@@ -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": "今天活跃人数",

View File

@@ -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": "保存中…",

View File

@@ -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": "请先关账当前账期。",

View File

@@ -24,6 +24,11 @@
"transferIn": "主站转入",
"transferOut": "主站转出",
"transferOutRefund": "转出失败回补",
"bizBetDeduct": "下注扣款",
"bizBetReverse": "下注冲正",
"bizSettlePayout": "派彩入账",
"bizJackpotPayout": "奖池派彩",
"bizSettlementAdjustment": "结算调账",
"transferOrders": "转账单",
"walletTransactions": "钱包流水",
"playerWalletQuery": "玩家钱包查询",

View File

@@ -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,

View File

@@ -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"] },

View File

@@ -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));
}

View File

@@ -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;
}

View File

@@ -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<string | null>(null);
const [keyword, setKeyword] = useState("");
const [status, setStatus] = useState<"all" | "enabled" | "disabled">("all");
const [includeRoots, setIncludeRoots] = useState(false);
const [status, setStatus] = useState<DirectoryStatusFilter>("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 {
/>
</div>
<div className="flex flex-wrap items-center gap-3">
<Select value={status} onValueChange={(value) => setStatus(value as typeof status)}>
<Select
value={status}
onValueChange={(value) => setStatus((value ?? "all") as DirectoryStatusFilter)}
>
<SelectTrigger className="h-9 w-[150px]">
<SelectValue />
<SelectValue>{() => directoryStatusLabel(status, t)}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{t("directoryStatus.all", { defaultValue: "全部状态" })}
</SelectItem>
<SelectItem value="enabled">
{t("directoryStatus.enabled", { defaultValue: "仅启用" })}
</SelectItem>
<SelectItem value="disabled">
{t("directoryStatus.disabled", { defaultValue: "仅停用" })}
</SelectItem>
{(["all", "enabled", "disabled"] as DirectoryStatusFilter[]).map((value) => (
<SelectItem key={value} value={value}>
{directoryStatusLabel(value, t)}
</SelectItem>
))}
</SelectContent>
</Select>
<Label className="flex h-9 items-center gap-2 rounded-md border border-border/70 px-3 text-sm font-normal">
<Checkbox
checked={includeRoots}
onCheckedChange={(checked) => setIncludeRoots(checked === true)}
/>
{t("includeRoots", { defaultValue: "包含根节点" })}
</Label>
</div>
</div>
@@ -229,8 +230,8 @@ export function AgentsDirectoryConsole(): React.ReactElement {
<TableHead className="w-[130px] text-right">
{t("lineUi.availableCredit", { defaultValue: "可下发" })}
</TableHead>
<TableHead className="w-[110px] text-right">
{t("common:actions.title", { defaultValue: "操作" })}
<TableHead className="sticky right-0 z-20 w-14 bg-muted text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
{t("common:table.actions", { defaultValue: "操作" })}
</TableHead>
</TableRow>
</TableHeader>
@@ -287,13 +288,17 @@ export function AgentsDirectoryConsole(): React.ReactElement {
<TableCell className="text-right">
<span className="tabular-nums">{formatCredit(profile?.available_credit)}</span>
</TableCell>
<TableCell className="text-right">
<Link
href={`/admin/agents?agent_node_id=${item.id}`}
className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}
>
{t("common:actions.view", { defaultValue: "查看" })}
</Link>
<TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
<AdminRowActionsMenu
actions={[
{
key: "view",
label: t("common:actions.viewDetails", { defaultValue: "查看详情" }),
icon: Eye,
href: `/admin/agents?agent_node_id=${item.id}`,
},
]}
/>
</TableCell>
</TableRow>
);

View File

@@ -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<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 || 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({
<SelectContent>
{billingBills.map((bill) => (
<SelectItem key={bill.id} value={String(bill.id)}>
{`#${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")}`}
</SelectItem>
))}
</SelectContent>
@@ -1076,7 +1084,7 @@ export function AgentsPlayersPanel({
<span className="text-muted-foreground">
{t("playersPanel.billUnpaid", { defaultValue: "未结" })}:
</span>{" "}
{selectedBill.unpaid_amount ?? 0}
{formatAdminMinorUnits(selectedBill.unpaid_amount ?? 0, billingCurrency)}
</div>
</div>
@@ -1090,7 +1098,15 @@ export function AgentsPlayersPanel({
<div className="space-y-3">
<div className="space-y-1">
<Label>{t("agents:settlementBills.paymentAmount", { defaultValue: "收付金额" })}</Label>
<Input value={payAmount} onChange={(e) => setPayAmount(e.target.value)} />
<Input
value={payAmount}
onChange={(e) => setPayAmount(e.target.value)}
inputMode="decimal"
placeholder={formatAdminMinorDecimal(
selectedBill.unpaid_amount ?? 0,
billingCurrency,
)}
/>
</div>
<div className="space-y-1">
<Label>{t("agents:settlementBills.paymentMethod", { defaultValue: "收付方式" })}</Label>
@@ -1116,6 +1132,8 @@ export function AgentsPlayersPanel({
{t("agents:settlementBills.paid", { defaultValue: "登记收付" })}
</Button>
{boundAgent === null ? (
<>
<div className="space-y-1 pt-2">
<Label>{t("agents:settlementBills.badDebtReason", { defaultValue: "核销原因" })}</Label>
<Input
@@ -1129,6 +1147,8 @@ export function AgentsPlayersPanel({
<Button type="button" variant="destructive" className="w-full" disabled={billingBusy || confirmBusy} onClick={requestWriteOffBillAction}>
{t("agents:settlementBills.confirmBadDebt", { defaultValue: "确认核销" })}
</Button>
</>
) : null}
</div>
) : null}
</div>

View File

@@ -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 {
</p>
</div>
<div className="rounded-2xl border border-white/10 bg-white/8 p-4 backdrop-blur">
<p className="text-xs text-slate-300">{t("agent.todayProfit")}</p>
<p className="text-xs text-slate-300">{t("agent.todayShareProfit")}</p>
<p className="mt-2 text-2xl font-semibold tabular-nums">
{formatDashboardMoneyMinor(overview.today_profit_minor, displayCurrency)}
{formatDashboardSignedMoneyMinor(overview.today_profit_minor, displayCurrency)}
</p>
</div>
</div>
@@ -316,8 +317,8 @@ export function AgentDashboardConsole(): ReactElement {
})}
</p>
<p className="text-xs text-muted-foreground">
{t("agent.sevenDayProfit", {
amount: formatDashboardMoneyMinor(overview.seven_day_profit_minor, displayCurrency),
{t("agent.sevenDayShareProfit", {
amount: formatDashboardSignedMoneyMinor(overview.seven_day_profit_minor, displayCurrency),
})}
</p>
</CardContent>
@@ -384,9 +385,9 @@ export function AgentDashboardConsole(): ReactElement {
{formatDashboardMoneyMinor(overview.top_agent_today.total_bet_minor, displayCurrency)}
</p>
<p className="text-xs text-muted-foreground">
{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,
),
})}

View File

@@ -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
}
/>
<DashboardKpiCard
label={t("analytics.summaryProfit")}
label={
profitScope === "share_profit"
? t("analytics.summaryShareProfit")
: t("analytics.summaryProfit")
}
value={formatSignedMoney(summary.approx_house_gross_minor, currency)}
hint={
summary.total_bet_minor > 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={<TrendingUp className="size-4" aria-hidden />}
sparklineValues={sparklines.profit}

View File

@@ -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 (

View File

@@ -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,

View File

@@ -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({
<DialogHeader>
<DialogTitle>{t("createDraw.title")}</DialogTitle>
<DialogDescription>
{t("createDraw.description", { tz: scheduleTimezone ?? LOTTERY_SCHEDULE_TIMEZONE })}
{t("createDraw.description", { tz: getAdminBrowserTimeZoneLabel() })}
</DialogDescription>
</DialogHeader>

View File

@@ -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 (
<ol className="grid gap-3 sm:grid-cols-3">

View File

@@ -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<void>;
};
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<void> {
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({
<DialogTitle>{t("editDraw.title")}</DialogTitle>
<DialogDescription>
{t("editDraw.description", {
tz: scheduleTimezone ?? LOTTERY_SCHEDULE_TIMEZONE,
tz: getAdminBrowserTimeZoneLabel(),
drawNo: draw?.draw_no ?? "",
})}
</DialogDescription>

View File

@@ -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<AdminDrawListData | null>(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<string | null>(null);
const [draftDrawNo, setDraftDrawNo] = useState("");

View File

@@ -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<SettlementBillRow | null>(null);
const [payments, setPayments] = useState<SettlementBillPaymentRow[]>([]);
const [rebateAllocations, setRebateAllocations] = useState<RebateAllocationRow[]>([]);
const [downlineShares, setDownlineShares] = useState<DownlineShareBreakdown | null>(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 (
<>
<ConfirmDialog />
<div className="grid gap-6 md:grid-cols-[minmax(0,1.35fr)_minmax(340px,0.95fr)]">
<div className="space-y-5 text-sm">
<SettlementBillSummaryHeader bill={bill} currencyCode={currencyCode} />
<SettlementBillPartiesRow bill={bill} />
<SettlementBillAmountBreakdown bill={bill} currencyCode={currencyCode} />
<div className="flex flex-col gap-5 text-sm">
<SettlementBillSummaryHeader bill={bill} currencyCode={currencyCode} />
<SettlementBillAmountBreakdown
bill={bill}
currencyCode={currencyCode}
downlineShares={downlineShares}
/>
{payments.length > 0 ? (
<div className="space-y-2 rounded-xl border border-border/70 p-4">
<p className="font-medium">
{t("settlementBills.paymentsHistory", { defaultValue: "收付记录" })}
</p>
<ul className="space-y-1.5 text-muted-foreground">
{payments.map((p) => (
<li key={p.id} className="flex justify-between gap-2">
<span>
{p.method
? `${p.method}`
: t("settlementCenter:billDisplay.payment", { defaultValue: "收付" })}
{p.remark ? ` · ${p.remark}` : ""}
</span>
<span className="shrink-0 tabular-nums">
{formatDashboardMoneyMinor(p.amount, currencyCode)}
</span>
</li>
))}
</ul>
</div>
) : null}
</div>
{payments.length > 0 ? (
<div className="space-y-2 rounded-xl border border-border/70 p-4">
<p className="font-medium">
{t("settlementBills.paymentsHistory", { defaultValue: "收付记录" })}
</p>
<ul className="space-y-1.5 text-muted-foreground">
{payments.map((p) => (
<li key={p.id} className="flex justify-between gap-2">
<span>
{p.method
? `${p.method}`
: t("settlementCenter:billDisplay.payment", { defaultValue: "收付" })}
{p.remark ? ` · ${p.remark}` : ""}
</span>
<span className="shrink-0 tabular-nums">
{formatDashboardMoneyMinor(p.amount, currencyCode)}
</span>
</li>
))}
</ul>
</div>
) : null}
<div className="space-y-5 text-sm">
{rebateAllocations.length > 0 ? (
{rebateAllocations.length > 0 ? (
<div className="space-y-2 rounded-xl border border-border/70 bg-card p-5 shadow-sm">
<p className="font-semibold tracking-tight">
{t("settlementBills.rebateAllocations", { defaultValue: "回水分摊" })}
@@ -353,30 +354,30 @@ export function AgentBillDetail({
</div>
) : null}
{canManage && bill.status === "pending_confirm" ? (
<div className="space-y-4 rounded-xl border border-border/70 bg-primary/5 p-5 shadow-sm">
<div className="space-y-1">
<p className="font-semibold tracking-tight text-primary">
{t("settlementBills.confirm", { defaultValue: "确认账单" })}
</p>
<p className="text-xs text-muted-foreground/80">
{t("settlementCenter:billDisplay.confirmHint", {
defaultValue: "确认后才可以登记收款或付款。",
})}
</p>
</div>
<Button
type="button"
className="w-full"
disabled={confirmBusy}
onClick={requestConfirmBill}
>
{canOperateBill && bill.status === "pending_confirm" ? (
<div className="space-y-3 rounded-xl border border-primary/20 bg-primary/5 p-5 shadow-sm">
<div className="space-y-1">
<p className="font-semibold tracking-tight text-primary">
{t("settlementBills.confirm", { defaultValue: "确认账单" })}
</Button>
</p>
<p className="text-xs text-muted-foreground/80">
{t("settlementCenter:billDisplay.confirmHint", {
defaultValue: "确认后才可以登记收款或付款。",
})}
</p>
</div>
) : null}
<Button
type="button"
className="w-full sm:w-auto sm:min-w-[10rem]"
disabled={confirmBusy}
onClick={requestConfirmBill}
>
{t("settlementBills.confirm", { defaultValue: "确认账单" })}
</Button>
</div>
) : 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 ? (
<div className="space-y-4 rounded-xl border border-border/70 bg-card p-5 shadow-sm">
<div className="space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
@@ -398,7 +399,8 @@ export function AgentBillDetail({
<Input
value={payAmount}
onChange={(e) => setPayAmount(e.target.value)}
placeholder={String(bill.unpaid_amount)}
inputMode="decimal"
placeholder={formatAdminMinorDecimal(bill.unpaid_amount, currencyCode)}
className="bg-background/50 transition-colors focus:bg-background"
/>
</div>
@@ -436,7 +438,7 @@ export function AgentBillDetail({
</div>
) : null}
{canWriteOff ? (
{canWriteOff ? (
<div className="space-y-4 rounded-xl border border-destructive/20 bg-destructive/5 p-5 shadow-sm">
<div className="space-y-1">
<p className="font-semibold tracking-tight text-destructive">
@@ -471,7 +473,7 @@ export function AgentBillDetail({
</div>
) : null}
{canManage && locked ? (
{canFinanceAdjustments && locked ? (
<div className="space-y-4 rounded-xl border border-dashed border-border/70 bg-card p-5 shadow-sm">
<div className="space-y-1">
<p className="font-semibold tracking-tight">
@@ -488,9 +490,9 @@ export function AgentBillDetail({
<Input
value={adjustAmount}
onChange={(e) => setAdjustAmount(e.target.value)}
type="number"
inputMode="decimal"
placeholder={t("settlementBills.adjustmentAmountPlaceholder", {
defaultValue: "输入正数或负数",
defaultValue: "例如35.20 或 -10.00",
})}
className="bg-background/50 transition-colors focus:bg-background"
/>
@@ -517,7 +519,6 @@ export function AgentBillDetail({
</Button>
</div>
) : null}
</div>
</div>
</>
);

View File

@@ -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<AgentSettlementReportType>("summary");
const [response, setResponse] = useState<AgentSettlementReportResponse | null>(null);
const [loading, setLoading] = useState(false);
@@ -82,7 +91,7 @@ export function AgentSettlementReportsPanel({
<SelectValue>{() => reportTypeLabel(reportType)}</SelectValue>
</SelectTrigger>
<SelectContent>
{REPORT_TYPES.map((key) => (
{reportTypes.map((key) => (
<SelectItem key={key} value={key}>
{reportTypeLabel(key)}
</SelectItem>

View File

@@ -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)}
</TableCell>
<TableCell>
{t(`adjustmentType.${row.adjustment_type}`, {
defaultValue: row.adjustment_type,
})}
{settlementAdjustmentTypeLabel(row.adjustment_type, t)}
</TableCell>
<TableCell className="tabular-nums">
{row.original_bill_id != null ? `#${row.original_bill_id}` : "—"}

View File

@@ -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({
</span>
</div>
<div className="flex flex-wrap items-center gap-2 text-base">
<span className="font-semibold text-foreground">{direction.payer}</span>
<span className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2.5 py-0.5 text-xs font-medium text-primary">
{t("settlementCenter:billDisplay.pays", { defaultValue: "应付" })}
<ArrowRight className="size-3.5" aria-hidden />
</span>
<span className="font-semibold text-foreground">{direction.payee}</span>
</div>
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div className="min-w-0 space-y-3">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm">
<span className="text-muted-foreground">
{t("settlementCenter:billDisplay.payerLabel", { defaultValue: "付款方" })}
</span>
<span className="font-semibold text-foreground">{direction.payer}</span>
<ArrowRight className="size-4 shrink-0 text-muted-foreground" aria-hidden />
<span className="text-muted-foreground">
{t("settlementCenter:billDisplay.payeeLabel", { defaultValue: "收款方" })}
</span>
<span className="font-semibold text-foreground">{direction.payee}</span>
</div>
<div>
<p className="text-xs text-muted-foreground">
{t("settlementCenter:billDisplay.settlementAmount", { defaultValue: "本期结算金额" })}
</p>
<p className="mt-1 text-2xl font-bold tabular-nums tracking-tight text-foreground">
{formatDashboardMoneyMinor(direction.amount, currencyCode)}
</p>
</div>
<div className="grid gap-2 sm:grid-cols-2">
<div className="rounded-xl border border-border/50 bg-background/50 px-4 py-3">
<p className="text-xs text-muted-foreground">
{t("settlementCenter:columns.paid", { defaultValue: "已收付" })}
</p>
<p className="mt-0.5 font-medium tabular-nums">
{formatDashboardMoneyMinor(bill.paid_amount ?? 0, currencyCode)}
</p>
<div>
<p className="text-xs text-muted-foreground">
{t("settlementCenter:billDisplay.settlementAmount", { defaultValue: "结算金额" })}
</p>
<p className="mt-0.5 text-2xl font-bold tabular-nums tracking-tight text-foreground">
{formatDashboardMoneyMinor(direction.amount, currencyCode)}
</p>
</div>
</div>
<div
className={cn(
"rounded-xl border px-4 py-3",
unpaid
? "border-amber-200/80 bg-amber-50/80 dark:border-amber-900/50 dark:bg-amber-950/20"
: "border-border/50 bg-background/50",
)}
>
<p className="text-xs text-muted-foreground">
{t("settlementCenter:columns.unpaid", { defaultValue: "未结" })}
</p>
<p
<div className="grid w-full gap-2 sm:grid-cols-2 lg:w-auto lg:min-w-[15rem]">
<div className="rounded-lg border border-border/50 bg-background/50 px-4 py-3">
<p className="text-xs text-muted-foreground">
{t("settlementCenter:columns.paid", { defaultValue: "已收付" })}
</p>
<p className="mt-0.5 font-medium tabular-nums">
{formatDashboardMoneyMinor(bill.paid_amount ?? 0, currencyCode)}
</p>
</div>
<div
className={cn(
"mt-0.5 font-semibold tabular-nums",
unpaid && "text-amber-900 dark:text-amber-200",
"rounded-lg border px-4 py-3",
unpaid
? "border-amber-200/80 bg-amber-50/80 dark:border-amber-900/50 dark:bg-amber-950/20"
: "border-border/50 bg-background/50",
)}
>
{formatDashboardMoneyMinor(bill.unpaid_amount, currencyCode)}
</p>
{unpaid ? (
<p className="mt-1 text-xs text-muted-foreground">
{bill.status === "pending_confirm"
? t("settlementCenter:billDisplay.unpaidPendingConfirm", {
defaultValue: "确认账单后可登记收付",
})
: t("settlementCenter:billDisplay.unpaidAwaitingPayment", {
defaultValue: "请登记线下收付",
})}
<p className="text-xs text-muted-foreground">
{t("settlementCenter:columns.unpaid", { defaultValue: "未结" })}
</p>
) : (
<p className="mt-1 text-xs text-emerald-700 dark:text-emerald-400">
{t("settlementCenter:billDisplay.fullySettled", { defaultValue: "本期已结清" })}
<p
className={cn(
"mt-0.5 font-semibold tabular-nums",
unpaid && "text-amber-900 dark:text-amber-200",
)}
>
{formatDashboardMoneyMinor(bill.unpaid_amount, currencyCode)}
</p>
)}
{unpaid ? (
<p className="mt-1 text-xs text-muted-foreground">
{bill.status === "pending_confirm"
? t("settlementCenter:billDisplay.unpaidPendingConfirm", {
defaultValue: "确认账单后可登记收付",
})
: t("settlementCenter:billDisplay.unpaidAwaitingPayment", {
defaultValue: "请登记线下收付",
})}
</p>
) : (
<p className="mt-1 text-xs text-emerald-700 dark:text-emerald-400">
{t("settlementCenter:billDisplay.fullySettled", { defaultValue: "本期已结清" })}
</p>
)}
</div>
</div>
</div>
</div>
@@ -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 (
<>
<div
className={cn(
"grid grid-cols-[minmax(0,1fr)_7.5rem] items-start gap-x-4 py-1.5 text-sm",
nested && "py-1 pl-4",
!nested && (line.kind === "subtotal" || line.kind === "total") &&
"border-t border-border/60 pt-2.5 font-medium",
!nested && line.kind === "total" && "text-base",
)}
>
<div className="min-w-0">
<span
className={cn(
nested ? "text-muted-foreground/90" : line.kind === "total" ? "text-foreground" : "text-muted-foreground",
)}
>
{prefix ? <span className="mr-1.5 tabular-nums">{prefix}</span> : null}
{line.label}
</span>
{line.hint ? (
<p className="mt-0.5 text-xs leading-relaxed text-muted-foreground/80">{line.hint}</p>
) : null}
</div>
<span
className={cn(
"text-right tabular-nums",
signedSettlementMoneyClass(line.signedAmount, line.kind === "total"),
)}
>
{formatSignedSettlementMoney(line.signedAmount, currencyCode)}
</span>
</div>
{line.children?.map((child) => (
<BreakdownAmountLine key={child.key} line={child} currencyCode={currencyCode} nested />
))}
</>
);
}
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 (
<div className="space-y-4 rounded-xl border border-border/70 bg-card p-5 shadow-sm">
<p className="font-semibold tracking-tight text-foreground">
{t("settlementCenter:billDisplay.howAmountWorks", { defaultValue: "金额怎么来的" })}
</p>
<div className="space-y-1">
<p className="font-semibold tracking-tight text-foreground">
{t("settlementCenter:billDisplay.howAmountWorks", { defaultValue: "结算明细" })}
</p>
{breakdownIntro ? (
<p className="text-xs leading-relaxed text-muted-foreground">{breakdownIntro}</p>
) : null}
</div>
<div className="space-y-2">
{lines.map((line) => {
const prefix =
line.kind === "subtract"
? ""
: line.kind === "add" && lines.indexOf(line) > 0
? "+"
: line.kind === "subtotal" || line.kind === "total"
? "="
: "";
return (
<div
key={line.key}
className={cn(
"flex items-start justify-between gap-3 text-sm",
(line.kind === "subtotal" || line.kind === "total") &&
"border-t border-border/60 pt-2 font-medium",
line.kind === "total" && "text-base",
)}
>
<div className="min-w-0">
<span className="text-muted-foreground">
{prefix ? <span className="mr-1.5 tabular-nums">{prefix}</span> : null}
{line.label}
</span>
</div>
<span className="shrink-0 tabular-nums">
{formatDashboardMoneyMinor(line.amount, currencyCode)}
</span>
</div>
);
})}
</div>
</div>
);
}
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 (
<div className="grid gap-4 text-sm sm:grid-cols-2">
<div className="rounded-xl border border-border/60 bg-card px-4 py-3.5 shadow-sm">
<p className="text-xs text-muted-foreground">
{t("settlementCenter:billDisplay.billOwner", { defaultValue: "账单主体" })}
</p>
<p className="mt-1 font-semibold text-foreground">{owner}</p>
</div>
<div className="rounded-xl border border-border/60 bg-card px-4 py-3.5 shadow-sm">
<p className="text-xs text-muted-foreground">
{t("settlementCenter:billDisplay.billCounterparty", { defaultValue: "结算对手" })}
</p>
<p className="mt-1 font-semibold text-foreground">{counterparty}</p>
<div className="rounded-lg border border-border/50 bg-muted/15 p-4">
{lines.map((line) => (
<BreakdownAmountLine key={line.key} line={line} currencyCode={currencyCode} />
))}
</div>
</div>
);

View File

@@ -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, string> = {},
): 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;

View File

@@ -0,0 +1,59 @@
import type { SettlementBillRow } from "@/api/admin-agent-settlement";
type BoundAgentRef = { id: number } | null | undefined;
function billPayeeParty(
bill: Pick<SettlementBillRow, "owner_type" | "owner_id" | "counterparty_type" | "counterparty_id" | "net_amount">,
): { 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<SettlementBillRow, "owner_type" | "owner_id" | "counterparty_type" | "counterparty_id">,
): 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;
}

View File

@@ -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,
};
}

View File

@@ -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({
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 flex-col gap-2">
<Link
href={settlementCenterListHref()}
href={settlementCenterListHref(adminSiteId)}
className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "h-8 w-fit px-2")}
>
<ArrowLeft className="size-4" aria-hidden />
@@ -74,7 +78,7 @@ export function SettlementCenterPeriodDetail({
{subViews.map((item) => (
<AdminSubnavLink
key={item.key}
href={settlementPeriodViewHref(period.id, item.key)}
href={settlementPeriodViewHref(period.id, item.key, adminSiteId)}
active={view === item.key}
>
{item.label}
@@ -93,6 +97,18 @@ export function SettlementCenterPeriodDetail({
pendingConfirm={pendingConfirm}
awaitingPayment={awaitingPayment}
selectedPeriodStatus={period.status}
boundAgentId={boundAgentId}
/>
) : null}
{view === "operations" ? (
<SettlementOperationsPanel
key={`${adminSiteId}-${period.id}-${refreshKey}-ops`}
adminSiteId={adminSiteId}
settlementPeriodId={period.id}
currencyCode={currencyCode}
refreshKey={refreshKey}
onOpenBill={onOpenBillDetail}
/>
) : null}

View File

@@ -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<SiteOption[]>([]);
const [adminSiteId, setAdminSiteId] = useState<number | null>(null);
@@ -59,10 +64,14 @@ export function SettlementCenterShell(): React.ReactElement {
const [periodsReady, setPeriodsReady] = useState(false);
const [detailBillId, setDetailBillId] = useState<number | null>(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 ? (
<p className="text-xs text-muted-foreground">
{t("boundAgentIdentity", {
defaultValue: "经营身份:{{agent}} · 账号 {{username}}",
agent: boundAgent.name || boundAgent.code,
username: profile?.username ?? "—",
})}
</p>
) : null;
const siteSelector =
siteOptions.length > 0 && siteId !== null ? (
<Popover open={sitePickerOpen} onOpenChange={setSitePickerOpen}>
@@ -143,6 +169,7 @@ export function SettlementCenterShell(): React.ReactElement {
setAdminSiteId(site.id);
setSitePickerOpen(false);
setSiteKeyword("");
router.replace(settlementCenterListHref(site.id));
}}
>
<div className="min-w-0 flex-1">
@@ -164,6 +191,14 @@ export function SettlementCenterShell(): React.ReactElement {
</Popover>
) : null;
const headerActions =
siteSelector !== null || boundAgentIdentity !== null ? (
<div className="flex flex-col items-end gap-1">
{siteSelector}
{boundAgentIdentity}
</div>
) : null;
const loadPeriods = useCallback(async (): Promise<SettlementPeriodRow[]> => {
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 (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4">
{siteId === null || !periodsReady ? (
{siteId === null ? (
<p className="text-sm text-muted-foreground">{t("empty.noSite", { defaultValue: "请选择站点。" })}</p>
) : !periodsReady ? (
<AdminLoadingState />
) : isListMode ? (
<SettlementPeriodWorkbench
adminSiteId={siteId}
currencyCode={currency}
canManage={canManagePeriods}
periods={periods}
headerActions={siteSelector}
headerActions={headerActions}
onViewDetail={(id) => openPeriodView(id, "bills")}
onReloadPeriods={loadPeriods}
onPeriodOpened={() => {
@@ -226,9 +314,21 @@ export function SettlementCenterShell(): React.ReactElement {
}}
/>
) : activePeriod === null ? (
<p className="text-sm text-muted-foreground">
{t("periodDetail.notFound", { defaultValue: "账期不存在或已切换站点,请返回列表。" })}
</p>
!periodLookupDone ? (
<AdminLoadingState />
) : (
<div className="flex flex-col gap-3">
<p className="text-sm text-muted-foreground">
{t("periodDetail.notFound", { defaultValue: "账期不存在或已切换站点,请返回列表。" })}
</p>
<Link
href={settlementCenterListHref(siteId)}
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "w-fit")}
>
{t("periodDetail.back", { defaultValue: "返回账期列表" })}
</Link>
</div>
)
) : (
<SettlementCenterPeriodDetail
period={activePeriod}
@@ -236,6 +336,7 @@ export function SettlementCenterShell(): React.ReactElement {
adminSiteId={siteId}
currencyCode={currency}
canOperateBills={canOperateBills}
boundAgentId={boundAgent?.id ?? null}
refreshKey={refreshKey}
onOpenBillDetail={setDetailBillId}
/>
@@ -243,7 +344,7 @@ export function SettlementCenterShell(): React.ReactElement {
<Dialog open={detailBillId !== null} onOpenChange={(open) => !open && setDetailBillId(null)}>
<DialogContent
className="grid !h-[min(92vh,980px)] !w-[calc(100vw-2rem)] !max-w-none sm:!w-[min(860px,calc(100vw-2rem))] sm:!max-w-[860px] grid-rows-[auto,minmax(0,1fr)] overflow-hidden p-0"
className="grid !h-[min(92vh,980px)] !w-[calc(100vw-2rem)] !max-w-none sm:!w-[min(640px,calc(100vw-2rem))] sm:!max-w-[640px] grid-rows-[auto,minmax(0,1fr)] overflow-hidden p-0"
>
<DialogHeader className="border-b px-6 py-4">
<DialogTitle>{t("actions.billDetail", { defaultValue: "账单详情" })}</DialogTitle>
@@ -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);

View File

@@ -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<BillFilters>(initialFilters);
const [applied, setApplied] = useState<BillFilters>(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({
<SelectValue>{() => billTypeLabel(draft.billType)}</SelectValue>
</SelectTrigger>
<SelectContent>
{(["all", "player", "agent"] as BillTypeFilter[]).map((value) => (
{billTypeOptions.map((value) => (
<SelectItem key={value} value={value}>
{billTypeLabel(value)}
</SelectItem>

View File

@@ -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<OperationTypeFilter, "all">;
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<OperationFilters>(initialFilters);
const [applied, setApplied] = useState<OperationFilters>(initialFilters);
const [allRows, setAllRows] = useState<SettlementOperationRow[]>([]);
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 (
<div className="space-y-5">
<div className="rounded-xl border border-border/70 bg-card p-5 shadow-sm">
<div className="grid gap-x-5 gap-y-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="grid gap-2">
<Label htmlFor="ops-bill-id" className="text-muted-foreground">
{t("columns.billId", { defaultValue: "账单 ID" })}
</Label>
<Input
id="ops-bill-id"
inputMode="numeric"
placeholder={t("billsPanel.optional", { defaultValue: "可选" })}
value={draft.billId}
onChange={(e) => setDraft((d) => ({ ...d, billId: e.target.value }))}
onKeyDown={(e) => {
if (e.key === "Enter") {
runSearch();
}
}}
className="bg-background/50 transition-colors focus:bg-background"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="ops-keyword" className="text-muted-foreground">
{t("operations.keyword", { defaultValue: "关键词" })}
</Label>
<Input
id="ops-keyword"
placeholder={t("operations.keywordPh", {
defaultValue: "方式、原因、凭证、收付方向",
})}
value={draft.keyword}
onChange={(e) => setDraft((d) => ({ ...d, keyword: e.target.value }))}
onKeyDown={(e) => {
if (e.key === "Enter") {
runSearch();
}
}}
className="bg-background/50 transition-colors focus:bg-background"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="ops-type" className="text-muted-foreground">
{t("operations.operationType", { defaultValue: "操作类型" })}
</Label>
<Select
modal={false}
value={draft.operationType}
onValueChange={(v) =>
setDraft((d) => ({
...d,
operationType: (v ?? "all") as OperationTypeFilter,
}))
}
>
<SelectTrigger id="ops-type" className="w-full bg-background/50 transition-colors focus:bg-background">
<SelectValue>{() => operationTypeLabel(draft.operationType)}</SelectValue>
</SelectTrigger>
<SelectContent>
{OPERATION_TYPES.map((value) => (
<SelectItem key={value} value={value}>
{operationTypeLabel(value)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="mt-5 flex flex-wrap items-center gap-3">
<Button type="button" onClick={runSearch}>
{t("billsPanel.searchBtn", { defaultValue: "搜索" })}
</Button>
<Button type="button" variant="outline" onClick={resetFilters}>
{t("billsPanel.reset", { defaultValue: "重置" })}
</Button>
</div>
</div>
<div className="admin-table-shell overflow-x-auto rounded-xl border border-border/70 bg-card shadow-sm">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("columns.time", { defaultValue: "时间" })}</TableHead>
<TableHead>{t("operations.operationType", { defaultValue: "操作类型" })}</TableHead>
<TableHead>{t("columns.billId", { defaultValue: "账单 ID" })}</TableHead>
<TableHead className="text-right">{t("columns.amount", { defaultValue: "金额" })}</TableHead>
<TableHead>{t("columns.summary", { defaultValue: "摘要" })}</TableHead>
<TableHead>{t("columns.detail", { defaultValue: "说明" })}</TableHead>
<TableHead className="sticky right-0 z-20 w-14 bg-muted text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
{t("common:table.actions", { defaultValue: "操作" })}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? <AdminTableLoadingRow colSpan={colSpan} /> : null}
{!loading && pageRows.length === 0 ? (
<AdminTableNoResourceRow colSpan={colSpan} cellClassName="py-12 text-center" />
) : null}
{!loading
? pageRows.map((row) => (
<TableRow key={row.key}>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{formatTs(row.sortAt)}
</TableCell>
<TableCell>
<span
className={cn(
"inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium",
kindBadgeClass(row.kind),
)}
>
{operationTypeLabel(row.kind)}
</span>
</TableCell>
<TableCell className="tabular-nums">
{row.billId > 0 ? `#${row.billId}` : "—"}
</TableCell>
<TableCell className="text-right tabular-nums font-medium">
{formatDashboardMoneyMinor(row.amount, currencyCode)}
</TableCell>
<TableCell className="max-w-[160px] truncate text-sm">{row.summary}</TableCell>
<TableCell className="max-w-[240px] truncate text-sm text-muted-foreground">
{row.detail ?? "—"}
</TableCell>
<TableCell
className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]"
onClick={(e) => e.stopPropagation()}
>
{row.billId > 0 ? (
<AdminRowActionsMenu
actions={[
{
key: "detail",
label: t("actions.detail", { defaultValue: "详情" }),
icon: Eye,
onClick: () => onOpenBill(row.billId),
},
]}
/>
) : (
"—"
)}
</TableCell>
</TableRow>
))
: null}
</TableBody>
</Table>
</div>
{!loading && total > 0 ? (
<AdminListPaginationFooter
page={page}
perPage={perPage}
total={total}
onPageChange={setPage}
onPerPageChange={(value) => {
setPerPage(value);
setPage(1);
}}
/>
) : null}
</div>
);
}

View File

@@ -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({
</TableCell>
<TableCell>{row.method ?? "—"}</TableCell>
<TableCell>
{t(`paymentStatus.${row.status}`, {
defaultValue: row.status === "confirmed" ? "已确认" : row.status,
})}
{settlementPaymentStatusLabel(row.status, t)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatTs(row.confirmed_at ?? row.created_at)}

View File

@@ -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<SettlementPeriodOpenHints | null>(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<void> {
async function openWithRange(startYmd: string, endYmd: string): Promise<void> {
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<void> {
const range = settlementPeriodPresetRange(key);
await openWithRange(range.period_start, range.period_end);
}
async function openCustom(): Promise<void> {
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<void> {
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)}
>
<Plus className="size-4" aria-hidden />
{t("period.openBtn", { defaultValue: "开账" })}
@@ -370,76 +443,81 @@ export function SettlementPeriodWorkbench({
/>
</AdminPageCard>
<Dialog
open={openDialogOpen}
onOpenChange={(open) => {
setOpenDialogOpen(open);
if (!open) {
setCustomStart("");
setCustomEnd("");
}
}}
>
<Dialog open={openDialogOpen} onOpenChange={handleOpenDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("period.openTitle", { defaultValue: "开账" })}</DialogTitle>
<DialogDescription>
{t("agents:settlementPeriods.openHint", {
defaultValue: "选择快捷账期或自定义起止时间。",
{t("agents:settlementPeriods.openDesc", {
defaultValue:
"选择账期起止日期;灰色删除线为已有账期不可再开,琥珀点为待入账,右上角红点为未结清。",
})}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
{PRESET_KEYS.map((key) => (
<Button
key={key}
type="button"
size="sm"
variant="secondary"
disabled={busy}
onClick={() => void openWithPreset(key)}
<div className="space-y-3">
<AdminDateRangeField
id="sp-dialog-range"
label={t("agents:settlementPeriods.rangeLabel", { defaultValue: "账期范围" })}
from={customStart}
to={customEnd}
disabled={hintsLoading || busy}
calendarMarkers={calendarMarkers}
rangeHint={t("agents:settlementPeriods.rangeHint", {
defaultValue: "先选开始日期,再选结束日期;单日账期可点同一天两次。",
})}
onRangeChange={({ from, to }) => {
setCustomStart(from);
setCustomEnd(to);
}}
/>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<span
className="inline-block size-3 rounded bg-muted ring-1 ring-border line-through"
aria-hidden
/>
{t("agents:settlementPeriods.markerOccupied", { defaultValue: "已有账期" })}
</span>
<span className="inline-flex items-center gap-1.5">
<span
className="inline-block size-1.5 rounded-full bg-amber-500"
aria-hidden
/>
{t("agents:settlementPeriods.markerPending", { defaultValue: "待入账流水" })}
</span>
<span className="inline-flex items-center gap-1.5">
<span
className="relative inline-block size-3 rounded ring-1 ring-border"
aria-hidden
>
{presetLabel(key)}
</Button>
))}
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid gap-1.5">
<Label htmlFor="sp-dialog-start">
{t("agents:settlementPeriods.start", { defaultValue: "开始" })}
</Label>
<Input
id="sp-dialog-start"
type="datetime-local"
value={customStart}
onChange={(e) => setCustomStart(e.target.value)}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="sp-dialog-end">
{t("agents:settlementPeriods.end", { defaultValue: "结束" })}
</Label>
<Input
id="sp-dialog-end"
type="datetime-local"
value={customEnd}
onChange={(e) => setCustomEnd(e.target.value)}
/>
</div>
<span className="absolute top-0 right-0 size-1.5 rounded-full bg-rose-500" />
</span>
{t("agents:settlementPeriods.markerUnpaid", { defaultValue: "未结清账期" })}
</span>
</div>
{selectedRangeOverlapsOccupied ? (
<p className="text-destructive text-xs">
{t("agents:settlementPeriods.overlapsOccupied", {
defaultValue: "所选范围与已有账期重叠,请避开灰色删除线的日期。",
})}
</p>
) : null}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
disabled={busy}
onClick={() => setOpenDialogOpen(false)}
disabled={busy || hintsLoading}
onClick={() => handleOpenDialog(false)}
>
{t("common:cancel", { defaultValue: "取消" })}
</Button>
<Button type="button" disabled={busy} onClick={() => void openCustom()}>
{t("agents:settlementPeriods.open", { defaultValue: "开期" })}
<Button
type="button"
disabled={busy || hintsLoading || selectedRangeOverlapsOccupied}
onClick={() => void openCustom()}
>
{t("period.openBtn", { defaultValue: "开账" })}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -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)}`;
}

View File

@@ -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 });
}

View File

@@ -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 ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
{(loading && !data) || data ? (
<>
<div className="rounded-md border">
<Table id="wallet-transfer-orders-table" className="table-fixed">
<div className="admin-table-shell overflow-x-auto rounded-md border">
<Table id="wallet-transfer-orders-table" className="min-w-[1180px]">
<TableHeader>
<TableRow>
<TableHead className="min-w-0 max-w-[14rem]">{t("localTransferNo")}</TableHead>
@@ -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 (
<Card>
<CardHeader>
@@ -863,56 +899,66 @@ export function WalletTxnsPanel(): React.ReactElement {
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
{(loading && !data) || data ? (
<>
<div className="rounded-md border">
<Table id="wallet-transactions-table" className="table-fixed">
<div className="admin-table-shell overflow-x-auto rounded-md border">
<Table id="wallet-transactions-table" className="min-w-[1180px]">
<TableHeader>
<TableRow>
<TableHead className="min-w-0 max-w-[14rem]">{t("txnNo")}</TableHead>
<TableHead className="min-w-0 max-w-[12rem]">{t("externalRefNo")}</TableHead>
<TableHead className="min-w-[10rem] whitespace-nowrap">{t("txnNo")}</TableHead>
<TableHead className="min-w-[8rem] whitespace-nowrap">{t("externalRefNo")}</TableHead>
<AdminAgentIdentityHeads />
<AdminPlayerIdentityHeads />
<TableHead className="whitespace-nowrap">{t("ledgerChannel", { defaultValue: "账本" })}</TableHead>
<TableHead className="whitespace-nowrap">{t("type")}</TableHead>
<TableHead className="whitespace-nowrap">{t("amount")}</TableHead>
{showLedgerColumn ? (
<TableHead className="whitespace-nowrap">{t("ledgerChannel", { defaultValue: "账本" })}</TableHead>
) : null}
<TableHead className="min-w-[6.5rem] whitespace-nowrap">{t("type")}</TableHead>
<TableHead className="min-w-[6.5rem] whitespace-nowrap">{t("amount")}</TableHead>
<TableHead className="whitespace-nowrap">{t("status")}</TableHead>
<TableHead className="min-w-0 whitespace-normal leading-tight">{t("requestTime")}</TableHead>
<TableHead className="min-w-0 whitespace-normal leading-tight">{t("finishedTime")}</TableHead>
<TableHead className="min-w-[8.5rem] whitespace-nowrap">{t("requestTime")}</TableHead>
<TableHead className="min-w-[8.5rem] whitespace-nowrap">{t("finishedTime")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading && !data ? (
<AdminTableLoadingRow colSpan={12} />
<AdminTableLoadingRow colSpan={txnTableColSpan} />
) : !data || data.items.length === 0 ? (
<AdminTableNoResourceRow colSpan={12} />
<AdminTableNoResourceRow colSpan={txnTableColSpan} />
) : (
data.items.map((row) => (
<TableRow key={row.id}>
<TableCell className="min-w-0 max-w-[14rem] align-top whitespace-normal">
<TableCell className="min-w-[10rem] max-w-[12rem] align-top">
<CellMonoId value={row.txn_no} copyHint={t("copyTxnNo")} />
</TableCell>
<TableCell className="min-w-0 max-w-[12rem] align-top whitespace-normal">
<TableCell className="min-w-[8rem] max-w-[10rem] align-top">
<CellMonoId value={row.external_ref_no} copyHint={t("copyExternalTxnRefNo")} />
</TableCell>
<AdminAgentIdentityCells row={row} />
<AdminPlayerIdentityCells row={row} />
<TableCell>
<PlayerLedgerSourceBadge ledgerSource={row.ledger_source} />
{showLedgerColumn ? (
<TableCell className="align-top whitespace-nowrap">
<PlayerLedgerSourceBadge ledgerSource={row.ledger_source} />
</TableCell>
) : null}
<TableCell className="min-w-[6.5rem] max-w-[9rem] align-top text-xs">
<span
className="block truncate"
title={walletTxnBizTypeLabel(row.biz_type, row.ledger_source, t, tSettlement)}
>
{walletTxnBizTypeLabel(row.biz_type, row.ledger_source, t, tSettlement)}
</span>
</TableCell>
<TableCell className="min-w-0 text-xs">
{row.ledger_source === "credit_ledger"
? creditLedgerReasonLabel(row.biz_type, tSettlement)
: row.biz_type}
<TableCell className="min-w-[6.5rem] align-top whitespace-nowrap tabular-nums text-xs">
{row.amount_formatted ?? formatAdminMinorUnits(row.amount)}
<span className="ml-1 text-muted-foreground">
({row.direction === 1 ? t("in") : t("out")})
</span>
</TableCell>
<TableCell className="tabular-nums text-xs">
{row.amount} ({row.direction === 1 ? t("in") : t("out")})
</TableCell>
<TableCell>
<TableCell className="align-top whitespace-nowrap">
<AdminStatusBadge status={row.status}>{statusLabelT(row.status, t)}</AdminStatusBadge>
</TableCell>
<TableCell className="min-w-0 whitespace-normal font-mono text-[11px] leading-snug text-muted-foreground">
<TableCell className="min-w-[8.5rem] align-top whitespace-nowrap font-mono text-[11px] leading-snug text-muted-foreground">
{formatTs(row.created_at)}
</TableCell>
<TableCell className="min-w-0 whitespace-normal font-mono text-[11px] leading-snug text-muted-foreground">
<TableCell className="min-w-[8.5rem] align-top whitespace-nowrap font-mono text-[11px] leading-snug text-muted-foreground">
{formatTs(row.updated_at)}
</TableCell>
</TableRow>

View File

@@ -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;

View File

@@ -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[];

View File

@@ -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;

View File

@@ -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;