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" | "platform_pnl"
| "draw_period"; | "draw_period";
export type DownlineShareItem = {
owner_id: number;
owner_label: string;
share_profit: number;
};
export type DownlineShareBreakdown = {
total: number;
items: DownlineShareItem[];
};
export type SettlementBillRow = { export type SettlementBillRow = {
id: number; id: number;
settlement_period_id: number; settlement_period_id: number;
@@ -88,6 +99,20 @@ export async function postSettlementPeriod(body: {
return adminRequest.post(`${A}/settlement-periods`, 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 = { export type SettlementPeriodCloseResult = {
period_id: number; period_id: number;
unsettled_ticket_count?: number; unsettled_ticket_count?: number;
@@ -262,6 +287,7 @@ export async function getSettlementBill(billId: number): Promise<{
rebate_allocations: RebateAllocationRow[]; rebate_allocations: RebateAllocationRow[];
adjustments: Array<{ id: number; amount: number; adjustment_type: string; reason: string | null }>; adjustments: Array<{ id: number; amount: number; adjustment_type: string; reason: string | null }>;
tier_edge?: string | null; tier_edge?: string | null;
downline_shares?: DownlineShareBreakdown;
}> { }> {
return adminRequest.get(`${A}/settlement-bills/${billId}`); 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 { export function AdminAgentHead({ className }: HeadProps): React.ReactElement {
const { t } = useTranslation("common"); const { t } = useTranslation("common");
return ( return (
<TableHead className={cn("whitespace-nowrap", className)}> <TableHead className={cn("min-w-[7.5rem] whitespace-nowrap", className)}>
{t("agentColumns.agent")} {t("agentColumns.agent")}
</TableHead> </TableHead>
); );
} }
export function AdminAgentCell({ row, className }: CellProps): React.ReactElement { export function AdminAgentCell({ row, className }: CellProps): React.ReactElement {
const name = cellText(row.agent_name);
const code = row.agent_code?.trim() ?? "";
return ( return (
<TableCell className={cn("text-xs", className)}> <TableCell className={cn("min-w-[7.5rem] max-w-[10rem] align-top text-xs", className)}>
<span className="font-medium">{cellText(row.agent_name)}</span> <div className="min-w-0 space-y-0.5">
{row.agent_code ? ( <span className="block truncate font-medium" title={name !== "—" ? name : undefined}>
<span className="mt-0.5 block font-mono text-[11px] text-muted-foreground">{row.agent_code}</span> {name}
</span>
{code !== "" ? (
<span
className="block truncate font-mono text-[11px] text-muted-foreground"
title={code}
>
{code}
</span>
) : null} ) : null}
</div>
</TableCell> </TableCell>
); );
} }

View File

@@ -14,6 +14,15 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import { useIsMobile } from "@/hooks/use-mobile"; import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils"; 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 { function parseYmd(value: string): Date | undefined {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
return undefined; return undefined;
@@ -45,6 +54,9 @@ export function AdminDateRangeField({
to: toProp, to: toProp,
onRangeChange, onRangeChange,
placeholder, placeholder,
rangeHint,
calendarMarkers,
disabled,
}: { }: {
id: string; id: string;
label?: string; label?: string;
@@ -52,6 +64,9 @@ export function AdminDateRangeField({
to: string; to: string;
onRangeChange: (next: { from: string; to: string }) => void; onRangeChange: (next: { from: string; to: string }) => void;
placeholder?: string; placeholder?: string;
rangeHint?: string;
calendarMarkers?: AdminDateRangeCalendarMarkers;
disabled?: boolean;
}) { }) {
const { t } = useTranslation(["common"]); const { t } = useTranslation(["common"]);
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
@@ -77,6 +92,28 @@ export function AdminDateRangeField({
const hasSelection = Boolean(parseYmd(fromProp) || parseYmd(toProp)); const hasSelection = Boolean(parseYmd(fromProp) || parseYmd(toProp));
const defaultMonth = selected?.from ?? selected?.to ?? new Date(); 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 ( return (
<div className="grid gap-1.5"> <div className="grid gap-1.5">
{label ? ( {label ? (
@@ -94,6 +131,7 @@ export function AdminDateRangeField({
<PopoverTrigger <PopoverTrigger
type="button" type="button"
id={id} id={id}
disabled={disabled}
className={cn( className={cn(
buttonVariants({ variant: "outline", size: "default" }), buttonVariants({ variant: "outline", size: "default" }),
"h-8 min-h-8 w-full justify-start gap-2 px-2.5 font-normal md:text-sm", "h-8 min-h-8 w-full justify-start gap-2 px-2.5 font-normal md:text-sm",
@@ -106,6 +144,9 @@ export function AdminDateRangeField({
</span> </span>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent align="start" sideOffset={6} className="w-auto max-w-[calc(100vw-2rem)] min-w-fit p-0"> <PopoverContent align="start" sideOffset={6} className="w-auto max-w-[calc(100vw-2rem)] min-w-fit p-0">
{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"> <p className="text-muted-foreground border-b px-3 py-2 text-xs leading-relaxed">
{t("date.rangeHint", { {t("date.rangeHint", {
ns: "common", ns: "common",
@@ -113,6 +154,7 @@ export function AdminDateRangeField({
"Select a start date, then an end date. For a single day, click the same date twice. Click Done to close.", "Select a start date, then an end date. For a single day, click the same date twice. Click Done to close.",
})} })}
</p> </p>
)}
<Calendar <Calendar
mode="range" mode="range"
locale={enUS} locale={enUS}
@@ -120,6 +162,20 @@ export function AdminDateRangeField({
selected={selected} selected={selected}
defaultMonth={defaultMonth} defaultMonth={defaultMonth}
numberOfMonths={isMobile ? 1 : 2} 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) => { onSelect={(range) => {
if (!range?.from && !range?.to) { if (!range?.from && !range?.to) {
onRangeChange({ from: "", to: "" }); onRangeChange({ from: "", to: "" });

View File

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

View File

@@ -164,6 +164,13 @@
"presetThisWeek": "This week", "presetThisWeek": "This week",
"presetLastWeek": "Last week", "presetLastWeek": "Last week",
"presetThisMonth": "This month", "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", "statusOpen": "Open",
"statusClosed": "Closed" "statusClosed": "Closed"
}, },

View File

@@ -25,6 +25,8 @@
"summaryBet": "Period bet", "summaryBet": "Period bet",
"summaryPayout": "Period payout", "summaryPayout": "Period payout",
"summaryProfit": "Period profit", "summaryProfit": "Period profit",
"summaryShareProfit": "Own share profit",
"shareProfitHint": "Your share after split — not platform or full team gross P/L",
"dailyTrend": "Period trend", "dailyTrend": "Period trend",
"granularityDay": "By day", "granularityDay": "By day",
"playBreakdown": "Play breakdown", "playBreakdown": "Play breakdown",
@@ -177,15 +179,18 @@
"todayBet": "Today's bet", "todayBet": "Today's bet",
"todayPayout": "Today's payout", "todayPayout": "Today's payout",
"todayProfit": "Today's profit", "todayProfit": "Today's profit",
"todayShareProfit": "Today's share profit",
"sevenDayTitle": "Last 7 days", "sevenDayTitle": "Last 7 days",
"sevenDayPayout": "Payout {{amount}}", "sevenDayPayout": "Payout {{amount}}",
"sevenDayProfit": "Profit {{amount}}", "sevenDayProfit": "Profit {{amount}}",
"sevenDayShareProfit": "Share profit {{amount}}",
"pendingBills": "Open agent bills", "pendingBills": "Open agent bills",
"pendingUnpaid": "Unpaid total {{amount}}", "pendingUnpaid": "Unpaid total {{amount}}",
"latestBetAt": "Latest bet {{time}}", "latestBetAt": "Latest bet {{time}}",
"noBetToday": "No bets yet today", "noBetToday": "No bets yet today",
"topMomentum": "Today's bet focus", "topMomentum": "Today's bet focus",
"topMomentumHint": "Profit {{profit}}", "topMomentumHint": "Profit {{profit}}",
"topMomentumPayout": "Payout {{amount}}",
"managementFocus": "Management focus", "managementFocus": "Management focus",
"focusBet": "Watch today's bet volume", "focusBet": "Watch today's bet volume",
"focusPlayers": "Today's active players", "focusPlayers": "Today's active players",

View File

@@ -5,11 +5,11 @@
"generating": "Generating…", "generating": "Generating…",
"generateSuccess": "Generated {{created}} draws, buffer {{upcoming}}/{{target}}", "generateSuccess": "Generated {{created}} draws, buffer {{upcoming}}/{{target}}",
"generateFailed": "Generation failed", "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": { "createDraw": {
"open": "New draw", "open": "New draw",
"title": "Create draw manually", "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.", "hint": "Start < close < draw. Draw number optional; sequence auto-assigned by UTC business date.",
"drawNoPlaceholder": "Enter draw number, for example 20260526-008", "drawNoPlaceholder": "Enter draw number, for example 20260526-008",
"drawTimeRequired": "Draw time is required", "drawTimeRequired": "Draw time is required",
@@ -34,7 +34,7 @@
"editDraw": { "editDraw": {
"action": "Edit draw", "action": "Edit draw",
"title": "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", "drawNoPlaceholder": "Enter draw number, for example 20260526-008",
"submit": "Save", "submit": "Save",
"saving": "Saving…", "saving": "Saving…",

View File

@@ -63,6 +63,7 @@
"nav": { "nav": {
"periods": "Periods", "periods": "Periods",
"bills": "Bills", "bills": "Bills",
"operations": "Payments & adjustments",
"ledger": "Account ledger", "ledger": "Account ledger",
"creditLedger": "Credit ledger", "creditLedger": "Credit ledger",
"playerBills": "Player bills", "playerBills": "Player bills",
@@ -74,6 +75,16 @@
"badDebt": "Bad debt", "badDebt": "Bad debt",
"reports": "Period reports" "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": { "filters": {
"period": "Period", "period": "Period",
"statusAll": "All", "statusAll": "All",
@@ -160,6 +171,8 @@
"amount": "Amount", "amount": "Amount",
"method": "Method", "method": "Method",
"time": "Time", "time": "Time",
"summary": "Summary",
"detail": "Detail",
"adjustmentType": "Type", "adjustmentType": "Type",
"originalBill": "Original bill", "originalBill": "Original bill",
"reason": "Reason", "reason": "Reason",
@@ -183,6 +196,10 @@
"reversal": "Reversal", "reversal": "Reversal",
"bad_debt": "Bad debt" "bad_debt": "Bad debt"
}, },
"paymentStatus": {
"pending": "Pending",
"confirmed": "Confirmed"
},
"actions": { "actions": {
"detail": "Detail", "detail": "Detail",
"viewBill": "View bill", "viewBill": "View bill",
@@ -193,9 +210,11 @@
"settlementAmount": "Settlement amount", "settlementAmount": "Settlement amount",
"pays": "Pays", "pays": "Pays",
"paysShort": "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.", "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", "playerGross": "Game win/loss",
"playerLostHint": "Player lost; owes agent", "playerLostHint": "Player lost; owes agent",
"playerWonHint": "Player won; agent owes player", "playerWonHint": "Player won; agent owes player",
@@ -208,15 +227,19 @@
"teamRebate": "Team rebate", "teamRebate": "Team rebate",
"teamNet": "Team net", "teamNet": "Team net",
"rebate": "Rebate", "rebate": "Rebate",
"agentShareKeep": "{{agent}} share kept", "agentShareKeep": "Share kept at this tier",
"agentShareKeepHint": "Profit retained at this tier by share ratio", "agentShareKeepHint": "Profit retained at this tier by share ratio",
"agentNet": "{{agent}} pays upline", "agentDownlineShare": "Downline share",
"agentNetReceive": "Upline pays {{agent}}", "agentDownlineShareHint": "Profit retained by downline agents (see breakdown below)",
"agentDownlineShareItem": "{{agent}} kept",
"agentNet": "Pay {{counterparty}}",
"agentNetReceive": "{{counterparty}} pays this tier",
"billOwner": "Bill owner", "billOwner": "Bill owner",
"billCounterparty": "Counterparty", "billCounterparty": "Counterparty",
"unpaidPendingConfirm": "Confirm the bill before recording payment", "unpaidPendingConfirm": "Confirm the bill before recording payment",
"unpaidAwaitingPayment": "Record offline payment", "unpaidAwaitingPayment": "Record offline payment",
"fullySettled": "Fully settled this period", "fullySettled": "Fully settled this period",
"confirmHint": "Confirm the bill before recording payment.",
"recordReceiptFrom": "Record receipt ({{payer}} → {{payee}})", "recordReceiptFrom": "Record receipt ({{payer}} → {{payee}})",
"recordPayoutTo": "Record payout ({{payer}} → {{payee}})", "recordPayoutTo": "Record payout ({{payer}} → {{payee}})",
"rebateAllocationsHint": "How rebate is allocated across agent tiers.", "rebateAllocationsHint": "How rebate is allocated across agent tiers.",

View File

@@ -24,6 +24,11 @@
"transferIn": "Main site transfer in", "transferIn": "Main site transfer in",
"transferOut": "Main site transfer out", "transferOut": "Main site transfer out",
"transferOutRefund": "Transfer-out refund", "transferOutRefund": "Transfer-out refund",
"bizBetDeduct": "Bet debit",
"bizBetReverse": "Bet reversal",
"bizSettlePayout": "Settlement payout",
"bizJackpotPayout": "Jackpot payout",
"bizSettlementAdjustment": "Settlement adjustment",
"transferOrders": "Transfer orders", "transferOrders": "Transfer orders",
"walletTransactions": "Wallet transactions", "walletTransactions": "Wallet transactions",
"playerWalletQuery": "Player wallet query", "playerWalletQuery": "Player wallet query",

View File

@@ -5,11 +5,11 @@
"generating": "सिर्जना हुँदैछ…", "generating": "सिर्जना हुँदैछ…",
"generateSuccess": "{{created}} ड्रअ सिर्जना भयो, बफर {{upcoming}}/{{target}}", "generateSuccess": "{{created}} ड्रअ सिर्जना भयो, बफर {{upcoming}}/{{target}}",
"generateFailed": "सिर्जना असफल भयो", "generateFailed": "सिर्जना असफल भयो",
"scheduleTimezoneHint": "सूची समय सर्भर समयक्षेत्र {{tz}} (GMT); ड्र अन्तराल {{interval}} मिनेट।", "scheduleTimezoneHint": "सूची समय स्थानीय समयक्षेत्र {{tz}} मा; सर्भर UTC मा भण्डारण। ड्र अन्तराल {{interval}} मिनेट।",
"createDraw": { "createDraw": {
"open": "नयाँ ड्रअ", "open": "नयाँ ड्रअ",
"title": "म्यानुअल ड्रअ सिर्जना", "title": "म्यानुअल ड्रअ सिर्जना",
"description": "{{tz}} मा मिति र समय प्रविष्ट गर्नुहोस् (ब्राउजर स्थानीय समय होइन)।", "description": "स्थानीय समयक्षेत्र {{tz}} मा मिति र समय प्रविष्ट गर्नुहोस्।",
"hint": "सुरु < बन्द < ड्रअ। ड्रअ नम्बर वैकल्पिक।", "hint": "सुरु < बन्द < ड्रअ। ड्रअ नम्बर वैकल्पिक।",
"drawNoPlaceholder": "ड्रअ नम्बर प्रविष्ट गर्नुहोस्, जस्तै 20260526-008", "drawNoPlaceholder": "ड्रअ नम्बर प्रविष्ट गर्नुहोस्, जस्तै 20260526-008",
"drawTimeRequired": "ड्रअ समय आवश्यक छ", "drawTimeRequired": "ड्रअ समय आवश्यक छ",
@@ -34,7 +34,7 @@
"editDraw": { "editDraw": {
"action": "सम्पादन", "action": "सम्पादन",
"title": "ड्रअ सम्पादन", "title": "ड्रअ सम्पादन",
"description": "ड्रअ {{drawNo}} · {{tz}}", "description": "ड्रअ {{drawNo}} · स्थानीय समयक्षेत्र {{tz}} मा सम्पादन",
"drawNoPlaceholder": "ड्रअ नम्बर प्रविष्ट गर्नुहोस्, जस्तै 20260526-008", "drawNoPlaceholder": "ड्रअ नम्बर प्रविष्ट गर्नुहोस्, जस्तै 20260526-008",
"submit": "सेभ", "submit": "सेभ",
"saving": "सेभ हुँदैछ…", "saving": "सेभ हुँदैछ…",

View File

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

View File

@@ -271,29 +271,33 @@
"settlementPeriods": { "settlementPeriods": {
"title": "代理账期", "title": "代理账期",
"manageTitle": "账期管理", "manageTitle": "账期管理",
"manageHint": "平台或负责人开并关账后,上方账单会自动生成。一般用快捷账期即可,无需手填时间。", "manageHint": "平台或负责人开并关账后,上方账单会自动生成。一般用快捷账期即可,无需手填时间。",
"presetHint": "快捷账期(推荐)", "presetHint": "快捷账期(推荐)",
"presetThisWeek": "本周", "presetThisWeek": "本周",
"presetLastWeek": "上周", "presetLastWeek": "上周",
"presetThisMonth": "本月", "presetThisMonth": "本月",
"openWithPreset": "按上方时间开期", "openHint": "本地日期({{tz}}",
"showAdvanced": "自定义起止时间", "openWithPreset": "填入快捷账期",
"hideAdvanced": "收起自定义时间", "showAdvanced": "自定义起止日期",
"hideAdvanced": "收起自定义日期",
"range": "账期", "range": "账期",
"statusOpen": "进行中", "statusOpen": "进行中",
"statusClosed": "已关账", "statusClosed": "已关账",
"empty": "尚无账期,请选择快捷账期开期。", "empty": "尚无账期,请选择快捷账期并开账。",
"startDate": "开始日期",
"endDate": "结束日期",
"start": "开始", "start": "开始",
"end": "结束", "end": "结束",
"status": "状态", "status": "状态",
"open": "开", "open": "开",
"close": "关账并生成账单", "close": "关账并生成账单",
"viewBills": "账单", "viewBills": "账单",
"opened": "账期已开启", "opened": "账期已开启",
"closed": "账期已关账,账单已生成", "closed": "账期已关账,账单已生成",
"openFailed": "开失败", "openFailed": "开失败",
"closeFailed": "关账失败", "closeFailed": "关账失败",
"datesRequired": "请填写账期起止" "datesRequired": "请填写账期起止日期",
"invalidRange": "结束日期不能早于开始日期"
}, },
"lineProvision": { "lineProvision": {
"title": "创建一级代理", "title": "创建一级代理",

View File

@@ -25,6 +25,8 @@
"summaryBet": "区间下注", "summaryBet": "区间下注",
"summaryPayout": "区间派彩", "summaryPayout": "区间派彩",
"summaryProfit": "区间盈亏", "summaryProfit": "区间盈亏",
"summaryShareProfit": "本级占成",
"shareProfitHint": "按占成比例计入本代理的收益/亏损,非平台或团队总输赢",
"dailyTrend": "区间趋势", "dailyTrend": "区间趋势",
"granularityDay": "按天", "granularityDay": "按天",
"playBreakdown": "玩法拆解 Top", "playBreakdown": "玩法拆解 Top",
@@ -177,15 +179,18 @@
"todayBet": "今日下注", "todayBet": "今日下注",
"todayPayout": "今日派彩", "todayPayout": "今日派彩",
"todayProfit": "今日盈亏", "todayProfit": "今日盈亏",
"todayShareProfit": "今日本级占成",
"sevenDayTitle": "近 7 天走势", "sevenDayTitle": "近 7 天走势",
"sevenDayPayout": "派彩 {{amount}}", "sevenDayPayout": "派彩 {{amount}}",
"sevenDayProfit": "盈亏 {{amount}}", "sevenDayProfit": "盈亏 {{amount}}",
"sevenDayShareProfit": "本级占成 {{amount}}",
"pendingBills": "待结代理账单", "pendingBills": "待结代理账单",
"pendingUnpaid": "未结合计 {{amount}}", "pendingUnpaid": "未结合计 {{amount}}",
"latestBetAt": "最近下注 {{time}}", "latestBetAt": "最近下注 {{time}}",
"noBetToday": "今日暂时没有下注", "noBetToday": "今日暂时没有下注",
"topMomentum": "今日投注焦点", "topMomentum": "今日投注焦点",
"topMomentumHint": "对应盈亏 {{profit}}", "topMomentumHint": "对应盈亏 {{profit}}",
"topMomentumPayout": "派彩 {{amount}}",
"managementFocus": "经营重点", "managementFocus": "经营重点",
"focusBet": "今天先盯下注额", "focusBet": "今天先盯下注额",
"focusPlayers": "今天活跃人数", "focusPlayers": "今天活跃人数",

View File

@@ -5,11 +5,11 @@
"generating": "生成中…", "generating": "生成中…",
"generateSuccess": "已生成 {{created}} 期,当前缓冲 {{upcoming}}/{{target}}", "generateSuccess": "已生成 {{created}} 期,当前缓冲 {{upcoming}}/{{target}}",
"generateFailed": "生成失败", "generateFailed": "生成失败",
"scheduleTimezoneHint": "列表时间为服务器时区 {{tz}}GMT与界面文档一致开奖间隔 {{interval}} 分钟LOTTERY_DRAW_INTERVAL_MINUTES。", "scheduleTimezoneHint": "列表时间按本地时区 {{tz}} 显示;服务器排期仍按 UTC 存储。开奖间隔 {{interval}} 分钟。",
"createDraw": { "createDraw": {
"open": "新建期号", "open": "新建期号",
"title": "手动创建期号", "title": "手动创建期号",
"description": "日期与时间按 {{tz}} 填写(勿用浏览器本地时区)。仅填开奖时间时,开始/封盘按系统配置自动推算。", "description": "日期与时间按本地时区 {{tz}} 填写。仅填开奖时间时,开始/封盘按系统配置自动推算。",
"hint": "开始 < 封盘 < 开奖。期号可留空,将按 UTC 业务日自动生成流水号。", "hint": "开始 < 封盘 < 开奖。期号可留空,将按 UTC 业务日自动生成流水号。",
"drawNoPlaceholder": "请输入期号,如 20260526-008", "drawNoPlaceholder": "请输入期号,如 20260526-008",
"drawTimeRequired": "请填写开奖时间", "drawTimeRequired": "请填写开奖时间",
@@ -34,7 +34,7 @@
"editDraw": { "editDraw": {
"action": "编辑期号", "action": "编辑期号",
"title": "编辑期号", "title": "编辑期号",
"description": "期号 {{drawNo}} · 时间按 {{tz}} 编辑", "description": "期号 {{drawNo}} · 时间按本地时区 {{tz}} 编辑",
"drawNoPlaceholder": "请输入期号,如 20260526-008", "drawNoPlaceholder": "请输入期号,如 20260526-008",
"submit": "保存", "submit": "保存",
"saving": "保存中…", "saving": "保存中…",

View File

@@ -49,6 +49,7 @@
"nav": { "nav": {
"periods": "账期", "periods": "账期",
"bills": "账单", "bills": "账单",
"operations": "收付与调账",
"ledger": "账务流水", "ledger": "账务流水",
"creditLedger": "信用流水", "creditLedger": "信用流水",
"playerBills": "玩家账单", "playerBills": "玩家账单",
@@ -60,6 +61,16 @@
"badDebt": "坏账核销", "badDebt": "坏账核销",
"reports": "账期报表" "reports": "账期报表"
}, },
"operations": {
"hint": "登记收付、坏账核销与补差/冲正的操作台账。玩家信用变动请查看「账务流水」。",
"adjustmentsTitle": "调账 / 冲正 / 坏账",
"loadFailed": "收付与调账记录加载失败",
"operationType": "操作类型",
"filterAllTypes": "全部类型",
"typePayment": "登记收付",
"keyword": "关键词",
"keywordPh": "方式、原因、凭证、收付方向"
},
"filters": { "filters": {
"period": "账期范围", "period": "账期范围",
"statusAll": "全部", "statusAll": "全部",
@@ -154,6 +165,8 @@
"amount": "金额", "amount": "金额",
"method": "方式", "method": "方式",
"time": "时间", "time": "时间",
"summary": "摘要",
"detail": "说明",
"adjustmentType": "调账类型", "adjustmentType": "调账类型",
"originalBill": "原账单", "originalBill": "原账单",
"reason": "原因", "reason": "原因",
@@ -177,6 +190,10 @@
"reversal": "冲正", "reversal": "冲正",
"bad_debt": "坏账核销" "bad_debt": "坏账核销"
}, },
"paymentStatus": {
"pending": "待确认",
"confirmed": "已确认"
},
"actions": { "actions": {
"detail": "详情", "detail": "详情",
"viewBill": "查看账单", "viewBill": "查看账单",
@@ -187,9 +204,11 @@
"settlementAmount": "结算金额", "settlementAmount": "结算金额",
"pays": "应付", "pays": "应付",
"paysShort": "应付", "paysShort": "应付",
"howAmountWorks": "金额怎么来的", "howAmountWorks": "结算明细",
"payerLabel": "付款方",
"payeeLabel": "收款方",
"playerBreakdownIntro": "玩家只与直属代理结算,净额 = 输赢 回水。", "playerBreakdownIntro": "玩家只与直属代理结算,净额 = 输赢 回水。",
"agentBreakdownIntro": "代理只与直属上级结算,净额 = 团队净额 本级占成。", "agentBreakdownIntro": "代理只与直属上级结算,净额 = 团队净额 下级占成 本级占成。",
"playerGross": "游戏输赢", "playerGross": "游戏输赢",
"playerLostHint": "玩家输了,应付代理", "playerLostHint": "玩家输了,应付代理",
"playerWonHint": "玩家赢了,代理应付玩家", "playerWonHint": "玩家赢了,代理应付玩家",
@@ -202,15 +221,19 @@
"teamRebate": "团队回水", "teamRebate": "团队回水",
"teamNet": "团队净额", "teamNet": "团队净额",
"rebate": "回水", "rebate": "回水",
"agentShareKeep": "{{agent}} 本级占成", "agentShareKeep": "本级占成",
"agentShareKeepHint": "本级按占成比例留下的利润", "agentShareKeepHint": "本级按占成比例留下的利润",
"agentNet": "{{agent}} 应付上级", "agentDownlineShare": "下级占成",
"agentNetReceive": "上级应付 {{agent}}", "agentDownlineShareHint": "下级代理按占成比例保留的利润(明细见下行)",
"agentDownlineShareItem": "{{agent}} 保留",
"agentNet": "应付 {{counterparty}}",
"agentNetReceive": "{{counterparty}} 应付本级",
"billOwner": "账单主体", "billOwner": "账单主体",
"billCounterparty": "结算对手", "billCounterparty": "结算对手",
"unpaidPendingConfirm": "确认账单后可登记收付", "unpaidPendingConfirm": "确认账单后可登记收付",
"unpaidAwaitingPayment": "请登记线下收付", "unpaidAwaitingPayment": "请登记线下收付",
"fullySettled": "本期已结清", "fullySettled": "本期已结清",
"confirmHint": "确认后才可以登记收款或付款。",
"recordReceiptFrom": "登记收款({{payer}} 付给 {{payee}}", "recordReceiptFrom": "登记收款({{payer}} 付给 {{payee}}",
"recordPayoutTo": "登记付款({{payer}} 付给 {{payee}}", "recordPayoutTo": "登记付款({{payer}} 付给 {{payee}}",
"rebateAllocationsHint": "各层级代理对回水的承担明细。", "rebateAllocationsHint": "各层级代理对回水的承担明细。",
@@ -293,7 +316,9 @@
"playerUpline": "直属代理的上级", "playerUpline": "直属代理的上级",
"agentUpline": "本单结算上级" "agentUpline": "本单结算上级"
}, },
"hierarchyHint": "同一账期会生成多笔账单:玩家先与直属代理结,代理扣除本级占成后再向上级缴纳。因此「输赢」可能相同,但「结算金额」会逐级减少。" "hierarchyHint": "同一账期会生成多笔账单:玩家先与直属代理结,代理扣除本级占成后再向上级缴纳。因此「输赢」可能相同,但「结算金额」会逐级减少。",
"emptyFiltered": "当前筛选下暂无账单,请改为「全部状态」或重置筛选。",
"emptyClosed": "本期已关账但暂无账单。常见原因:账期内无信用盘玩家的已结算注单,或占成流水不在本账期时间范围内。"
}, },
"panels": { "panels": {
"workbench": { "title": "工作台" }, "workbench": { "title": "工作台" },
@@ -310,10 +335,6 @@
"reports": { "title": "账期报表" }, "reports": { "title": "账期报表" },
"badDebt": { "title": "坏账核销" } "badDebt": { "title": "坏账核销" }
}, },
"billsPanel": {
"emptyFiltered": "当前筛选下暂无账单,请改为「全部状态」或重置筛选。",
"emptyClosed": "本期已关账但暂无账单。常见原因:账期内无信用盘玩家的已结算注单,或占成流水不在本账期时间范围内。"
},
"empty": { "empty": {
"noSite": "请选择站点。", "noSite": "请选择站点。",
"noPeriods": "请先关账当前账期。", "noPeriods": "请先关账当前账期。",

View File

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

View File

@@ -52,9 +52,75 @@ export function formatAdminCalendarToday(locale: AdminApiLocale, weekdayLabel: s
return `${datePart} ${weekdayLabel}`; 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`。 * 将接口返回的 ISO 时间串格式化为浏览器本地时区下的 `YYYY-MM-DD HH:mm:ss`。
* 期号相关列表请使用 {@link formatAdminInstantInTimeZone} 并传入 UTC。
*/ */
export function formatAdminInstant( export function formatAdminInstant(
iso: string | null | undefined, iso: string | null | undefined,

View File

@@ -8,7 +8,6 @@ export const AGENT_OWNER_BASE_SLUGS = [
"prd.agent.role.view", "prd.agent.role.view",
"prd.agent.user.view", "prd.agent.user.view",
"prd.tickets.view", "prd.tickets.view",
"prd.report.view",
"prd.settlement.agent.view", "prd.settlement.agent.view",
] as const; ] as const;
@@ -45,7 +44,6 @@ export const AGENT_PERMISSION_PACKAGES: Record<
{ key: "manage", label: "管理", slugs: ["prd.users.manage"] }, { key: "manage", label: "管理", slugs: ["prd.users.manage"] },
], ],
tickets: [{ key: "view", label: "查看", slugs: ["prd.tickets.view"] }], tickets: [{ key: "view", label: "查看", slugs: ["prd.tickets.view"] }],
reports: [{ key: "view", label: "查看", slugs: ["prd.report.view"] }],
settlement_agent: [ settlement_agent: [
{ key: "view", label: "账单·查看", slugs: ["prd.settlement.agent.view"] }, { key: "view", label: "账单·查看", slugs: ["prd.settlement.agent.view"] },
{ key: "manage", label: "账单·管理", slugs: ["prd.settlement.agent.manage"] }, { 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"; function pad2(n: number): string {
return String(n).padStart(2, "0");
/** `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 startOfDay(date: Date): Date { /** 本地日历 `YYYY-MM-DD` */
const d = new Date(date); export function toDateYmdValue(date: Date): string {
d.setHours(0, 0, 0, 0); return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
return d;
} }
function endOfDay(date: Date): Date { function parseDateYmd(value: string): Date | null {
const d = new Date(date); const match = DATE_YMD_RE.exec(value.trim());
d.setHours(23, 59, 0, 0); if (!match) {
return null;
return d; }
const [, y, mo, d] = match;
const date = new Date(Number(y), Number(mo) - 1, Number(d));
return Number.isNaN(date.getTime()) ? null : date;
} }
/** 周一为一周起始(与产品文档「周结」一致) */ function formatUtcDateTimeFromMs(ms: number): string {
function startOfWeekMonday(date: Date): Date { const date = new Date(ms);
const d = startOfDay(date); return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())} ${pad2(date.getUTCHours())}:${pad2(date.getUTCMinutes())}:${pad2(date.getUTCSeconds())}`;
const day = d.getDay();
const diff = day === 0 ? -6 : 1 - day;
d.setDate(d.getDate() + diff);
return d;
} }
function addDays(date: Date, days: number): Date { /** 本地自然日 00:00:00 → UTC `YYYY-MM-DD HH:mm:ss`(开账 API */
const d = new Date(date); export function localDateYmdToUtcPeriodStart(ymd: string): string {
d.setDate(d.getDate() + days); 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 { /** 本地自然日 23:59:59 → UTC `YYYY-MM-DD HH:mm:ss`(开账 API */
const d = startOfDay(date); export function localDateYmdToUtcPeriodEnd(ymd: string): string {
d.setDate(1); 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 { export function localDateRangeToUtcPeriodBounds(
const d = startOfDay(date); startYmd: string,
d.setMonth(d.getMonth() + 1); endYmd: string,
d.setDate(0);
return endOfDay(d);
}
export function settlementPeriodPresetRange(
key: SettlementPeriodPresetKey,
now: Date = new Date(),
): { period_start: string; period_end: string } { ): { period_start: string; period_end: string } {
switch (key) {
case "this_week": {
const start = startOfWeekMonday(now);
const end = endOfDay(addDays(start, 6));
return { return {
period_start: toDateTimeLocalValue(start), period_start: localDateYmdToUtcPeriodStart(startYmd),
period_end: toDateTimeLocalValue(end), period_end: localDateYmdToUtcPeriodEnd(endYmd),
}; };
}
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),
};
}
}
} }
/** 按代理结算周期推荐默认快捷开期(周结优先 */ /** UTC 账期时刻 → 本地日历 `YYYY-MM-DD`(列表展示 */
export function defaultSettlementPeriodPreset( export function utcPeriodInstantToLocalYmd(iso: string | null | undefined): string {
cycle: AgentSettlementCycle, if (iso == null || iso === "") {
): SettlementPeriodPresetKey { return "—";
if (cycle === "monthly") {
return "this_month";
} }
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( export function formatSettlementPeriodSpan(
periodStart: string | undefined, periodStart: string | undefined,
periodEnd: string | undefined, periodEnd: string | undefined,
): string { ): string {
const start = periodStart?.slice(0, 10) ?? "—"; const start = utcPeriodInstantToLocalYmd(periodStart);
const end = periodEnd?.slice(0, 10) ?? "—"; const end = utcPeriodInstantToLocalYmd(periodEnd);
return `${start} ~ ${end}`; 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); const minor = Math.round(n * factor);
return Number.isSafeInteger(minor) ? minor : null; 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"; "use client";
import Link from "next/link"; import { Eye, RefreshCw, Search } from "lucide-react";
import { RefreshCw, Search } from "lucide-react";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -9,11 +8,10 @@ import { getAgentNodes } from "@/api/admin-agents";
import { AdminPageCard } from "@/components/admin/admin-page-card"; import { AdminPageCard } from "@/components/admin/admin-page-card";
import { AdminTableLoadingRow } from "@/components/admin/admin-loading-state"; import { AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import { AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-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 { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { Button, buttonVariants } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -32,7 +30,6 @@ import {
import { useAsyncEffect } from "@/hooks/use-async-effect"; import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useTranslationRef } from "@/hooks/use-translation-ref"; import { useTranslationRef } from "@/hooks/use-translation-ref";
import type { AgentNodeRow } from "@/types/api/admin-agent"; import type { AgentNodeRow } from "@/types/api/admin-agent";
import { cn } from "@/lib/utils";
function formatPercent(value: number | null | undefined): string { function formatPercent(value: number | null | undefined): string {
if (value == null || Number.isNaN(value)) { if (value == null || Number.isNaN(value)) {
@@ -56,6 +53,22 @@ function statusLabel(status: number, t: (key: string, options?: { defaultValue?:
: t("statusDisabled", { 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 { export function AgentsDirectoryConsole(): React.ReactElement {
const { t } = useTranslation(["agents", "common"]); const { t } = useTranslation(["agents", "common"]);
const tRef = useTranslationRef(["agents", "common"]); const tRef = useTranslationRef(["agents", "common"]);
@@ -64,8 +77,7 @@ export function AgentsDirectoryConsole(): React.ReactElement {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
const [keyword, setKeyword] = useState(""); const [keyword, setKeyword] = useState("");
const [status, setStatus] = useState<"all" | "enabled" | "disabled">("all"); const [status, setStatus] = useState<DirectoryStatusFilter>("all");
const [includeRoots, setIncludeRoots] = useState(false);
const [reloadKey, setReloadKey] = useState(0); const [reloadKey, setReloadKey] = useState(0);
const parentNameMap = useMemo( const parentNameMap = useMemo(
@@ -96,9 +108,6 @@ export function AgentsDirectoryConsole(): React.ReactElement {
const normalized = keyword.trim().toLowerCase(); const normalized = keyword.trim().toLowerCase();
return items.filter((item) => { return items.filter((item) => {
if (!includeRoots && item.is_root) {
return false;
}
if (status === "enabled" && item.status !== 1) { if (status === "enabled" && item.status !== 1) {
return false; return false;
} }
@@ -115,7 +124,7 @@ export function AgentsDirectoryConsole(): React.ReactElement {
.toLowerCase() .toLowerCase()
.includes(normalized); .includes(normalized);
}); });
}, [includeRoots, items, keyword, parentNameMap, status]); }, [items, keyword, parentNameMap, status]);
const totalOperatingAgents = useMemo( const totalOperatingAgents = useMemo(
() => items.filter((item) => !item.is_root).length, () => items.filter((item) => !item.is_root).length,
@@ -175,29 +184,21 @@ export function AgentsDirectoryConsole(): React.ReactElement {
/> />
</div> </div>
<div className="flex flex-wrap items-center gap-3"> <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]"> <SelectTrigger className="h-9 w-[150px]">
<SelectValue /> <SelectValue>{() => directoryStatusLabel(status, t)}</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all"> {(["all", "enabled", "disabled"] as DirectoryStatusFilter[]).map((value) => (
{t("directoryStatus.all", { defaultValue: "全部状态" })} <SelectItem key={value} value={value}>
</SelectItem> {directoryStatusLabel(value, t)}
<SelectItem value="enabled">
{t("directoryStatus.enabled", { defaultValue: "仅启用" })}
</SelectItem>
<SelectItem value="disabled">
{t("directoryStatus.disabled", { defaultValue: "仅停用" })}
</SelectItem> </SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </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>
</div> </div>
@@ -229,8 +230,8 @@ export function AgentsDirectoryConsole(): React.ReactElement {
<TableHead className="w-[130px] text-right"> <TableHead className="w-[130px] text-right">
{t("lineUi.availableCredit", { defaultValue: "可下发" })} {t("lineUi.availableCredit", { defaultValue: "可下发" })}
</TableHead> </TableHead>
<TableHead className="w-[110px] text-right"> <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:actions.title", { defaultValue: "操作" })} {t("common:table.actions", { defaultValue: "操作" })}
</TableHead> </TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -287,13 +288,17 @@ export function AgentsDirectoryConsole(): React.ReactElement {
<TableCell className="text-right"> <TableCell className="text-right">
<span className="tabular-nums">{formatCredit(profile?.available_credit)}</span> <span className="tabular-nums">{formatCredit(profile?.available_credit)}</span>
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
<Link <AdminRowActionsMenu
href={`/admin/agents?agent_node_id=${item.id}`} actions={[
className={cn(buttonVariants({ variant: "ghost", size: "sm" }))} {
> key: "view",
{t("common:actions.view", { defaultValue: "查看" })} label: t("common:actions.viewDetails", { defaultValue: "查看详情" }),
</Link> icon: Eye,
href: `/admin/agents?agent_node_id=${item.id}`,
},
]}
/>
</TableCell> </TableCell>
</TableRow> </TableRow>
); );

View File

@@ -56,7 +56,7 @@ import { useConfirmAction } from "@/hooks/use-confirm-action";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"; import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { PlayerFundingModeBadge } from "@/components/admin/player-funding-badges"; import { PlayerFundingModeBadge } from "@/components/admin/player-funding-badges";
import { formatPlayerCreditAmount, playerBalanceCells } from "@/lib/admin-player-display"; 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 { parsePercentUi, percentValueToUi } from "@/lib/admin-rate-percent";
import { adminPlayerDetailPath } from "@/lib/admin-player-paths"; import { adminPlayerDetailPath } from "@/lib/admin-player-paths";
import { adminHasAnyPermission } from "@/lib/admin-permissions"; import { adminHasAnyPermission } from "@/lib/admin-permissions";
@@ -503,6 +503,8 @@ export function AgentsPlayersPanel({
() => billingBills.find((bill) => bill.id === selectedBillId) ?? null, () => billingBills.find((bill) => bill.id === selectedBillId) ?? null,
[billingBills, selectedBillId], [billingBills, selectedBillId],
); );
const billingCurrency =
selectedBill?.currency_code ?? billingPlayer?.default_currency ?? "NPR";
const projectedCreditLimit = useMemo(() => { const projectedCreditLimit = useMemo(() => {
const delta = editCreditDelta.trim() === "" ? 0 : Number.parseInt(editCreditDelta, 10); const delta = editCreditDelta.trim() === "" ? 0 : Number.parseInt(editCreditDelta, 10);
if (Number.isNaN(delta) || delta <= 0) { if (Number.isNaN(delta) || delta <= 0) {
@@ -540,7 +542,9 @@ export function AgentsPlayersPanel({
setBillingBills(items); setBillingBills(items);
const first = items[0] ?? null; const first = items[0] ?? null;
setSelectedBillId(first?.id ?? 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) { } catch (e) {
toast.error( toast.error(
e instanceof LotteryApiBizError e instanceof LotteryApiBizError
@@ -576,7 +580,11 @@ export function AgentsPlayersPanel({
async function handlePayBill(): Promise<void> { async function handlePayBill(): Promise<void> {
if (selectedBill === null) return; 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)) { if (amount === null || amount <= 0 || amount > Number(selectedBill.unpaid_amount ?? 0)) {
toast.error(t("playersPanel.paymentAmountInvalid", { defaultValue: "请输入有效的收付金额" })); toast.error(t("playersPanel.paymentAmountInvalid", { defaultValue: "请输入有效的收付金额" }));
return; 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 { function requestConfirmBillAction(): void {
if (selectedBill === null) return; if (selectedBill === null) return;
requestConfirm({ requestConfirm({
@@ -655,9 +655,13 @@ export function AgentsPlayersPanel({
function requestPayBillAction(): void { function requestPayBillAction(): void {
if (selectedBill === null) return; 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) { if (amount === null || amount <= 0) {
toast.error(t("playersPanel.paymentAmountInvalid", { defaultValue: "请输入大于 0 的整数金额" })); toast.error(t("playersPanel.paymentAmountInvalid", { defaultValue: "请输入大于 0 的有效金额" }));
return; return;
} }
if (amount > Number(selectedBill.unpaid_amount ?? 0)) { if (amount > Number(selectedBill.unpaid_amount ?? 0)) {
@@ -1044,7 +1048,11 @@ export function AgentsPlayersPanel({
onValueChange={(value) => { onValueChange={(value) => {
const next = billingBills.find((bill) => bill.id === Number(value)) ?? null; const next = billingBills.find((bill) => bill.id === Number(value)) ?? null;
setSelectedBillId(next?.id ?? null); setSelectedBillId(next?.id ?? null);
setPayAmount(next ? String(next.unpaid_amount ?? 0) : ""); setPayAmount(
next
? formatAdminMinorDecimal(next.unpaid_amount ?? 0, billingCurrency)
: "",
);
setPayMethod(""); setPayMethod("");
setPayProof(""); setPayProof("");
setBadDebtReason(""); setBadDebtReason("");
@@ -1056,7 +1064,7 @@ export function AgentsPlayersPanel({
<SelectContent> <SelectContent>
{billingBills.map((bill) => ( {billingBills.map((bill) => (
<SelectItem key={bill.id} value={String(bill.id)}> <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> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -1076,7 +1084,7 @@ export function AgentsPlayersPanel({
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{t("playersPanel.billUnpaid", { defaultValue: "未结" })}: {t("playersPanel.billUnpaid", { defaultValue: "未结" })}:
</span>{" "} </span>{" "}
{selectedBill.unpaid_amount ?? 0} {formatAdminMinorUnits(selectedBill.unpaid_amount ?? 0, billingCurrency)}
</div> </div>
</div> </div>
@@ -1090,7 +1098,15 @@ export function AgentsPlayersPanel({
<div className="space-y-3"> <div className="space-y-3">
<div className="space-y-1"> <div className="space-y-1">
<Label>{t("agents:settlementBills.paymentAmount", { defaultValue: "收付金额" })}</Label> <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>
<div className="space-y-1"> <div className="space-y-1">
<Label>{t("agents:settlementBills.paymentMethod", { defaultValue: "收付方式" })}</Label> <Label>{t("agents:settlementBills.paymentMethod", { defaultValue: "收付方式" })}</Label>
@@ -1116,6 +1132,8 @@ export function AgentsPlayersPanel({
{t("agents:settlementBills.paid", { defaultValue: "登记收付" })} {t("agents:settlementBills.paid", { defaultValue: "登记收付" })}
</Button> </Button>
{boundAgent === null ? (
<>
<div className="space-y-1 pt-2"> <div className="space-y-1 pt-2">
<Label>{t("agents:settlementBills.badDebtReason", { defaultValue: "核销原因" })}</Label> <Label>{t("agents:settlementBills.badDebtReason", { defaultValue: "核销原因" })}</Label>
<Input <Input
@@ -1129,6 +1147,8 @@ export function AgentsPlayersPanel({
<Button type="button" variant="destructive" className="w-full" disabled={billingBusy || confirmBusy} onClick={requestWriteOffBillAction}> <Button type="button" variant="destructive" className="w-full" disabled={billingBusy || confirmBusy} onClick={requestWriteOffBillAction}>
{t("agents:settlementBills.confirmBadDebt", { defaultValue: "确认核销" })} {t("agents:settlementBills.confirmBadDebt", { defaultValue: "确认核销" })}
</Button> </Button>
</>
) : null}
</div> </div>
) : null} ) : null}
</div> </div>

View File

@@ -43,6 +43,7 @@ import { DashboardAnalyticsPanel } from "@/modules/dashboard/dashboard-analytics
import { import {
formatDashboardCreditMajor, formatDashboardCreditMajor,
formatDashboardMoneyMinor, formatDashboardMoneyMinor,
formatDashboardSignedMoneyMinor,
} from "@/modules/dashboard/use-dashboard-analytics"; } from "@/modules/dashboard/use-dashboard-analytics";
import type { AdminDashboardAgentOverview } from "@/types/api/admin-dashboard"; import type { AdminDashboardAgentOverview } from "@/types/api/admin-dashboard";
import type { DrawCurrentSnapshot } from "@/types/api/public-draw"; import type { DrawCurrentSnapshot } from "@/types/api/public-draw";
@@ -229,9 +230,9 @@ export function AgentDashboardConsole(): ReactElement {
</p> </p>
</div> </div>
<div className="rounded-2xl border border-white/10 bg-white/8 p-4 backdrop-blur"> <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"> <p className="mt-2 text-2xl font-semibold tabular-nums">
{formatDashboardMoneyMinor(overview.today_profit_minor, displayCurrency)} {formatDashboardSignedMoneyMinor(overview.today_profit_minor, displayCurrency)}
</p> </p>
</div> </div>
</div> </div>
@@ -316,8 +317,8 @@ export function AgentDashboardConsole(): ReactElement {
})} })}
</p> </p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t("agent.sevenDayProfit", { {t("agent.sevenDayShareProfit", {
amount: formatDashboardMoneyMinor(overview.seven_day_profit_minor, displayCurrency), amount: formatDashboardSignedMoneyMinor(overview.seven_day_profit_minor, displayCurrency),
})} })}
</p> </p>
</CardContent> </CardContent>
@@ -384,9 +385,9 @@ export function AgentDashboardConsole(): ReactElement {
{formatDashboardMoneyMinor(overview.top_agent_today.total_bet_minor, displayCurrency)} {formatDashboardMoneyMinor(overview.top_agent_today.total_bet_minor, displayCurrency)}
</p> </p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t("agent.topMomentumHint", { {t("agent.topMomentumPayout", {
profit: formatDashboardMoneyMinor( amount: formatDashboardMoneyMinor(
overview.top_agent_today.approx_house_gross_minor, overview.top_agent_today.total_payout_minor,
displayCurrency, displayCurrency,
), ),
})} })}

View File

@@ -90,6 +90,7 @@ export function DashboardAnalyticsMain({ analytics }: { analytics: DashboardAnal
sparklines, sparklines,
formatMoney, formatMoney,
formatSignedMoney, formatSignedMoney,
profitScope,
} = analytics; } = analytics;
if (!enabled) { if (!enabled) {
@@ -234,10 +235,16 @@ export function DashboardAnalyticsMain({ analytics }: { analytics: DashboardAnal
} }
/> />
<DashboardKpiCard <DashboardKpiCard
label={t("analytics.summaryProfit")} label={
profitScope === "share_profit"
? t("analytics.summaryShareProfit")
: t("analytics.summaryProfit")
}
value={formatSignedMoney(summary.approx_house_gross_minor, currency)} value={formatSignedMoney(summary.approx_house_gross_minor, currency)}
hint={ hint={
summary.total_bet_minor > 0 profitScope === "share_profit"
? t("analytics.shareProfitHint")
: summary.total_bet_minor > 0
? t("marginRate", { ? t("marginRate", {
rate: ((summary.approx_house_gross_minor / summary.total_bet_minor) * 100).toFixed(1), rate: ((summary.approx_house_gross_minor / summary.total_bet_minor) * 100).toFixed(1),
}) })

View File

@@ -9,9 +9,7 @@ import { buttonVariants } from "@/components/ui/button";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state"; import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { formatAdminInstantInTimeZone } from "@/lib/admin-datetime"; import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { getAdminRequestLocale } from "@/lib/admin-locale";
import { LOTTERY_SCHEDULE_TIMEZONE } from "@/lib/lottery-schedule-timezone";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { DrawCurrentSnapshot } from "@/types/api/public-draw"; import type { DrawCurrentSnapshot } from "@/types/api/public-draw";
@@ -64,11 +62,7 @@ export function DashboardCurrentDrawCard({
loading = false, loading = false,
}: DashboardCurrentDrawCardProps): ReactElement { }: DashboardCurrentDrawCardProps): ReactElement {
const { t } = useTranslation(["dashboard", "draws"]); const { t } = useTranslation(["dashboard", "draws"]);
const formatDt = (iso: string | null | undefined): string => const formatDt = useAdminDateTimeFormatter();
formatAdminInstantInTimeZone(iso, {
locale: getAdminRequestLocale(),
timeZone: LOTTERY_SCHEDULE_TIMEZONE,
});
if (loading) { if (loading) {
return ( return (

View File

@@ -140,6 +140,7 @@ export function useDashboardAnalytics({
const currency = data?.currency_code ?? null; const currency = data?.currency_code ?? null;
const summary = data?.summary; const summary = data?.summary;
const profitScope = data?.profit_scope ?? "house_gross";
const periodRangeLabel = useMemo(() => { const periodRangeLabel = useMemo(() => {
if (!data) { if (!data) {
@@ -230,6 +231,7 @@ export function useDashboardAnalytics({
data, data,
currency, currency,
summary, summary,
profitScope,
periodRangeLabel, periodRangeLabel,
playFilterLabel, playFilterLabel,
playOptions, playOptions,

View File

@@ -17,6 +17,10 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import {
adminLocalScheduleValueToTimezoneNaive,
getAdminBrowserTimeZoneLabel,
} from "@/lib/admin-datetime";
import { LOTTERY_SCHEDULE_TIMEZONE } from "@/lib/lottery-schedule-timezone"; import { LOTTERY_SCHEDULE_TIMEZONE } from "@/lib/lottery-schedule-timezone";
import { LotteryApiBizError } from "@/types/api/errors"; import { LotteryApiBizError } from "@/types/api/errors";
@@ -61,10 +65,15 @@ export function DrawCreateDialog({
} }
setSaving(true); setSaving(true);
try { try {
const scheduleTz = scheduleTimezone ?? LOTTERY_SCHEDULE_TIMEZONE;
await postAdminCreateDraw({ await postAdminCreateDraw({
draw_time: form.drawTime.trim(), draw_time: adminLocalScheduleValueToTimezoneNaive(form.drawTime.trim(), scheduleTz),
close_time: form.closeTime.trim() || undefined, close_time: form.closeTime.trim()
start_time: form.startTime.trim() || undefined, ? adminLocalScheduleValueToTimezoneNaive(form.closeTime.trim(), scheduleTz)
: undefined,
start_time: form.startTime.trim()
? adminLocalScheduleValueToTimezoneNaive(form.startTime.trim(), scheduleTz)
: undefined,
draw_no: form.drawNo.trim() || undefined, draw_no: form.drawNo.trim() || undefined,
}); });
toast.success(t("createDraw.success")); toast.success(t("createDraw.success"));
@@ -84,7 +93,7 @@ export function DrawCreateDialog({
<DialogHeader> <DialogHeader>
<DialogTitle>{t("createDraw.title")}</DialogTitle> <DialogTitle>{t("createDraw.title")}</DialogTitle>
<DialogDescription> <DialogDescription>
{t("createDraw.description", { tz: scheduleTimezone ?? LOTTERY_SCHEDULE_TIMEZONE })} {t("createDraw.description", { tz: getAdminBrowserTimeZoneLabel() })}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>

View File

@@ -20,9 +20,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state"; import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { AdminLoadingState } from "@/components/admin/admin-loading-state"; import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { formatAdminInstantInTimeZone } from "@/lib/admin-datetime"; import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { getAdminRequestLocale } from "@/lib/admin-locale";
import { LOTTERY_SCHEDULE_TIMEZONE } from "@/lib/lottery-schedule-timezone";
import { useConfirmAction } from "@/hooks/use-confirm-action"; import { useConfirmAction } from "@/hooks/use-confirm-action";
import { LotteryApiBizError } from "@/types/api/errors"; import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminDrawFinanceSummaryData } from "@/types/api/admin-draw-finance"; import type { AdminDrawFinanceSummaryData } from "@/types/api/admin-draw-finance";
@@ -48,14 +46,7 @@ type ScheduleStep = {
}; };
function ScheduleTimeline({ steps }: { steps: ScheduleStep[] }) { function ScheduleTimeline({ steps }: { steps: ScheduleStep[] }) {
const formatDt = useCallback( const formatDt = useAdminDateTimeFormatter();
(iso: string | null | undefined) =>
formatAdminInstantInTimeZone(iso, {
locale: getAdminRequestLocale(),
timeZone: LOTTERY_SCHEDULE_TIMEZONE,
}),
[],
);
return ( return (
<ol className="grid gap-3 sm:grid-cols-3"> <ol className="grid gap-3 sm:grid-cols-3">

View File

@@ -17,8 +17,11 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { formatAdminInstantInTimeZone } from "@/lib/admin-datetime"; import {
import { getAdminRequestLocale } from "@/lib/admin-locale"; adminLocalScheduleValueToTimezoneNaive,
getAdminBrowserTimeZoneLabel,
isoToAdminLocalScheduleValue,
} from "@/lib/admin-datetime";
import { LOTTERY_SCHEDULE_TIMEZONE } from "@/lib/lottery-schedule-timezone"; import { LOTTERY_SCHEDULE_TIMEZONE } from "@/lib/lottery-schedule-timezone";
import { LotteryApiBizError } from "@/types/api/errors"; import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminDrawListItem } from "@/types/api/admin-draws"; import type { AdminDrawListItem } from "@/types/api/admin-draws";
@@ -31,13 +34,6 @@ type DrawEditDialogProps = {
onSaved: () => void | Promise<void>; onSaved: () => void | Promise<void>;
}; };
function isoToScheduleValue(iso: string | null, timeZone: string): string {
return formatAdminInstantInTimeZone(iso, {
locale: getAdminRequestLocale(),
timeZone,
});
}
export function DrawEditDialog({ export function DrawEditDialog({
open, open,
onOpenChange, onOpenChange,
@@ -57,13 +53,12 @@ export function DrawEditDialog({
if (!open || draw == null) { if (!open || draw == null) {
return; return;
} }
const tz = scheduleTimezone ?? LOTTERY_SCHEDULE_TIMEZONE; setDrawTime(isoToAdminLocalScheduleValue(draw.draw_time));
setDrawTime(isoToScheduleValue(draw.draw_time, tz)); setCloseTime(isoToAdminLocalScheduleValue(draw.close_time));
setCloseTime(isoToScheduleValue(draw.close_time, tz)); setStartTime(isoToAdminLocalScheduleValue(draw.start_time));
setStartTime(isoToScheduleValue(draw.start_time, tz));
setDrawNo(draw.draw_no); setDrawNo(draw.draw_no);
}); });
}, [open, draw, scheduleTimezone]); }, [open, draw]);
async function submit(): Promise<void> { async function submit(): Promise<void> {
if (draw == null) { if (draw == null) {
@@ -75,10 +70,15 @@ export function DrawEditDialog({
} }
setSaving(true); setSaving(true);
try { try {
const scheduleTz = scheduleTimezone ?? LOTTERY_SCHEDULE_TIMEZONE;
await putAdminUpdateDraw(draw.id, { await putAdminUpdateDraw(draw.id, {
draw_time: drawTime.trim(), draw_time: adminLocalScheduleValueToTimezoneNaive(drawTime.trim(), scheduleTz),
close_time: closeTime.trim() || undefined, close_time: closeTime.trim()
start_time: startTime.trim() || undefined, ? adminLocalScheduleValueToTimezoneNaive(closeTime.trim(), scheduleTz)
: undefined,
start_time: startTime.trim()
? adminLocalScheduleValueToTimezoneNaive(startTime.trim(), scheduleTz)
: undefined,
draw_no: drawNo.trim() || undefined, draw_no: drawNo.trim() || undefined,
}); });
toast.success(t("editDraw.success")); toast.success(t("editDraw.success"));
@@ -98,7 +98,7 @@ export function DrawEditDialog({
<DialogTitle>{t("editDraw.title")}</DialogTitle> <DialogTitle>{t("editDraw.title")}</DialogTitle>
<DialogDescription> <DialogDescription>
{t("editDraw.description", { {t("editDraw.description", {
tz: scheduleTimezone ?? LOTTERY_SCHEDULE_TIMEZONE, tz: getAdminBrowserTimeZoneLabel(),
drawNo: draw?.draw_no ?? "", drawNo: draw?.draw_no ?? "",
})} })}
</DialogDescription> </DialogDescription>

View File

@@ -14,8 +14,7 @@ import {
postAdminCancelDraw, postAdminCancelDraw,
postAdminGenerateDrawPlan, postAdminGenerateDrawPlan,
} from "@/api/admin-draws"; } from "@/api/admin-draws";
import { formatAdminInstantInTimeZone } from "@/lib/admin-datetime"; import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { getAdminRequestLocale } from "@/lib/admin-locale";
import { LOTTERY_SCHEDULE_TIMEZONE } from "@/lib/lottery-schedule-timezone"; import { LOTTERY_SCHEDULE_TIMEZONE } from "@/lib/lottery-schedule-timezone";
import { AdminTableLoadingRow } from "@/components/admin/admin-loading-state"; import { AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import { AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state"; import { AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
@@ -99,14 +98,7 @@ export function DrawsIndexConsole() {
const canViewFinance = canViewDrawFinance(profile?.permissions); const canViewFinance = canViewDrawFinance(profile?.permissions);
const { request: requestConfirm, ConfirmDialog } = useConfirmAction(); const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
const [data, setData] = useState<AdminDrawListData | null>(null); const [data, setData] = useState<AdminDrawListData | null>(null);
const formatDt = useCallback( const formatDt = useAdminDateTimeFormatter();
(iso: string | null | undefined) =>
formatAdminInstantInTimeZone(iso, {
locale: getAdminRequestLocale(),
timeZone: LOTTERY_SCHEDULE_TIMEZONE,
}),
[],
);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [draftDrawNo, setDraftDrawNo] = useState(""); const [draftDrawNo, setDraftDrawNo] = useState("");

View File

@@ -14,15 +14,17 @@ import {
type RebateAllocationRow, type RebateAllocationRow,
type SettlementBillRow, type SettlementBillRow,
type SettlementBillPaymentRow, type SettlementBillPaymentRow,
type DownlineShareBreakdown,
} from "@/api/admin-agent-settlement"; } from "@/api/admin-agent-settlement";
import { AdminLoadingState } from "@/components/admin/admin-loading-state"; import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { formatAdminMinorDecimal, parseAdminMajorToMinor, parseSignedAdminMajorToMinor } from "@/lib/money";
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics"; import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
import { import {
SettlementBillAmountBreakdown, SettlementBillAmountBreakdown,
SettlementBillPartiesRow,
SettlementBillSummaryHeader, SettlementBillSummaryHeader,
} from "@/modules/settlement/settlement-bill-breakdown"; } from "@/modules/settlement/settlement-bill-breakdown";
import { describeBillPaymentDirection } from "@/modules/settlement/settlement-bill-display"; import { describeBillPaymentDirection } from "@/modules/settlement/settlement-bill-display";
import { settlementBillOperableByBoundAgent } from "@/modules/settlement/settlement-bill-operable";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -33,6 +35,8 @@ type AgentBillDetailProps = {
billId: number; billId: number;
currencyCode: string; currencyCode: string;
canManage?: boolean; canManage?: boolean;
canFinanceAdjustments?: boolean;
boundAgent?: { id: number } | null;
onUpdated?: () => void; onUpdated?: () => void;
}; };
@@ -40,12 +44,15 @@ export function AgentBillDetail({
billId, billId,
currencyCode, currencyCode,
canManage = true, canManage = true,
canFinanceAdjustments = false,
boundAgent = null,
onUpdated, onUpdated,
}: AgentBillDetailProps): React.ReactElement { }: AgentBillDetailProps): React.ReactElement {
const { t } = useTranslation(["agents", "settlementCenter", "common"]); const { t } = useTranslation(["agents", "settlementCenter", "common"]);
const [bill, setBill] = useState<SettlementBillRow | null>(null); const [bill, setBill] = useState<SettlementBillRow | null>(null);
const [payments, setPayments] = useState<SettlementBillPaymentRow[]>([]); const [payments, setPayments] = useState<SettlementBillPaymentRow[]>([]);
const [rebateAllocations, setRebateAllocations] = useState<RebateAllocationRow[]>([]); const [rebateAllocations, setRebateAllocations] = useState<RebateAllocationRow[]>([]);
const [downlineShares, setDownlineShares] = useState<DownlineShareBreakdown | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [payAmount, setPayAmount] = useState(""); const [payAmount, setPayAmount] = useState("");
const [payMethod, setPayMethod] = useState(""); const [payMethod, setPayMethod] = useState("");
@@ -63,11 +70,12 @@ export function AgentBillDetail({
setBill(data.bill); setBill(data.bill);
setPayments(data.payments ?? []); setPayments(data.payments ?? []);
setRebateAllocations(data.rebate_allocations ?? []); 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 { } finally {
setLoading(false); setLoading(false);
} }
}, [billId]); }, [billId, currencyCode]);
useEffect(() => { useEffect(() => {
void load(); void load();
@@ -78,6 +86,7 @@ export function AgentBillDetail({
} }
const direction = describeBillPaymentDirection(bill, t); const direction = describeBillPaymentDirection(bill, t);
const canOperateBill = canManage && settlementBillOperableByBoundAgent(bill, boundAgent);
const locked = ["confirmed", "partial_paid", "settled", "overdue"].includes(bill.status); const locked = ["confirmed", "partial_paid", "settled", "overdue"].includes(bill.status);
const paymentTitle = direction.ownerOwes const paymentTitle = direction.ownerOwes
? t("settlementBills.submitReceipt", { defaultValue: "登记收款" }) ? t("settlementBills.submitReceipt", { defaultValue: "登记收款" })
@@ -86,7 +95,7 @@ export function AgentBillDetail({
? t("settlementBills.submitReceipt", { defaultValue: "确认收款" }) ? t("settlementBills.submitReceipt", { defaultValue: "确认收款" })
: t("settlementBills.submitPayout", { defaultValue: "确认付款" }); : t("settlementBills.submitPayout", { defaultValue: "确认付款" });
const canWriteOff = const canWriteOff =
canManage && canFinanceAdjustments &&
bill.unpaid_amount > 0 && bill.unpaid_amount > 0 &&
["confirmed", "partial_paid", "overdue"].includes(bill.status) && ["confirmed", "partial_paid", "overdue"].includes(bill.status) &&
!["adjustment", "reversal", "bad_debt"].includes(bill.bill_type); !["adjustment", "reversal", "bad_debt"].includes(bill.bill_type);
@@ -119,14 +128,6 @@ export function AgentBillDetail({
toast.error(err instanceof LotteryApiBizError ? err.message : fallback); 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 => { const requestConfirmBill = (): void => {
requestConfirm({ requestConfirm({
title: t("settlementBills.confirmBillTitle", { defaultValue: "确认账单?" }), title: t("settlementBills.confirmBillTitle", { defaultValue: "确认账单?" }),
@@ -152,9 +153,9 @@ export function AgentBillDetail({
}; };
const requestPayment = (): void => { const requestPayment = (): void => {
const amount = parseWholeAmount(payAmount); const amount = parseAdminMajorToMinor(payAmount, currencyCode);
if (amount === null || amount <= 0) { if (amount === null || amount <= 0) {
toast.error(t("settlementBills.paymentAmountInvalid", { defaultValue: "请输入大于 0 的整数金额" })); toast.error(t("settlementBills.paymentAmountInvalid", { defaultValue: "请输入大于 0 的有效金额" }));
return; return;
} }
if (amount > bill.unpaid_amount) { if (amount > bill.unpaid_amount) {
@@ -220,10 +221,10 @@ export function AgentBillDetail({
}; };
const requestAdjustment = (): void => { const requestAdjustment = (): void => {
const amount = parseWholeAmount(adjustAmount); const amount = parseSignedAdminMajorToMinor(adjustAmount, currencyCode);
const reason = adjustReason.trim(); const reason = adjustReason.trim();
if (amount === null || amount === 0) { if (amount === null || amount === 0) {
toast.error(t("settlementBills.adjustmentAmountInvalid", { defaultValue: "请输入非 0 的整数调整金额" })); toast.error(t("settlementBills.adjustmentAmountInvalid", { defaultValue: "请输入非 0 的有效金额" }));
return; return;
} }
if (!reason) { if (!reason) {
@@ -262,11 +263,13 @@ export function AgentBillDetail({
return ( return (
<> <>
<ConfirmDialog /> <ConfirmDialog />
<div className="grid gap-6 md:grid-cols-[minmax(0,1.35fr)_minmax(340px,0.95fr)]"> <div className="flex flex-col gap-5 text-sm">
<div className="space-y-5 text-sm">
<SettlementBillSummaryHeader bill={bill} currencyCode={currencyCode} /> <SettlementBillSummaryHeader bill={bill} currencyCode={currencyCode} />
<SettlementBillPartiesRow bill={bill} /> <SettlementBillAmountBreakdown
<SettlementBillAmountBreakdown bill={bill} currencyCode={currencyCode} /> bill={bill}
currencyCode={currencyCode}
downlineShares={downlineShares}
/>
{payments.length > 0 ? ( {payments.length > 0 ? (
<div className="space-y-2 rounded-xl border border-border/70 p-4"> <div className="space-y-2 rounded-xl border border-border/70 p-4">
@@ -290,9 +293,7 @@ export function AgentBillDetail({
</ul> </ul>
</div> </div>
) : null} ) : null}
</div>
<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"> <div className="space-y-2 rounded-xl border border-border/70 bg-card p-5 shadow-sm">
<p className="font-semibold tracking-tight"> <p className="font-semibold tracking-tight">
@@ -353,8 +354,8 @@ export function AgentBillDetail({
</div> </div>
) : null} ) : null}
{canManage && bill.status === "pending_confirm" ? ( {canOperateBill && 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-3 rounded-xl border border-primary/20 bg-primary/5 p-5 shadow-sm">
<div className="space-y-1"> <div className="space-y-1">
<p className="font-semibold tracking-tight text-primary"> <p className="font-semibold tracking-tight text-primary">
{t("settlementBills.confirm", { defaultValue: "确认账单" })} {t("settlementBills.confirm", { defaultValue: "确认账单" })}
@@ -367,7 +368,7 @@ export function AgentBillDetail({
</div> </div>
<Button <Button
type="button" type="button"
className="w-full" className="w-full sm:w-auto sm:min-w-[10rem]"
disabled={confirmBusy} disabled={confirmBusy}
onClick={requestConfirmBill} onClick={requestConfirmBill}
> >
@@ -376,7 +377,7 @@ export function AgentBillDetail({
</div> </div>
) : null} ) : null}
{canManage && ["confirmed", "partial_paid", "overdue"].includes(bill.status) && bill.unpaid_amount > 0 ? ( {canOperateBill && ["confirmed", "partial_paid", "overdue"].includes(bill.status) && bill.unpaid_amount > 0 ? (
<div className="space-y-4 rounded-xl border border-border/70 bg-card p-5 shadow-sm"> <div className="space-y-4 rounded-xl border border-border/70 bg-card p-5 shadow-sm">
<div className="space-y-2"> <div className="space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
@@ -398,7 +399,8 @@ export function AgentBillDetail({
<Input <Input
value={payAmount} value={payAmount}
onChange={(e) => setPayAmount(e.target.value)} 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" className="bg-background/50 transition-colors focus:bg-background"
/> />
</div> </div>
@@ -471,7 +473,7 @@ export function AgentBillDetail({
</div> </div>
) : null} ) : 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-4 rounded-xl border border-dashed border-border/70 bg-card p-5 shadow-sm">
<div className="space-y-1"> <div className="space-y-1">
<p className="font-semibold tracking-tight"> <p className="font-semibold tracking-tight">
@@ -488,9 +490,9 @@ export function AgentBillDetail({
<Input <Input
value={adjustAmount} value={adjustAmount}
onChange={(e) => setAdjustAmount(e.target.value)} onChange={(e) => setAdjustAmount(e.target.value)}
type="number" inputMode="decimal"
placeholder={t("settlementBills.adjustmentAmountPlaceholder", { placeholder={t("settlementBills.adjustmentAmountPlaceholder", {
defaultValue: "输入正数或负数", defaultValue: "例如35.20 或 -10.00",
})} })}
className="bg-background/50 transition-colors focus:bg-background" className="bg-background/50 transition-colors focus:bg-background"
/> />
@@ -518,7 +520,6 @@ export function AgentBillDetail({
</div> </div>
) : null} ) : null}
</div> </div>
</div>
</> </>
); );
} }

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
@@ -18,8 +18,9 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { useAdminProfile } from "@/stores/admin-session";
const REPORT_TYPES: AgentSettlementReportType[] = [ const ALL_REPORT_TYPES: AgentSettlementReportType[] = [
"summary", "summary",
"player_win_loss", "player_win_loss",
"agent_share", "agent_share",
@@ -43,6 +44,14 @@ export function AgentSettlementReportsPanel({
currencyCode, currencyCode,
}: AgentSettlementReportsPanelProps): React.ReactElement { }: AgentSettlementReportsPanelProps): React.ReactElement {
const { t } = useTranslation(["agents", "common"]); 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 [reportType, setReportType] = useState<AgentSettlementReportType>("summary");
const [response, setResponse] = useState<AgentSettlementReportResponse | null>(null); const [response, setResponse] = useState<AgentSettlementReportResponse | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -82,7 +91,7 @@ export function AgentSettlementReportsPanel({
<SelectValue>{() => reportTypeLabel(reportType)}</SelectValue> <SelectValue>{() => reportTypeLabel(reportType)}</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{REPORT_TYPES.map((key) => ( {reportTypes.map((key) => (
<SelectItem key={key} value={key}> <SelectItem key={key} value={key}>
{reportTypeLabel(key)} {reportTypeLabel(key)}
</SelectItem> </SelectItem>

View File

@@ -8,6 +8,7 @@ import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter"; import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { formatSettlementPeriodSpan } from "@/lib/agent-settlement-period-range"; import { formatSettlementPeriodSpan } from "@/lib/agent-settlement-period-range";
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics"; import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
import { settlementAdjustmentTypeLabel } from "@/modules/settlement/settlement-status-label";
import { import {
Table, Table,
TableBody, TableBody,
@@ -62,9 +63,7 @@ export function SettlementAdjustmentsTable({
{formatSettlementPeriodSpan(row.period_start, row.period_end)} {formatSettlementPeriodSpan(row.period_start, row.period_end)}
</TableCell> </TableCell>
<TableCell> <TableCell>
{t(`adjustmentType.${row.adjustment_type}`, { {settlementAdjustmentTypeLabel(row.adjustment_type, t)}
defaultValue: row.adjustment_type,
})}
</TableCell> </TableCell>
<TableCell className="tabular-nums"> <TableCell className="tabular-nums">
{row.original_bill_id != null ? `#${row.original_bill_id}` : "—"} {row.original_bill_id != null ? `#${row.original_bill_id}` : "—"}

View File

@@ -10,8 +10,13 @@ import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-ana
import { import {
buildBillAmountBreakdown, buildBillAmountBreakdown,
describeBillPaymentDirection, describeBillPaymentDirection,
resolveBillPartyName, type BillBreakdownLine,
type DownlineShareBreakdown,
} from "@/modules/settlement/settlement-bill-display"; } from "@/modules/settlement/settlement-bill-display";
import {
formatSignedSettlementMoney,
signedSettlementMoneyClass,
} from "@/modules/settlement/settlement-signed-money";
import { import {
settlementBillStatusLabel, settlementBillStatusLabel,
settlementBillTypeLabel, settlementBillTypeLabel,
@@ -41,26 +46,32 @@ export function SettlementBillSummaryHeader({
</span> </span>
</div> </div>
<div className="flex flex-wrap items-center gap-2 text-base"> <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> <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"> <ArrowRight className="size-4 shrink-0 text-muted-foreground" aria-hidden />
{t("settlementCenter:billDisplay.pays", { defaultValue: "应付" })} <span className="text-muted-foreground">
<ArrowRight className="size-3.5" aria-hidden /> {t("settlementCenter:billDisplay.payeeLabel", { defaultValue: "收款方" })}
</span> </span>
<span className="font-semibold text-foreground">{direction.payee}</span> <span className="font-semibold text-foreground">{direction.payee}</span>
</div> </div>
<div> <div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t("settlementCenter:billDisplay.settlementAmount", { defaultValue: "本期结算金额" })} {t("settlementCenter:billDisplay.settlementAmount", { defaultValue: "结算金额" })}
</p> </p>
<p className="mt-1 text-2xl font-bold tabular-nums tracking-tight text-foreground"> <p className="mt-0.5 text-2xl font-bold tabular-nums tracking-tight text-foreground">
{formatDashboardMoneyMinor(direction.amount, currencyCode)} {formatDashboardMoneyMinor(direction.amount, currencyCode)}
</p> </p>
</div> </div>
</div>
<div className="grid gap-2 sm:grid-cols-2"> <div className="grid w-full gap-2 sm:grid-cols-2 lg:w-auto lg:min-w-[15rem]">
<div className="rounded-xl border border-border/50 bg-background/50 px-4 py-3"> <div className="rounded-lg border border-border/50 bg-background/50 px-4 py-3">
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t("settlementCenter:columns.paid", { defaultValue: "已收付" })} {t("settlementCenter:columns.paid", { defaultValue: "已收付" })}
</p> </p>
@@ -70,7 +81,7 @@ export function SettlementBillSummaryHeader({
</div> </div>
<div <div
className={cn( className={cn(
"rounded-xl border px-4 py-3", "rounded-lg border px-4 py-3",
unpaid unpaid
? "border-amber-200/80 bg-amber-50/80 dark:border-amber-900/50 dark:bg-amber-950/20" ? "border-amber-200/80 bg-amber-50/80 dark:border-amber-900/50 dark:bg-amber-950/20"
: "border-border/50 bg-background/50", : "border-border/50 bg-background/50",
@@ -105,91 +116,112 @@ export function SettlementBillSummaryHeader({
</div> </div>
</div> </div>
</div> </div>
</div>
); );
} }
type SettlementBillAmountBreakdownProps = { type SettlementBillAmountBreakdownProps = {
bill: SettlementBillRow; bill: SettlementBillRow;
currencyCode: string; currencyCode: string;
downlineShares?: DownlineShareBreakdown | null;
}; };
export function SettlementBillAmountBreakdown({ function BreakdownAmountLine({
bill, line,
currencyCode, currencyCode,
}: SettlementBillAmountBreakdownProps): React.ReactElement | null { nested = false,
const { t } = useTranslation(["settlementCenter", "agents"]); }: {
const lines = buildBillAmountBreakdown(bill, t); line: BillBreakdownLine;
currencyCode: string;
if (lines.length === 0) { nested?: boolean;
return null; }): React.ReactElement {
}
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-2">
{lines.map((line) => {
const prefix = const prefix =
line.kind === "subtract" line.kind === "subtract"
? "" ? ""
: line.kind === "add" && lines.indexOf(line) > 0 : line.kind === "add" && line.key !== "gross"
? "+" ? "+"
: line.kind === "subtotal" || line.kind === "total" : line.kind === "subtotal" || line.kind === "total"
? "=" ? "="
: ""; : "";
return ( return (
<>
<div <div
key={line.key}
className={cn( className={cn(
"flex items-start justify-between gap-3 text-sm", "grid grid-cols-[minmax(0,1fr)_7.5rem] items-start gap-x-4 py-1.5 text-sm",
(line.kind === "subtotal" || line.kind === "total") && nested && "py-1 pl-4",
"border-t border-border/60 pt-2 font-medium", !nested && (line.kind === "subtotal" || line.kind === "total") &&
line.kind === "total" && "text-base", "border-t border-border/60 pt-2.5 font-medium",
!nested && line.kind === "total" && "text-base",
)} )}
> >
<div className="min-w-0"> <div className="min-w-0">
<span className="text-muted-foreground"> <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} {prefix ? <span className="mr-1.5 tabular-nums">{prefix}</span> : null}
{line.label} {line.label}
</span> </span>
{line.hint ? (
<p className="mt-0.5 text-xs leading-relaxed text-muted-foreground/80">{line.hint}</p>
) : null}
</div> </div>
<span className="shrink-0 tabular-nums"> <span
{formatDashboardMoneyMinor(line.amount, currencyCode)} className={cn(
"text-right tabular-nums",
signedSettlementMoneyClass(line.signedAmount, line.kind === "total"),
)}
>
{formatSignedSettlementMoney(line.signedAmount, currencyCode)}
</span> </span>
</div> </div>
); {line.children?.map((child) => (
})} <BreakdownAmountLine key={child.key} line={child} currencyCode={currencyCode} nested />
</div> ))}
</div> </>
); );
} }
type SettlementBillPartiesRowProps = { export function SettlementBillAmountBreakdown({
bill: SettlementBillRow; bill,
}; currencyCode,
downlineShares = null,
export function SettlementBillPartiesRow({ bill }: SettlementBillPartiesRowProps): React.ReactElement { }: SettlementBillAmountBreakdownProps): React.ReactElement | null {
const { t } = useTranslation(["settlementCenter", "agents"]); const { t } = useTranslation(["settlementCenter", "agents"]);
const owner = resolveBillPartyName(bill, "owner", t); const lines = buildBillAmountBreakdown(bill, t, downlineShares);
const counterparty = resolveBillPartyName(bill, "counterparty", t);
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 ( return (
<div className="grid gap-4 text-sm sm:grid-cols-2"> <div className="space-y-4 rounded-xl border border-border/70 bg-card p-5 shadow-sm">
<div className="rounded-xl border border-border/60 bg-card px-4 py-3.5 shadow-sm"> <div className="space-y-1">
<p className="text-xs text-muted-foreground"> <p className="font-semibold tracking-tight text-foreground">
{t("settlementCenter:billDisplay.billOwner", { defaultValue: "账单主体" })} {t("settlementCenter:billDisplay.howAmountWorks", { defaultValue: "结算明细" })}
</p> </p>
<p className="mt-1 font-semibold text-foreground">{owner}</p> {breakdownIntro ? (
<p className="text-xs leading-relaxed text-muted-foreground">{breakdownIntro}</p>
) : null}
</div> </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"> <div className="rounded-lg border border-border/50 bg-muted/15 p-4">
{t("settlementCenter:billDisplay.billCounterparty", { defaultValue: "结算对手" })} {lines.map((line) => (
</p> <BreakdownAmountLine key={line.key} line={line} currencyCode={currencyCode} />
<p className="mt-1 font-semibold text-foreground">{counterparty}</p> ))}
</div> </div>
</div> </div>
); );

View File

@@ -2,6 +2,20 @@ import type { TFunction } from "i18next";
import type { SettlementBillRow } from "@/api/admin-agent-settlement"; 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 BillPartyRole = "owner" | "counterparty";
export type BillPaymentDirection = { 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 = { export type BillBreakdownLine = {
key: string; key: string;
label: string; label: string;
amount: number; amount: number;
signedAmount: number;
kind: "add" | "subtract" | "subtotal" | "total"; kind: "add" | "subtract" | "subtotal" | "total";
hint?: string; hint?: string;
children?: BillBreakdownLine[];
}; };
export function parseBillMeta(metaJson: SettlementBillRow["meta_json"]): { export function parseBillMeta(metaJson: SettlementBillRow["meta_json"]): {
@@ -181,6 +208,7 @@ export function describeBillPaymentDirection(
export function buildBillAmountBreakdown( export function buildBillAmountBreakdown(
bill: SettlementBillRow, bill: SettlementBillRow,
t: TFunction<["agents", "settlementCenter"]>, t: TFunction<["agents", "settlementCenter"]>,
downlineShares?: DownlineShareBreakdown | null,
): BillBreakdownLine[] { ): BillBreakdownLine[] {
const meta = parseBillMeta(bill.meta_json); const meta = parseBillMeta(bill.meta_json);
const gross = bill.gross_win_loss ?? 0; const gross = bill.gross_win_loss ?? 0;
@@ -195,7 +223,8 @@ export function buildBillAmountBreakdown(
lines.push({ lines.push({
key: "gross", key: "gross",
label: t("settlementCenter:billDisplay.playerGross", { defaultValue: "游戏输赢" }), label: t("settlementCenter:billDisplay.playerGross", { defaultValue: "游戏输赢" }),
amount: gross, amount: Math.abs(gross),
signedAmount: gross,
kind: "add", kind: "add",
hint: hint:
gross > 0 gross > 0
@@ -209,7 +238,8 @@ export function buildBillAmountBreakdown(
lines.push({ lines.push({
key: "rebate", key: "rebate",
label: t("settlementCenter:billDisplay.rebate", { defaultValue: "回水" }), label: t("settlementCenter:billDisplay.rebate", { defaultValue: "回水" }),
amount: rebate, amount: Math.abs(rebate),
signedAmount: -Math.abs(rebate),
kind: "subtract", kind: "subtract",
}); });
} }
@@ -217,7 +247,8 @@ export function buildBillAmountBreakdown(
lines.push({ lines.push({
key: "rounding", key: "rounding",
label: t("agents:settlementBills.platformRounding", { defaultValue: "平台尾差" }), 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", kind: rounding > 0 ? "subtract" : "add",
}); });
} }
@@ -228,19 +259,21 @@ export function buildBillAmountBreakdown(
? t("settlementCenter:billDisplay.playerNet", { defaultValue: "玩家应付净额" }) ? t("settlementCenter:billDisplay.playerNet", { defaultValue: "玩家应付净额" })
: t("settlementCenter:billDisplay.playerNetReceive", { defaultValue: "代理应付玩家" }), : t("settlementCenter:billDisplay.playerNetReceive", { defaultValue: "代理应付玩家" }),
amount: Math.abs(bill.net_amount), amount: Math.abs(bill.net_amount),
signedAmount: bill.net_amount,
kind: "total", kind: "total",
}); });
return lines; return lines;
} }
if (bill.bill_type === "agent") { if (bill.bill_type === "agent") {
const owner = resolveBillPartyName(bill, "owner", t); const counterparty = resolveBillPartyName(bill, "counterparty", t);
const lines: BillBreakdownLine[] = []; const lines: BillBreakdownLine[] = [];
if (bill.gross_win_loss != null) { if (bill.gross_win_loss != null) {
lines.push({ lines.push({
key: "gross", key: "gross",
label: t("settlementCenter:billDisplay.teamGross", { defaultValue: "团队游戏输赢" }), label: t("settlementCenter:billDisplay.teamGross", { defaultValue: "团队游戏输赢" }),
amount: gross, amount: Math.abs(gross),
signedAmount: gross,
kind: "add", kind: "add",
hint: t("settlementCenter:billDisplay.teamGrossHint", { hint: t("settlementCenter:billDisplay.teamGrossHint", {
defaultValue: "含本级及下级玩家的合计", defaultValue: "含本级及下级玩家的合计",
@@ -251,7 +284,8 @@ export function buildBillAmountBreakdown(
lines.push({ lines.push({
key: "rebate", key: "rebate",
label: t("settlementCenter:billDisplay.teamRebate", { defaultValue: "团队回水" }), label: t("settlementCenter:billDisplay.teamRebate", { defaultValue: "团队回水" }),
amount: rebate, amount: Math.abs(rebate),
signedAmount: -Math.abs(rebate),
kind: "subtract", kind: "subtract",
}); });
} }
@@ -260,28 +294,49 @@ export function buildBillAmountBreakdown(
key: "team-net", key: "team-net",
label: t("settlementCenter:billDisplay.teamNet", { defaultValue: "团队净额" }), label: t("settlementCenter:billDisplay.teamNet", { defaultValue: "团队净额" }),
amount: Math.abs(teamNet), amount: Math.abs(teamNet),
signedAmount: teamNet,
kind: "subtotal", 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) { if (meta.share_profit != null) {
lines.push({ lines.push({
key: "share", key: "share",
label: t("settlementCenter:billDisplay.agentShareKeep", { label: billDisplayLabel(t, "agentShareKeep", "本级占成"),
defaultValue: "{{agent}} 本级占成", amount: Math.abs(shareProfit),
agent: owner, signedAmount: -Math.abs(shareProfit),
}),
amount: shareProfit,
kind: "subtract", kind: "subtract",
hint: t("settlementCenter:billDisplay.agentShareKeepHint", { hint: billDisplayLabel(t, "agentShareKeepHint", "本级按占成比例留下的利润"),
defaultValue: "本级按占成比例留下的利润",
}),
}); });
} }
if (rounding !== 0) { if (rounding !== 0) {
lines.push({ lines.push({
key: "rounding", key: "rounding",
label: t("agents:settlementBills.platformRounding", { defaultValue: "平台尾差" }), 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", kind: rounding > 0 ? "subtract" : "add",
}); });
} }
@@ -289,15 +344,10 @@ export function buildBillAmountBreakdown(
key: "net", key: "net",
label: label:
bill.net_amount > 0 bill.net_amount > 0
? t("settlementCenter:billDisplay.agentNet", { ? billDisplayLabel(t, "agentNet", "应付 {{counterparty}}", { counterparty })
defaultValue: "{{agent}} 应付级", : billDisplayLabel(t, "agentNetReceive", "{{counterparty}} 应付级", { counterparty }),
agent: owner,
})
: t("settlementCenter:billDisplay.agentNetReceive", {
defaultValue: "上级应付 {{agent}}",
agent: owner,
}),
amount: Math.abs(bill.net_amount), amount: Math.abs(bill.net_amount),
signedAmount: bill.net_amount,
kind: "total", kind: "total",
}); });
return lines; 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"; return "/admin/settlement-center";
} }
export function settlementPeriodViewHref( export function settlementPeriodViewHref(
periodId: number, periodId: number,
view: SettlementPeriodView = "bills", view: SettlementPeriodView = "bills",
adminSiteId?: number | null,
): string { ): 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( export function parseSettlementCenterView(
siteRaw: string | null,
periodRaw: string | null, periodRaw: string | null,
viewRaw: string | null, viewRaw: string | null,
): { periodId: number | null; view: SettlementPeriodView } { ): { siteId: number | null; periodId: number | null; view: SettlementPeriodView } {
const periodId = periodRaw !== null && periodRaw !== "" ? Number(periodRaw) : NaN;
const normalizedView = viewRaw === "reports" ? "bills" : viewRaw; const normalizedView = viewRaw === "reports" ? "bills" : viewRaw;
const view = const view =
normalizedView !== null && VALID_VIEWS.includes(normalizedView as SettlementPeriodView) normalizedView !== null && VALID_VIEWS.includes(normalizedView as SettlementPeriodView)
@@ -25,7 +44,8 @@ export function parseSettlementCenterView(
: "bills"; : "bills";
return { return {
periodId: Number.isInteger(periodId) && periodId > 0 ? periodId : null, siteId: parsePositiveInt(siteRaw),
periodId: parsePositiveInt(periodRaw),
view, view,
}; };
} }

View File

@@ -8,6 +8,7 @@ import type { SettlementPeriodRow } from "@/api/admin-agent-settlement";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge"; import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { SettlementCreditLedgerPanel } from "@/modules/settlement/settlement-credit-ledger-panel"; import { SettlementCreditLedgerPanel } from "@/modules/settlement/settlement-credit-ledger-panel";
import { SettlementMainPanel } from "@/modules/settlement/settlement-main-panel"; import { SettlementMainPanel } from "@/modules/settlement/settlement-main-panel";
import { SettlementOperationsPanel } from "@/modules/settlement/settlement-operations-panel";
import { import {
settlementCenterListHref, settlementCenterListHref,
settlementPeriodViewHref, settlementPeriodViewHref,
@@ -25,6 +26,7 @@ type SettlementCenterPeriodDetailProps = {
adminSiteId: number; adminSiteId: number;
currencyCode: string; currencyCode: string;
canOperateBills: boolean; canOperateBills: boolean;
boundAgentId?: number | null;
refreshKey: number; refreshKey: number;
onOpenBillDetail: (billId: number) => void; onOpenBillDetail: (billId: number) => void;
}; };
@@ -35,6 +37,7 @@ export function SettlementCenterPeriodDetail({
adminSiteId, adminSiteId,
currencyCode, currencyCode,
canOperateBills, canOperateBills,
boundAgentId = null,
refreshKey, refreshKey,
onOpenBillDetail, onOpenBillDetail,
}: SettlementCenterPeriodDetailProps): React.ReactElement { }: SettlementCenterPeriodDetailProps): React.ReactElement {
@@ -42,6 +45,7 @@ export function SettlementCenterPeriodDetail({
const subViews: { key: SettlementPeriodView; label: string }[] = [ const subViews: { key: SettlementPeriodView; label: string }[] = [
{ key: "bills", label: t("nav.bills", { defaultValue: "账单" }) }, { key: "bills", label: t("nav.bills", { defaultValue: "账单" }) },
{ key: "operations", label: t("nav.operations", { defaultValue: "收付与调账" }) },
{ key: "ledger", label: t("nav.ledger", { 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 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 flex-col gap-2"> <div className="flex min-w-0 flex-col gap-2">
<Link <Link
href={settlementCenterListHref()} href={settlementCenterListHref(adminSiteId)}
className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "h-8 w-fit px-2")} className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "h-8 w-fit px-2")}
> >
<ArrowLeft className="size-4" aria-hidden /> <ArrowLeft className="size-4" aria-hidden />
@@ -74,7 +78,7 @@ export function SettlementCenterPeriodDetail({
{subViews.map((item) => ( {subViews.map((item) => (
<AdminSubnavLink <AdminSubnavLink
key={item.key} key={item.key}
href={settlementPeriodViewHref(period.id, item.key)} href={settlementPeriodViewHref(period.id, item.key, adminSiteId)}
active={view === item.key} active={view === item.key}
> >
{item.label} {item.label}
@@ -93,6 +97,18 @@ export function SettlementCenterPeriodDetail({
pendingConfirm={pendingConfirm} pendingConfirm={pendingConfirm}
awaitingPayment={awaitingPayment} awaitingPayment={awaitingPayment}
selectedPeriodStatus={period.status} 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} ) : null}

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { Check, ChevronDown, Search } from "lucide-react"; import { Check, ChevronDown, Search } from "lucide-react";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -8,10 +9,12 @@ import { toast } from "sonner";
import { getSettlementPeriods, type SettlementPeriodRow } from "@/api/admin-agent-settlement"; import { getSettlementPeriods, type SettlementPeriodRow } from "@/api/admin-agent-settlement";
import { getAdminIntegrationSites } from "@/api/admin-integration-sites"; import { getAdminIntegrationSites } from "@/api/admin-integration-sites";
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { AgentBillDetail } from "@/modules/settlement/agent-bill-detail"; import { AgentBillDetail } from "@/modules/settlement/agent-bill-detail";
import { SettlementCenterPeriodDetail } from "@/modules/settlement/settlement-center-period-detail"; import { SettlementCenterPeriodDetail } from "@/modules/settlement/settlement-center-period-detail";
import { import {
parseSettlementCenterView, parseSettlementCenterView,
settlementCenterListHref,
settlementPeriodViewHref, settlementPeriodViewHref,
type SettlementPeriodView, type SettlementPeriodView,
} from "@/modules/settlement/settlement-center-nav"; } from "@/modules/settlement/settlement-center-nav";
@@ -25,7 +28,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button, buttonVariants } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
@@ -41,7 +44,8 @@ export function SettlementCenterShell(): React.ReactElement {
const profile = useAdminProfile(); const profile = useAdminProfile();
const boundAgent = profile?.agent ?? null; 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("period"),
searchParams.get("view"), searchParams.get("view"),
); );
@@ -50,6 +54,7 @@ export function SettlementCenterShell(): React.ReactElement {
profile?.is_super_admin === true || profile?.is_super_admin === true ||
adminHasAnyPermission(profile?.permissions, [PRD_SETTLEMENT_AGENT_MANAGE]); adminHasAnyPermission(profile?.permissions, [PRD_SETTLEMENT_AGENT_MANAGE]);
const canManagePeriods = canOperateBills && boundAgent === null; const canManagePeriods = canOperateBills && boundAgent === null;
const canFinanceAdjustments = canOperateBills && boundAgent === null;
const [siteOptions, setSiteOptions] = useState<SiteOption[]>([]); const [siteOptions, setSiteOptions] = useState<SiteOption[]>([]);
const [adminSiteId, setAdminSiteId] = useState<number | null>(null); const [adminSiteId, setAdminSiteId] = useState<number | null>(null);
@@ -59,10 +64,14 @@ export function SettlementCenterShell(): React.ReactElement {
const [periodsReady, setPeriodsReady] = useState(false); const [periodsReady, setPeriodsReady] = useState(false);
const [detailBillId, setDetailBillId] = useState<number | null>(null); const [detailBillId, setDetailBillId] = useState<number | null>(null);
const [refreshKey, setRefreshKey] = useState(0); const [refreshKey, setRefreshKey] = useState(0);
const [periodLookupDone, setPeriodLookupDone] = useState(false);
useEffect(() => { useEffect(() => {
if (boundAgent?.admin_site_id) { 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([{ setSiteOptions([{
id: boundAgent.admin_site_id, id: boundAgent.admin_site_id,
label, label,
@@ -81,11 +90,17 @@ export function SettlementCenterShell(): React.ReactElement {
currency_code: site.currency_code ?? "NPR", currency_code: site.currency_code ?? "NPR",
})); }));
setSiteOptions(options); setSiteOptions(options);
if (adminSiteId === null && options[0]) { setAdminSiteId((current) => {
setAdminSiteId(options[0].id); 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 siteId = adminSiteId ?? siteOptions[0]?.id ?? null;
const selectedSite = siteOptions.find((s) => s.id === siteId) ?? 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.filter((site) => site.label.toLowerCase().includes(siteKeyword.trim().toLowerCase()))
: siteOptions; : 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 = const siteSelector =
siteOptions.length > 0 && siteId !== null ? ( siteOptions.length > 0 && siteId !== null ? (
<Popover open={sitePickerOpen} onOpenChange={setSitePickerOpen}> <Popover open={sitePickerOpen} onOpenChange={setSitePickerOpen}>
@@ -143,6 +169,7 @@ export function SettlementCenterShell(): React.ReactElement {
setAdminSiteId(site.id); setAdminSiteId(site.id);
setSitePickerOpen(false); setSitePickerOpen(false);
setSiteKeyword(""); setSiteKeyword("");
router.replace(settlementCenterListHref(site.id));
}} }}
> >
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
@@ -164,6 +191,14 @@ export function SettlementCenterShell(): React.ReactElement {
</Popover> </Popover>
) : null; ) : 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[]> => { const loadPeriods = useCallback(async (): Promise<SettlementPeriodRow[]> => {
if (siteId === null) { if (siteId === null) {
return []; return [];
@@ -191,22 +226,75 @@ export function SettlementCenterShell(): React.ReactElement {
activePeriodId !== null ? (periods.find((row) => row.id === activePeriodId) ?? null) : null; activePeriodId !== null ? (periods.find((row) => row.id === activePeriodId) ?? null) : null;
const openPeriodView = (periodId: number, view: SettlementPeriodView): void => { const openPeriodView = (periodId: number, view: SettlementPeriodView): void => {
router.push(settlementPeriodViewHref(periodId, view)); router.push(settlementPeriodViewHref(periodId, view, siteId));
}; };
const isListMode = activePeriodId === null; 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 ( return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4"> <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> <p className="text-sm text-muted-foreground">{t("empty.noSite", { defaultValue: "请选择站点。" })}</p>
) : !periodsReady ? (
<AdminLoadingState />
) : isListMode ? ( ) : isListMode ? (
<SettlementPeriodWorkbench <SettlementPeriodWorkbench
adminSiteId={siteId} adminSiteId={siteId}
currencyCode={currency} currencyCode={currency}
canManage={canManagePeriods} canManage={canManagePeriods}
periods={periods} periods={periods}
headerActions={siteSelector} headerActions={headerActions}
onViewDetail={(id) => openPeriodView(id, "bills")} onViewDetail={(id) => openPeriodView(id, "bills")}
onReloadPeriods={loadPeriods} onReloadPeriods={loadPeriods}
onPeriodOpened={() => { onPeriodOpened={() => {
@@ -226,9 +314,21 @@ export function SettlementCenterShell(): React.ReactElement {
}} }}
/> />
) : activePeriod === null ? ( ) : activePeriod === null ? (
!periodLookupDone ? (
<AdminLoadingState />
) : (
<div className="flex flex-col gap-3">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t("periodDetail.notFound", { defaultValue: "账期不存在或已切换站点,请返回列表。" })} {t("periodDetail.notFound", { defaultValue: "账期不存在或已切换站点,请返回列表。" })}
</p> </p>
<Link
href={settlementCenterListHref(siteId)}
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "w-fit")}
>
{t("periodDetail.back", { defaultValue: "返回账期列表" })}
</Link>
</div>
)
) : ( ) : (
<SettlementCenterPeriodDetail <SettlementCenterPeriodDetail
period={activePeriod} period={activePeriod}
@@ -236,6 +336,7 @@ export function SettlementCenterShell(): React.ReactElement {
adminSiteId={siteId} adminSiteId={siteId}
currencyCode={currency} currencyCode={currency}
canOperateBills={canOperateBills} canOperateBills={canOperateBills}
boundAgentId={boundAgent?.id ?? null}
refreshKey={refreshKey} refreshKey={refreshKey}
onOpenBillDetail={setDetailBillId} onOpenBillDetail={setDetailBillId}
/> />
@@ -243,7 +344,7 @@ export function SettlementCenterShell(): React.ReactElement {
<Dialog open={detailBillId !== null} onOpenChange={(open) => !open && setDetailBillId(null)}> <Dialog open={detailBillId !== null} onOpenChange={(open) => !open && setDetailBillId(null)}>
<DialogContent <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"> <DialogHeader className="border-b px-6 py-4">
<DialogTitle>{t("actions.billDetail", { defaultValue: "账单详情" })}</DialogTitle> <DialogTitle>{t("actions.billDetail", { defaultValue: "账单详情" })}</DialogTitle>
@@ -254,6 +355,8 @@ export function SettlementCenterShell(): React.ReactElement {
billId={detailBillId} billId={detailBillId}
currencyCode={currency} currencyCode={currency}
canManage={canOperateBills} canManage={canOperateBills}
boundAgent={boundAgent}
canFinanceAdjustments={canFinanceAdjustments}
onUpdated={() => { onUpdated={() => {
void loadPeriods(); void loadPeriods();
setRefreshKey((n) => n + 1); setRefreshKey((n) => n + 1);

View File

@@ -37,11 +37,11 @@ type BillFilters = {
statusScope: BillStatusFilter; statusScope: BillStatusFilter;
}; };
function filtersForPeriod(): BillFilters { function filtersForPeriod(boundAgentId: number | null): BillFilters {
return { return {
billId: "", billId: "",
ownerKeyword: "", ownerKeyword: "",
billType: "all", billType: boundAgentId !== null ? "agent" : "all",
statusScope: "all", statusScope: "all",
}; };
} }
@@ -86,6 +86,7 @@ export type SettlementMainPanelProps = {
pendingConfirm: number; pendingConfirm: number;
awaitingPayment: number; awaitingPayment: number;
selectedPeriodStatus?: string | null; selectedPeriodStatus?: string | null;
boundAgentId?: number | null;
}; };
export function SettlementMainPanel({ export function SettlementMainPanel({
@@ -97,12 +98,13 @@ export function SettlementMainPanel({
pendingConfirm, pendingConfirm,
awaitingPayment, awaitingPayment,
selectedPeriodStatus, selectedPeriodStatus,
boundAgentId = null,
}: SettlementMainPanelProps): React.ReactElement { }: SettlementMainPanelProps): React.ReactElement {
const { t } = useTranslation("settlementCenter"); const { t } = useTranslation("settlementCenter");
const periodId = periodFilter === "all" ? undefined : periodFilter; const periodId = periodFilter === "all" ? undefined : periodFilter;
const periodOpen = selectedPeriodStatus === "open"; const periodOpen = selectedPeriodStatus === "open";
const initialFilters = useMemo(() => filtersForPeriod(), []); const initialFilters = useMemo(() => filtersForPeriod(boundAgentId), [boundAgentId]);
const [draft, setDraft] = useState<BillFilters>(initialFilters); const [draft, setDraft] = useState<BillFilters>(initialFilters);
const [applied, setApplied] = useState<BillFilters>(initialFilters); const [applied, setApplied] = useState<BillFilters>(initialFilters);
@@ -197,6 +199,9 @@ export function SettlementMainPanel({
}); });
}, [applied.statusScope, periodOpen, t]); }, [applied.statusScope, periodOpen, t]);
const billTypeOptions: BillTypeFilter[] =
boundAgentId !== null ? ["agent"] : ["all", "player", "agent"];
const billTypeLabel = (value: BillTypeFilter): string => { const billTypeLabel = (value: BillTypeFilter): string => {
switch (value) { switch (value) {
case "player": case "player":
@@ -291,7 +296,7 @@ export function SettlementMainPanel({
<SelectValue>{() => billTypeLabel(draft.billType)}</SelectValue> <SelectValue>{() => billTypeLabel(draft.billType)}</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{(["all", "player", "agent"] as BillTypeFilter[]).map((value) => ( {billTypeOptions.map((value) => (
<SelectItem key={value} value={value}> <SelectItem key={value} value={value}>
{billTypeLabel(value)} {billTypeLabel(value)}
</SelectItem> </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 { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { formatSettlementPeriodSpan } from "@/lib/agent-settlement-period-range"; import { formatSettlementPeriodSpan } from "@/lib/agent-settlement-period-range";
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics"; 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 { import {
Table, Table,
TableBody, TableBody,
@@ -80,9 +83,7 @@ export function SettlementPaymentsTable({
</TableCell> </TableCell>
<TableCell>{row.method ?? "—"}</TableCell> <TableCell>{row.method ?? "—"}</TableCell>
<TableCell> <TableCell>
{t(`paymentStatus.${row.status}`, { {settlementPaymentStatusLabel(row.status, t)}
defaultValue: row.status === "confirmed" ? "已确认" : row.status,
})}
</TableCell> </TableCell>
<TableCell className="text-xs text-muted-foreground"> <TableCell className="text-xs text-muted-foreground">
{formatTs(row.confirmed_at ?? row.created_at)} {formatTs(row.confirmed_at ?? row.created_at)}

View File

@@ -6,11 +6,14 @@ import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
getSettlementPeriodOpenHints,
postSettlementPeriod, postSettlementPeriod,
postSettlementPeriodClose, postSettlementPeriodClose,
type SettlementPeriodCloseResult, type SettlementPeriodCloseResult,
type SettlementPeriodOpenHints,
type SettlementPeriodRow, type SettlementPeriodRow,
} from "@/api/admin-agent-settlement"; } from "@/api/admin-agent-settlement";
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer"; import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { AdminPageCard } from "@/components/admin/admin-page-card"; import { AdminPageCard } from "@/components/admin/admin-page-card";
import { SettlementPeriodsTable } from "@/modules/settlement/settlement-periods-table"; import { SettlementPeriodsTable } from "@/modules/settlement/settlement-periods-table";
@@ -23,7 +26,6 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { import {
Select, Select,
@@ -34,14 +36,15 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { import {
formatSettlementPeriodSpan, formatSettlementPeriodSpan,
settlementPeriodPresetRange, isSettlementLocalDateRangeValid,
type SettlementPeriodPresetKey, localDateRangeToUtcPeriodBounds,
settlementRangeOverlapsOccupiedDates,
utcStorageDateToLocalFormYmd,
utcStorageDatesToLocalMarks,
} from "@/lib/agent-settlement-period-range"; } from "@/lib/agent-settlement-period-range";
import { settlementPeriodStatusLabel } from "@/modules/settlement/settlement-status-label"; import { settlementPeriodStatusLabel } from "@/modules/settlement/settlement-status-label";
import { LotteryApiBizError } from "@/types/api/errors"; import { LotteryApiBizError } from "@/types/api/errors";
const PRESET_KEYS: SettlementPeriodPresetKey[] = ["this_week", "last_week", "this_month"];
type PeriodStatusFilter = "all" | "open" | "closed" | "completed"; type PeriodStatusFilter = "all" | "open" | "closed" | "completed";
const STATUS_FILTER_OPTIONS: 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 [openDialogOpen, setOpenDialogOpen] = useState(false);
const [customStart, setCustomStart] = useState(""); const [customStart, setCustomStart] = useState("");
const [customEnd, setCustomEnd] = useState(""); const [customEnd, setCustomEnd] = useState("");
const [openHints, setOpenHints] = useState<SettlementPeriodOpenHints | null>(null);
const [hintsLoading, setHintsLoading] = useState(false);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [reloading, setReloading] = useState(false); const [reloading, setReloading] = useState(false);
const [closeDialogOpen, setCloseDialogOpen] = useState(false); const [closeDialogOpen, setCloseDialogOpen] = useState(false);
@@ -112,16 +117,32 @@ export function SettlementPeriodWorkbench({
} }
}, [page, lastPage]); }, [page, lastPage]);
const presetLabel = (key: SettlementPeriodPresetKey): string => { const calendarMarkers = useMemo(() => {
switch (key) { if (openHints === null) {
case "this_week": return undefined;
return t("agents:settlementPeriods.presetThisWeek", { defaultValue: "本周" });
case "last_week":
return t("agents:settlementPeriods.presetLastWeek", { defaultValue: "上周" });
case "this_month":
return t("agents:settlementPeriods.presetThisMonth", { defaultValue: "本月" });
} }
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 => { const statusFilterLabel = (value: PeriodStatusFilter): string => {
if (value === "all") { if (value === "all") {
@@ -130,45 +151,97 @@ export function SettlementPeriodWorkbench({
return settlementPeriodStatusLabel(value, t); return settlementPeriodStatusLabel(value, t);
}; };
async function openWithRange(periodStart: string, periodEnd: string): Promise<void> { async function openWithRange(startYmd: string, endYmd: string): Promise<void> {
if (!canManage) { if (!canManage) {
return; 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); setBusy(true);
try { try {
const row = await postSettlementPeriod({ const row = await postSettlementPeriod({
admin_site_id: adminSiteId, admin_site_id: adminSiteId,
period_start: periodStart, period_start: bounds.period_start,
period_end: periodEnd, period_end: bounds.period_end,
}); });
await onReloadPeriods(); await onReloadPeriods();
onPeriodOpened?.(row.id); onPeriodOpened?.(row.id);
setOpenDialogOpen(false); setOpenDialogOpen(false);
setCustomStart(""); setCustomStart("");
setCustomEnd(""); setCustomEnd("");
setOpenHints(null);
toast.success(t("agents:settlementPeriods.opened", { defaultValue: "账期已开启" })); toast.success(t("agents:settlementPeriods.opened", { defaultValue: "账期已开启" }));
} catch (err: unknown) { } catch (err: unknown) {
toast.error( toast.error(
err instanceof LotteryApiBizError err instanceof LotteryApiBizError
? err.message ? err.message
: t("agents:settlementPeriods.openFailed", { defaultValue: "开失败" }), : t("agents:settlementPeriods.openFailed", { defaultValue: "开失败" }),
); );
} finally { } finally {
setBusy(false); 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> { async function openCustom(): Promise<void> {
if (!customStart.trim() || !customEnd.trim()) { if (!customStart.trim() || !customEnd.trim()) {
toast.error(t("agents:settlementPeriods.datesRequired", { defaultValue: "请填写账期起止" })); toast.error(t("agents:settlementPeriods.datesRequired", { defaultValue: "请填写账期起止" }));
return; 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 { function requestClose(row: SettlementPeriodRow): void {
@@ -299,7 +372,7 @@ export function SettlementPeriodWorkbench({
type="button" type="button"
size="sm" size="sm"
disabled={busy} disabled={busy}
onClick={() => setOpenDialogOpen(true)} onClick={() => handleOpenDialog(true)}
> >
<Plus className="size-4" aria-hidden /> <Plus className="size-4" aria-hidden />
{t("period.openBtn", { defaultValue: "开账" })} {t("period.openBtn", { defaultValue: "开账" })}
@@ -370,76 +443,81 @@ export function SettlementPeriodWorkbench({
/> />
</AdminPageCard> </AdminPageCard>
<Dialog <Dialog open={openDialogOpen} onOpenChange={handleOpenDialog}>
open={openDialogOpen}
onOpenChange={(open) => {
setOpenDialogOpen(open);
if (!open) {
setCustomStart("");
setCustomEnd("");
}
}}
>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>{t("period.openTitle", { defaultValue: "开账" })}</DialogTitle> <DialogTitle>{t("period.openTitle", { defaultValue: "开账" })}</DialogTitle>
<DialogDescription> <DialogDescription>
{t("agents:settlementPeriods.openHint", { {t("agents:settlementPeriods.openDesc", {
defaultValue: "选择快捷账期或自定义起止时间。", defaultValue:
"选择账期起止日期;灰色删除线为已有账期不可再开,琥珀点为待入账,右上角红点为未结清。",
})} })}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-3">
<div className="flex flex-wrap gap-2"> <AdminDateRangeField
{PRESET_KEYS.map((key) => ( id="sp-dialog-range"
<Button label={t("agents:settlementPeriods.rangeLabel", { defaultValue: "账期范围" })}
key={key} from={customStart}
type="button" to={customEnd}
size="sm" disabled={hintsLoading || busy}
variant="secondary" calendarMarkers={calendarMarkers}
disabled={busy} rangeHint={t("agents:settlementPeriods.rangeHint", {
onClick={() => void openWithPreset(key)} 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)} <span className="absolute top-0 right-0 size-1.5 rounded-full bg-rose-500" />
</Button> </span>
))} {t("agents:settlementPeriods.markerUnpaid", { defaultValue: "未结清账期" })}
</div> </span>
<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>
</div> </div>
{selectedRangeOverlapsOccupied ? (
<p className="text-destructive text-xs">
{t("agents:settlementPeriods.overlapsOccupied", {
defaultValue: "所选范围与已有账期重叠,请避开灰色删除线的日期。",
})}
</p>
) : null}
</div> </div>
<DialogFooter> <DialogFooter>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
disabled={busy} disabled={busy || hintsLoading}
onClick={() => setOpenDialogOpen(false)} onClick={() => handleOpenDialog(false)}
> >
{t("common:cancel", { defaultValue: "取消" })} {t("common:cancel", { defaultValue: "取消" })}
</Button> </Button>
<Button type="button" disabled={busy} onClick={() => void openCustom()}> <Button
{t("agents:settlementPeriods.open", { defaultValue: "开期" })} type="button"
disabled={busy || hintsLoading || selectedRangeOverlapsOccupied}
onClick={() => void openCustom()}
>
{t("period.openBtn", { defaultValue: "开账" })}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@@ -1,3 +1,4 @@
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
/** 结算金额正负着色:负红、正绿、零灰 */ /** 结算金额正负着色:负红、正绿、零灰 */
@@ -11,3 +12,12 @@ export function signedSettlementMoneyClass(amount: number, emphasize = false): s
return "text-muted-foreground"; 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; const key = `adjustmentType.${type}` as const;
return t(key, { defaultValue: type }); 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 { function statusLabelT(status: string, t: (key: string) => string): string {
switch (status) { switch (status) {
case "processing": 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} {err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
{(loading && !data) || data ? ( {(loading && !data) || data ? (
<> <>
<div className="rounded-md border"> <div className="admin-table-shell overflow-x-auto rounded-md border">
<Table id="wallet-transfer-orders-table" className="table-fixed"> <Table id="wallet-transfer-orders-table" className="min-w-[1180px]">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="min-w-0 max-w-[14rem]">{t("localTransferNo")}</TableHead> <TableHead className="min-w-0 max-w-[14rem]">{t("localTransferNo")}</TableHead>
@@ -713,6 +745,10 @@ export function WalletTxnsPanel(): React.ReactElement {
setPage(1); setPage(1);
}; };
const showLedgerColumn =
data?.items.some((row) => row.ledger_source === "credit_ledger") ?? false;
const txnTableColSpan = showLedgerColumn ? 12 : 11;
return ( return (
<Card> <Card>
<CardHeader> <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} {err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
{(loading && !data) || data ? ( {(loading && !data) || data ? (
<> <>
<div className="rounded-md border"> <div className="admin-table-shell overflow-x-auto rounded-md border">
<Table id="wallet-transactions-table" className="table-fixed"> <Table id="wallet-transactions-table" className="min-w-[1180px]">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="min-w-0 max-w-[14rem]">{t("txnNo")}</TableHead> <TableHead className="min-w-[10rem] whitespace-nowrap">{t("txnNo")}</TableHead>
<TableHead className="min-w-0 max-w-[12rem]">{t("externalRefNo")}</TableHead> <TableHead className="min-w-[8rem] whitespace-nowrap">{t("externalRefNo")}</TableHead>
<AdminAgentIdentityHeads /> <AdminAgentIdentityHeads />
<AdminPlayerIdentityHeads /> <AdminPlayerIdentityHeads />
{showLedgerColumn ? (
<TableHead className="whitespace-nowrap">{t("ledgerChannel", { defaultValue: "账本" })}</TableHead> <TableHead className="whitespace-nowrap">{t("ledgerChannel", { defaultValue: "账本" })}</TableHead>
<TableHead className="whitespace-nowrap">{t("type")}</TableHead> ) : null}
<TableHead className="whitespace-nowrap">{t("amount")}</TableHead> <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="whitespace-nowrap">{t("status")}</TableHead>
<TableHead className="min-w-0 whitespace-normal leading-tight">{t("requestTime")}</TableHead> <TableHead className="min-w-[8.5rem] whitespace-nowrap">{t("requestTime")}</TableHead>
<TableHead className="min-w-0 whitespace-normal leading-tight">{t("finishedTime")}</TableHead> <TableHead className="min-w-[8.5rem] whitespace-nowrap">{t("finishedTime")}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{loading && !data ? ( {loading && !data ? (
<AdminTableLoadingRow colSpan={12} /> <AdminTableLoadingRow colSpan={txnTableColSpan} />
) : !data || data.items.length === 0 ? ( ) : !data || data.items.length === 0 ? (
<AdminTableNoResourceRow colSpan={12} /> <AdminTableNoResourceRow colSpan={txnTableColSpan} />
) : ( ) : (
data.items.map((row) => ( data.items.map((row) => (
<TableRow key={row.id}> <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")} /> <CellMonoId value={row.txn_no} copyHint={t("copyTxnNo")} />
</TableCell> </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")} /> <CellMonoId value={row.external_ref_no} copyHint={t("copyExternalTxnRefNo")} />
</TableCell> </TableCell>
<AdminAgentIdentityCells row={row} /> <AdminAgentIdentityCells row={row} />
<AdminPlayerIdentityCells row={row} /> <AdminPlayerIdentityCells row={row} />
<TableCell> {showLedgerColumn ? (
<TableCell className="align-top whitespace-nowrap">
<PlayerLedgerSourceBadge ledgerSource={row.ledger_source} /> <PlayerLedgerSourceBadge ledgerSource={row.ledger_source} />
</TableCell> </TableCell>
<TableCell className="min-w-0 text-xs"> ) : null}
{row.ledger_source === "credit_ledger" <TableCell className="min-w-[6.5rem] max-w-[9rem] align-top text-xs">
? creditLedgerReasonLabel(row.biz_type, tSettlement) <span
: row.biz_type} 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>
<TableCell className="tabular-nums text-xs"> <TableCell className="min-w-[6.5rem] align-top whitespace-nowrap tabular-nums text-xs">
{row.amount} ({row.direction === 1 ? t("in") : t("out")}) {row.amount_formatted ?? formatAdminMinorUnits(row.amount)}
<span className="ml-1 text-muted-foreground">
({row.direction === 1 ? t("in") : t("out")})
</span>
</TableCell> </TableCell>
<TableCell> <TableCell className="align-top whitespace-nowrap">
<AdminStatusBadge status={row.status}>{statusLabelT(row.status, t)}</AdminStatusBadge> <AdminStatusBadge status={row.status}>{statusLabelT(row.status, t)}</AdminStatusBadge>
</TableCell> </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)} {formatTs(row.created_at)}
</TableCell> </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)} {formatTs(row.updated_at)}
</TableCell> </TableCell>
</TableRow> </TableRow>

View File

@@ -3,6 +3,8 @@ import type { AdminRoleRow, AdminUserPermissionRow } from "@/types/api/admin-use
export type AdminAgentContext = { export type AdminAgentContext = {
id: number; id: number;
admin_site_id: number; admin_site_id: number;
/** 主站名称admin_sites.name展示用 */
admin_site_name?: string;
/** 主站编号admin_sites.code创建玩家时预填 */ /** 主站编号admin_sites.code创建玩家时预填 */
site_code: string; site_code: string;
path: string; path: string;

View File

@@ -49,6 +49,8 @@ export type AdminDashboardAnalyticsData = {
play_code: string | null; play_code: string | null;
date_from: string; date_from: string;
date_to: string; date_to: string;
/** 绑定代理时为 share_profit本级占成平台账号为 house_gross */
profit_scope?: "share_profit" | "house_gross";
currency_code: string | null; currency_code: string | null;
summary: AdminDashboardAnalyticsSummary; summary: AdminDashboardAnalyticsSummary;
daily_series: AdminReportDailyProfitRow[]; daily_series: AdminReportDailyProfitRow[];

View File

@@ -74,6 +74,8 @@ export type AdminDashboardAgentOverview = {
seven_day_bet_minor: number; seven_day_bet_minor: number;
seven_day_payout_minor: number; seven_day_payout_minor: number;
seven_day_profit_minor: number; seven_day_profit_minor: number;
/** 代理视角盈亏口径:本级占成 */
profit_scope?: "share_profit";
currency_code: string | null; currency_code: string | null;
pending_bill_count: number; pending_bill_count: number;
pending_unpaid_minor: number; pending_unpaid_minor: number;

View File

@@ -53,6 +53,7 @@ export type AdminWalletTxnItem = {
biz_no: string; biz_no: string;
direction: number; direction: number;
amount: number; amount: number;
amount_formatted?: string;
balance_before: number; balance_before: number;
balance_after: number; balance_after: number;
status: string; status: string;