feat(api, agents, i18n): enhance settlement features and multi-language support

Added new types and API functions for settlement period summaries and credit ledgers, improving the management of agent settlements. Updated the admin console to reflect these changes, enhancing user experience with better navigation and data presentation. Additionally, expanded multi-language support by incorporating new translations in English, Nepali, and Chinese for settlement-related terms, ensuring consistency across the platform.
This commit is contained in:
2026-06-05 18:00:59 +08:00
parent 65eaeecf8c
commit af982bb9f7
73 changed files with 4307 additions and 2494 deletions

View File

@@ -10,11 +10,16 @@ export type SettlementPeriodSummary = {
awaiting_payment: number;
settled: number;
total_unpaid: number;
total_net?: number;
};
export type SettlementPeriodPipeline = {
credit_ledger_count: number;
share_ledger_count: number;
game_win_loss_total?: number;
win_loss_scope?: "platform" | "agent";
basic_rebate_total?: number;
unsettled_ticket_count?: number;
};
export type SettlementPeriodRow = {
@@ -55,6 +60,12 @@ export type SettlementBillRow = {
status: string;
owner_label?: string;
counterparty_label?: string;
player_username?: string | null;
player_site_player_id?: string | null;
player_id_display?: number | null;
direct_agent_label?: string | null;
superior_agent_label?: string | null;
owner_party_label?: string | null;
owner_funding_mode?: string | null;
owner_auth_source?: string | null;
period_start?: string;
@@ -102,12 +113,71 @@ export type SettlementBillListScope =
| "settled"
| "adjustment";
export type SettlementCreditLedgerRow = {
entry_kind: string;
id: number;
row_key: string;
txn_no: string;
player_id: number;
site_code?: string | null;
username?: string | null;
nickname?: string | null;
site_player_id?: string | null;
biz_type: string;
biz_no?: string | null;
ref_type?: string | null;
ref_id?: number | null;
direction: 1 | 2;
amount: number;
amount_formatted?: string;
signed_amount?: number;
currency_code?: string;
status: string;
created_at?: string | null;
ledger_source: string;
funding_mode?: string | null;
direct_agent_label?: string | null;
parent_agent_label?: string | null;
play_code?: string | null;
draw_no?: string | null;
ticket_item_id?: number | null;
};
export async function getCreditLedger(params: {
admin_site_id: number;
settlement_period_id: number;
entry_kind?: "credit";
bet_flow_only?: boolean;
bet_flow_display?: "simple";
player_account?: string;
reason?: string;
page?: number;
per_page?: number;
}): Promise<{
items: SettlementCreditLedgerRow[];
total: number;
page: number;
per_page: number;
ledger_source: string;
}> {
return adminRequest.get(`${A}/credit-ledger`, { params });
}
export async function getSettlementBills(params?: {
settlement_period_id?: number;
admin_site_id?: number;
bill_type?: string;
scope?: SettlementBillListScope;
}): Promise<{ items: SettlementBillRow[] }> {
bill_id?: number;
keyword?: string;
page?: number;
per_page?: number;
}): Promise<{
items: SettlementBillRow[];
total?: number;
page?: number;
per_page?: number;
}> {
return adminRequest.get(`${A}/settlement-bills`, { params });
}
@@ -163,70 +233,13 @@ export async function getSettlementAdjustments(params?: {
return adminRequest.get(`${A}/settlement-adjustments`, { params });
}
export type SettlementLedgerRow = {
entry_kind: "credit" | "payment" | "adjustment";
id: number;
row_key?: string;
txn_no: string;
player_id: number;
site_code?: string;
site_player_id?: string | null;
username?: string | null;
nickname?: string | null;
biz_type: string;
type?: string;
biz_no?: string | null;
direction: number;
amount: number;
amount_formatted?: string;
signed_amount?: number;
currency_code?: string;
status: string;
created_at?: string | null;
ledger_source: string;
funding_mode?: string;
auth_source?: string | null;
settlement_bill_id?: number | null;
bill_status?: string | null;
bill_type?: string | null;
bill_unpaid_amount?: number | null;
available_actions?: string[];
};
/** @deprecated Use {@link SettlementLedgerRow} */
export type CreditLedgerRow = SettlementLedgerRow;
export async function getCreditLedger(params?: {
admin_site_id?: number;
settlement_period_id?: number;
player_id?: number;
player_account?: string;
txn_no?: string;
reason?: string;
biz_type?: string;
entry_kind?: string;
bill_status?: string;
actionable_only?: boolean;
bad_debt_only?: boolean;
created_from?: string;
created_to?: string;
page?: number;
per_page?: number;
}): Promise<{
items: SettlementLedgerRow[];
total: number;
page: number;
per_page: number;
ledger_source: string;
}> {
return adminRequest.get(`${A}/credit-ledger`, { params });
}
export type RebateAllocationRow = {
id: number;
rebate_record_id: number;
settlement_bill_id?: number;
participant_type: string;
participant_id: number;
participant_label?: string;
actual_share_rate: number;
allocated_amount: number;
allocation_rule: string;

View File

@@ -1,5 +1,5 @@
import { redirect } from "next/navigation";
export default function AdminConfigRebateRedirectPage() {
redirect("/admin/rules/odds#rebate");
redirect("/admin/rules/odds");
}

View File

@@ -81,7 +81,9 @@ export function AdminPerPagePicker({
}}
>
<SelectTrigger id={selectId} size="sm" className="w-[6.75rem]">
<SelectValue placeholder={t("pagination.selectPlaceholder", { defaultValue: "Select" })} />
<SelectValue>
{(v) => (v == null || v === "" ? String(perPage) : String(v))}
</SelectValue>
</SelectTrigger>
<SelectContent align="start" sideOffset={6}>
{ADMIN_LIST_PER_PAGE_OPTIONS.map((n) => (

View File

@@ -30,7 +30,7 @@ export function AdminNoResourceState({
<div
className={cn(
"flex w-full flex-col items-center justify-center text-center",
compact ? "gap-2 py-4" : "gap-3 py-8",
compact ? "min-h-[120px] gap-2 py-4" : "min-h-[200px] gap-3 py-8",
className,
)}
role="status"
@@ -41,14 +41,14 @@ export function AdminNoResourceState({
width={compact ? 120 : 160}
height={compact ? 120 : 160}
className={cn(
"h-auto w-auto object-contain",
"h-auto w-auto object-contain self-center object-center -mt-4",
compact ? "max-h-24 max-w-[120px]" : "max-h-40 max-w-[160px]",
imageClassName,
)}
/>
<p
className={cn(
"text-muted-foreground",
"text-muted-foreground self-center",
compact ? "text-[11px] leading-snug" : "text-sm",
)}
>

View File

@@ -14,6 +14,7 @@ import {
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
useSidebar,
} from "@/components/ui/sidebar";
import {
ADMIN_NAV_GROUP_ICON,
@@ -29,7 +30,7 @@ const NAV_BTN =
"h-8 gap-2 px-2.5 py-0 text-[13px] leading-snug font-normal text-sidebar-foreground/90 hover:text-sidebar-accent-foreground [&_svg]:size-4";
const NAV_ACTIVE = "data-active:bg-red-600 data-active:text-white data-active:font-medium data-active:shadow-sm";
const SUB_NAV =
"h-8 min-h-8 rounded-sm px-2.5 py-0 text-sm leading-snug font-normal text-sidebar-foreground/90 hover:text-sidebar-accent-foreground data-[size=md]:text-sm data-[size=sm]:text-sm [&>span]:text-sm";
"h-8 min-h-8 gap-2 rounded-sm px-2.5 py-0 text-sm leading-snug font-normal text-sidebar-foreground/90 hover:text-sidebar-accent-foreground data-[size=md]:text-sm data-[size=sm]:text-sm [&>span]:text-sm [&_svg]:size-4";
function isActive(
pathname: string,
@@ -101,6 +102,7 @@ function NavSubLeaf({
pathname: string;
t: TFunction;
}): ReactElement {
const Icon = resolveAdminNavIcon(item.segment);
const active = isActive(pathname, item);
const label = adminNavLabel(item.segment, t, item.label);
@@ -112,6 +114,7 @@ function NavSubLeaf({
render={<Link href={item.href} />}
className={cn(SUB_NAV, NAV_ACTIVE)}
>
<Icon aria-hidden />
<span>{label}</span>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
@@ -175,7 +178,9 @@ export function AdminSidebarNav({
}): ReactElement {
const { t } = useTranslation("common");
const pathname = usePathname();
const { state } = useSidebar();
const navGroups = useMemo(() => groupAdminNavItems(items), [items]);
const flatItems = useMemo(() => navGroups.flatMap((g) => g.items), [navGroups]);
const [openGroups, setOpenGroups] = useState<Record<AdminNavGroup, boolean>>(() =>
defaultOpenGroups(navGroups, pathname),
@@ -195,6 +200,16 @@ export function AdminSidebarNav({
});
}, [pathname, navGroups]);
if (state === "collapsed") {
return (
<SidebarMenu className="gap-0.5 px-1.5 py-1.5">
{flatItems.map((item) => (
<NavLeaf key={item.segment} item={item} pathname={pathname} t={t} />
))}
</SidebarMenu>
);
}
const overview = navGroups.find((g) => g.group === "overview");
const collapsible = navGroups.filter((g) => g.group !== "overview");

View File

@@ -0,0 +1,128 @@
"use client";
import Link from "next/link";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
export function adminSubnavItemClassName(active: boolean, disabled = false): string {
if (disabled) {
return "border-b-2 border-transparent px-4 py-3 text-sm font-medium text-muted-foreground/45 cursor-not-allowed";
}
return cn(
"relative -mb-px shrink-0 border-b-2 px-4 py-3 text-sm font-medium transition-colors",
active
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:border-border/80 hover:text-foreground",
);
}
type AdminSubnavProps = {
children: ReactNode;
className?: string;
/** Accessible name for the tab list */
"aria-label": string;
};
/** Underline-style horizontal tab bar used across admin page headers. */
export function AdminSubnav({ children, className, "aria-label": ariaLabel }: AdminSubnavProps) {
return (
<nav
aria-label={ariaLabel}
className={cn(
"flex w-full flex-wrap items-end gap-1 border-b border-border/60 px-1",
className,
)}
>
{children}
</nav>
);
}
type AdminSubnavLinkProps = {
href: string;
active: boolean;
children: ReactNode;
className?: string;
disabled?: boolean;
disabledTitle?: string;
};
export function AdminSubnavLink({
href,
active,
children,
className,
disabled = false,
disabledTitle,
}: AdminSubnavLinkProps) {
if (disabled) {
return (
<span className={cn(adminSubnavItemClassName(false, true), className)} title={disabledTitle}>
{children}
</span>
);
}
return (
<Link href={href} className={cn(adminSubnavItemClassName(active), className)}>
{children}
</Link>
);
}
type AdminSubnavButtonProps = {
active: boolean;
onClick: () => void;
children: ReactNode;
className?: string;
count?: number;
disabled?: boolean;
};
export function AdminSubnavButton({
active,
onClick,
children,
className,
count,
disabled = false,
}: AdminSubnavButtonProps) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className={cn(adminSubnavItemClassName(active, disabled), className)}
>
{children}
{count !== undefined && count > 0 ? (
<span
className={cn(
"ml-1.5 inline-flex min-w-[1.25rem] items-center justify-center rounded-full px-1.5 py-0.5 text-[10px] font-medium tabular-nums",
active ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground",
)}
>
{count}
</span>
) : null}
</button>
);
}
type AdminSubnavBarProps = {
children: ReactNode;
trailing?: ReactNode;
className?: string;
};
/** Wrapper for subnav plus optional trailing controls (site selector, back link, etc.). */
export function AdminSubnavBar({ children, trailing, className }: AdminSubnavBarProps) {
return (
<div className={cn("flex w-full flex-wrap items-end justify-between gap-3", className)}>
{children}
{trailing ? <div className="shrink-0 pb-1">{trailing}</div> : null}
</div>
);
}

View File

@@ -24,7 +24,7 @@ function Tabs({
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
"group/tabs-list inline-flex w-fit items-end justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
@@ -59,9 +59,10 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:-mb-px group-data-[variant=line]/tabs-list:h-auto group-data-[variant=line]/tabs-list:shrink-0 group-data-[variant=line]/tabs-list:flex-none group-data-[variant=line]/tabs-list:rounded-none group-data-[variant=line]/tabs-list:border-b-2 group-data-[variant=line]/tabs-list:border-transparent group-data-[variant=line]/tabs-list:px-4 group-data-[variant=line]/tabs-list:py-3 group-data-[variant=line]/tabs-list:text-muted-foreground group-data-[variant=line]/tabs-list:hover:border-border/80 group-data-[variant=line]/tabs-list:hover:text-foreground group-data-[variant=line]/tabs-list:data-active:border-primary group-data-[variant=line]/tabs-list:data-active:bg-transparent group-data-[variant=line]/tabs-list:data-active:text-primary dark:group-data-[variant=line]/tabs-list:data-active:border-primary dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:after:hidden group-data-[variant=line]/tabs-list:data-active:after:opacity-0",
className
)}
{...props}

View File

@@ -17,6 +17,7 @@
"rulesPlaysTitle": "Play rules",
"rulesOddsTitle": "Odds & rebate",
"rulesOddsDescription": "Odds matrix and rebate rates on one page, sharing the same odds version line.",
"rulesOddsDescriptionShort": "Pick a play on the left, edit odds and rebate on the right, then save and publish.",
"riskCapTitle": "Risk cap rules"
},
"hub": {
@@ -411,6 +412,16 @@
"oddsConfig": "Odds"
},
"currentSelection": "Selection: {{category}} / {{play}}",
"playSelectPlaceholder": "Select play type",
"readOnlyBanner": "This version is read-only. Create a draft to edit odds and rebate.",
"table": {
"prizeScope": "Prize scope",
"multiplier": "Odds multiplier"
},
"draftBar": {
"unsaved": "Unsaved changes",
"saved": "Changes kept in local draft"
},
"playGroups": {
"bigSmall": "Big / small",
"combo4": "4D position",
@@ -420,10 +431,13 @@
},
"summary": {
"title": "Summary",
"version": "Version",
"contextTitle": "Version & tips",
"version": "Editing version",
"activeVersion": "Active version",
"statusLabel": "Status",
"readOnlyTag": "Read-only",
"readOnlyHint": "This version is read-only. Create a draft to make changes.",
"draftHint": "Save draft changes before publishing; publish affects new tickets only.",
"activeHint": "This version is active; new tickets use these settings."
},
"tabs": {

View File

@@ -27,6 +27,7 @@
"playerAllPlayersHint": "If no player is selected, the reconcile job will cover all players in the chosen date range.",
"createSummaryAll": "A manual reconcile will run for all players from {{from}} to {{to}}.",
"createSummaryPlayer": "A manual reconcile will run for player {{player}} from {{from}} to {{to}}.",
"createSummaryPending": "Choose a complete reconcile date range before creating a job.",
"jobsTitle": "Reconcile jobs",
"jobsDesc": "Use the action on the right to open paginated item details.",
"refresh": "Refresh",

View File

@@ -1,5 +1,50 @@
{
"title": "Settlement center",
"subtitle": "Period close, bill confirm, and payments",
"subtitleList": "Period list: open/close periods; summary columns included — use row actions for bills and bet ledger.",
"period": {
"title": "Period",
"statusCompleted": "Completed",
"pipelineShare": "{{count}} ledger entries",
"billTodo": "Pending {{p}} · Awaiting {{a}}",
"openTitle": "Open period",
"openBtn": "Open period",
"closeNeedLedger": "No ledger activity yet. Complete draw settlement first.",
"closeDialogTitle": "Close period",
"closeDialogDesc": "Summarize {{range}} and generate bills.",
"closeDialogShare": "{{count}} ledger entries",
"closeDialogUnsettled": "{{count}} tickets still unsettled",
"closeDialogIrreversible": "Cannot undo. Use adjustments or reversals to fix errors.",
"closeDialogConfirm": "Close period"
},
"periodDetail": {
"back": "Back to periods",
"notFound": "Period not found or site changed. Go back to the list."
},
"periodTable": {
"title": "Periods",
"statusFilter": "Status",
"range": "Period",
"ledgerCount": "Ledger",
"pending": "Pending",
"awaiting": "Awaiting",
"shareLedger": "Share ledger",
"gameWinLoss": "Win/loss total",
"platformWinLoss": "Platform win/loss",
"agentWinLoss": "Agent win/loss",
"basicRebate": "Rebate total",
"unsettledTickets": "Unsettled tickets",
"openReportHint": "Open period: share/win-loss from in-period ledger; bill count updates after close.",
"viewDetail": "View details",
"close": "Close",
"closeNow": "Close now",
"hasOpen": "Period {{range}} is open. Close it before opening a new one.",
"emptyOpenHint": "No periods yet. Click Open period in the toolbar.",
"emptyReadOnly": "No period records.",
"emptyFiltered": "No rows match the filter. Reset filters.",
"emptyFilteredOpen": "The open period is hidden by the filter. Choose All or Open.",
"readOnlyHint": "Bound agent accounts cannot open or close site periods."
},
"header": {
"subtitle": "Credit-line settlement",
"statusRunning": "Period open",
@@ -9,18 +54,16 @@
"subnav": {
"label": "Settlement center navigation"
},
"nav": {
"aria": "Settlement center navigation",
"group": {
"hub": "Workbench",
"finance": "Finance",
"ledger": "Ledger",
"bills": "Bills"
"workbench": {
"viewPeriod": "Period",
"closePreset": "Close · {{label}}",
"closeNoData": "Close failed: no share ledger in period. Run credit game settlement first.",
"openPeriodPipeline": "Open {{range}} · {{share}} share entries"
},
"overview": "Overview",
"nav": {
"periods": "Periods",
"ledger": "Account ledger",
"bills": "Bills",
"ledger": "Account ledger",
"creditLedger": "Credit ledger",
"playerBills": "Player bills",
"agentBills": "Agent bills",
@@ -33,6 +76,7 @@
},
"filters": {
"period": "Period",
"statusAll": "All",
"allPeriods": "All periods",
"statusOpen": "Open",
"statusClosed": "Closed",
@@ -54,7 +98,9 @@
"badDebtIntro": "Bad debt write-off entries linked to original bills."
},
"creditLedger": {
"intro": "In-period credit holds, bill payments, adjustments, and bad debt. Use ⋯ on a row to confirm, record payment, adjust, reverse, or write off (when a bill is linked).",
"periodIntro": "Credit-line bets in this period: bet hold and draw settlement (merged per ticket).",
"emptyPeriod": "No bet ledger entries in this period. Ensure credit players placed bets and draws were settled.",
"intro": "Ledger entries for the selected period.",
"columns": {
"txn": "Txn ID",
"player": "Player",
@@ -83,9 +129,16 @@
"reason": {
"payment_record": "Bill payment",
"bet_hold": "Bet hold",
"game_settlement": "Draw settlement",
"game_settlement_win": "Draw settlement credit",
"bet_hold_release": "Hold release",
"game_settlement_loss": "Draw settlement",
"settlement_confirm": "Period confirm"
"game_settlement_loss": "Draw settlement debit",
"settlement_payout": "Settlement payout",
"settlement_confirm": "Period confirm",
"adjustment": "Adjustment",
"reversal": "Reversal",
"bad_debt": "Bad debt",
"share_ledger": "Share ledger"
}
},
"columns": {
@@ -132,6 +185,51 @@
"viewBill": "View bill",
"billDetail": "Bill detail"
},
"billDisplay": {
"settlementFlow": "Who pays whom",
"settlementAmount": "Settlement amount",
"pays": "Pays",
"paysShort": "Pays",
"howAmountWorks": "How the amount is calculated",
"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.",
"playerGross": "Game win/loss",
"playerLostHint": "Player lost; owes agent",
"playerWonHint": "Player won; agent owes player",
"playerNet": "Player net payable",
"playerNetReceive": "Agent pays player",
"teamGross": "Team game win/loss",
"teamGrossHint": "Includes this agent and all downline players",
"teamGrossShort": "Team",
"playerGrossShort": "Player",
"teamRebate": "Team rebate",
"teamNet": "Team net",
"rebate": "Rebate",
"agentShareKeep": "{{agent}} share kept",
"agentShareKeepHint": "Profit retained at this tier by share ratio",
"agentNet": "{{agent}} pays upline",
"agentNetReceive": "Upline pays {{agent}}",
"billOwner": "Bill owner",
"billCounterparty": "Counterparty",
"unpaidPendingConfirm": "Confirm the bill before recording payment",
"unpaidAwaitingPayment": "Record offline payment",
"fullySettled": "Fully settled this period",
"recordReceiptFrom": "Record receipt ({{payer}} → {{payee}})",
"recordPayoutTo": "Record payout ({{payer}} → {{payee}})",
"rebateAllocationsHint": "How rebate is allocated across agent tiers.",
"payment": "Payment",
"flowHint": {
"playerPayAgent": "Player should settle with the direct agent",
"agentPayPlayer": "Direct agent should settle with the player",
"agentPayUpstream": "This agent should settle with upline / platform",
"upstreamPayAgent": "Upline / platform should settle with this agent",
"adjustment": "Adjustment settles separately and keeps the original bill relation",
"badDebt": "Write off unpaid amount and archive it as bad debt",
"reversal": "Reverse the original bill impact according to reversal rules",
"generic": "Apply payment or adjustment based on this bill relation"
},
"hierarchyHint": "One period creates multiple bills: players pay their agent first; each agent keeps share profit and remits the rest upline. Gross win/loss may match across rows while settlement amounts step down."
},
"ledgerPanel": {
"search": "Search",
"searchBtn": "Search",
@@ -144,18 +242,34 @@
"optional": "Optional",
"billStatus": "Bill status",
"dateRange": "Date range",
"rowPosted": "Posted",
"category": {
"all": "All",
"credit": "Credit holds",
"payment": "Payments",
"adjustment": "Adjust / reverse",
"badDebt": "Bad debt",
"actionable": "Needs action"
}
"rowPosted": "Posted"
},
"billsPanel": {
"intro": "Share bills after period close. Filter by type or status; open detail to confirm or record payment.",
"hierarchyHint": "One period creates multiple bills: players pay their agent first; each agent keeps share profit and remits the rest upline. Gross win/loss may match across rows while settlement amounts step down.",
"quickFilter": {
"title": "Which settlement layer do you want to review",
"desc": "Choose player or agent settlement first, then narrow by status or bill id.",
"allTitle": "All bills",
"allHint": "View player, agent, adjustment, and bad debt bills together",
"playerTitle": "Player settlement",
"playerHint": "Credit settlement between players and their direct agent",
"agentTitle": "Agent settlement",
"agentHint": "Tier settlement between agents and their upline / platform"
},
"activeHint": {
"all": "Current focus: all settlement documents in this period",
"player": "Current focus: credit settlement between players and direct agents",
"agent": "Current focus: payments between agents and their upline / platform"
},
"layer": {
"player": "Player vs direct agent",
"agent": "Agent vs upline / platform",
"adjustment": "Settlement adjustment",
"badDebt": "Bad debt archive",
"reversal": "Historical bill reversal",
"generic": "Settlement supporting document"
},
"category": {
"all": "All",
"player": "Player bills",
@@ -179,12 +293,12 @@
"badDebt": { "title": "Bad debt" }
},
"empty": {
"noSite": "Select an integration site.",
"noPeriods": "Open and close a period under Periods first.",
"noSite": "Select a site.",
"noPeriods": "Close the current period first.",
"noClosed": "Close a period to generate bills.",
"noBadDebt": "No bad debt write-offs yet.",
"noCreditLedger": "No credit ledger rows in this period. Check credit players placed bets and the period date range.",
"billsNeedClose": "Share bills appear after period close. If credit ledger has rows but bills are empty, settle draws then close the period."
"noBadDebt": "No bad debt records.",
"noCreditLedger": "No ledger entries in this period.",
"billsNeedClose": "Bills are created after period close."
},
"periods": {
"loadFailed": "Failed to load periods"

View File

@@ -3,9 +3,9 @@
"subnavLabel": "Wallet sub pages",
"subnavTransactions": "Main-site wallet txns",
"subnavTransferOrders": "Main-site transfers",
"scopeHint": "This area is for main-site wallet mode (wallet txns and transfers). For credit-line bet holds and settlement entries, see",
"scopeHintSettlementLink": "Settlement center → Credit ledger",
"scopeHintSettlement": "Settlement center → Credit ledger",
"scopeHint": "This area is for main-site wallet mode (wallet txns and transfers). For credit-line period settlement, see",
"scopeHintSettlementLink": "Settlement center",
"scopeHintSettlement": "Settlement center",
"ledgerChannel": "Ledger",
"ledgerCredit": "Credit ledger",
"ledgerWallet": "Wallet txn",

View File

@@ -17,6 +17,7 @@
"rulesPlaysTitle": "खेल नियम",
"rulesOddsTitle": "बाधा र रिबेट",
"rulesOddsDescription": "बाधा म्याट्रिक्स र रिबेट दर एउटै पृष्ठमा, एउटै बाधा संस्करण लाइनमा।",
"rulesOddsDescriptionShort": "बायाँबाट खेल छान्नुहोस्, दायाँबाट बाधा र रिबेट सम्पादन गर्नुहोस्, त्यसपछि सेभ र प्रकाशन गर्नुहोस्।",
"riskCapTitle": "जोखिम क्याप संस्करण"
},
"hub": {
@@ -394,6 +395,16 @@
"oddsConfig": "बाधा सेटिङ"
},
"currentSelection": "हालको छनोट: {{category}} / {{play}}",
"playSelectPlaceholder": "खेल प्रकार छान्नुहोस्",
"readOnlyBanner": "यो संस्करण पढ्न मात्र हो। बाधा र रिबेट सम्पादन गर्न ड्राफ्ट बनाउनुहोस्।",
"table": {
"prizeScope": "पुरस्कार दायरा",
"multiplier": "बाधा गुणक"
},
"draftBar": {
"unsaved": "नसेभ परिवर्तनहरू",
"saved": "परिवर्तन स्थानीय ड्राफ्टमा राखिएको छ"
},
"playGroups": {
"bigSmall": "ठूलो / सानो",
"combo4": "4D स्थिति",
@@ -403,10 +414,13 @@
},
"summary": {
"title": "सारांश",
"version": "संस्करण",
"contextTitle": "संस्करण र सुझाव",
"version": "सम्पादन संस्करण",
"activeVersion": "सक्रिय संस्करण",
"statusLabel": "स्थिति",
"readOnlyTag": "पढ्न मात्र",
"readOnlyHint": "यो संस्करण पढ्न मात्र हो। परिवर्तन गर्न ड्राफ्ट बनाउनुहोस्।",
"draftHint": "प्रकाशन अघि ड्राफ्ट सेभ गर्नुहोस्; प्रकाशनले नयाँ टिकटमा मात्र असर गर्छ।",
"activeHint": "यो संस्करण सक्रिय छ; नयाँ टिकट यही सेटिङ प्रयोग गर्छ।"
},
"tabs": {

View File

@@ -23,6 +23,7 @@
"playerAllPlayersHint": "खेलाडी नछानेमा, छनोट गरिएको मिति दायराभित्र सबै खेलाडीका लागि मिलान चलाइनेछ।",
"createSummaryAll": "{{from}} देखि {{to}} सम्म सबै खेलाडीका लागि म्यानुअल मिलान चलाइनेछ।",
"createSummaryPlayer": "खेलाडी {{player}} का लागि {{from}} देखि {{to}} सम्म म्यानुअल मिलान चलाइनेछ।",
"createSummaryPending": "कार्य सिर्जना गर्नु अघि पूरा मिलान मिति दायरा छान्नुहोस्।",
"jobsTitle": "मिलान कार्यहरू",
"jobsDesc": "दायाँपट्टिको कार्यबाट विवरण खोल्नुहोस्।",
"refresh": "रिफ्रेस",

View File

@@ -164,7 +164,7 @@
"periodPlaceholder": "选择账期",
"allPeriods": "全部账期",
"filteredByPeriodRange": "账期 {{range}} 的账单",
"emptyNoPeriodsManage": "尚无账期与账单。请在下方「账期管理」点「本周」开期,到期后关账,账单会自动出现在这里。",
"emptyNoPeriodsManage": "尚无账单。请在结算中心完成「关账出账」后,账单会出现在此处。",
"emptyNoPeriodsAgent": "尚无账单。账期由上级或平台关账后自动生成,无需您手动筛选时间。",
"emptyNoClosed": "当前没有已关账的账期,账单尚未生成。请等待负责人关账后再查看。",
"typePlayer": "玩家账单",
@@ -273,7 +273,7 @@
"range": "账期",
"statusOpen": "进行中",
"statusClosed": "已关账",
"empty": "尚无账期。点「本周」等快捷账期开期,到期后在此关账。",
"empty": "尚无账期,请选择快捷账期开期。",
"start": "开始",
"end": "结束",
"status": "状态",

View File

@@ -17,6 +17,7 @@
"rulesPlaysTitle": "投注规则",
"rulesOddsTitle": "赔率与回水",
"rulesOddsDescription": "赔率矩阵与回水比例在同一页维护,共用赔率版本线。",
"rulesOddsDescriptionShort": "左侧选玩法,右侧改赔率与回水;修改后记得保存草稿并发布。",
"riskCapTitle": "限额版本"
},
"hub": {
@@ -420,6 +421,16 @@
"oddsConfig": "赔率配置"
},
"currentSelection": "当前选择:{{category}} / {{play}}",
"playSelectPlaceholder": "选择玩法",
"readOnlyBanner": "当前版本只读,需先创建草稿才能修改赔率与回水。",
"table": {
"prizeScope": "奖级范围",
"multiplier": "赔率倍数"
},
"draftBar": {
"unsaved": "有未保存的修改",
"saved": "修改已同步到本地草稿"
},
"playGroups": {
"bigSmall": "大小类",
"combo4": "组合类",
@@ -429,10 +440,13 @@
},
"summary": {
"title": "配置摘要",
"version": "版本",
"contextTitle": "版本与提示",
"version": "当前编辑版本",
"activeVersion": "线上生效版本",
"statusLabel": "状态",
"readOnlyTag": "只读",
"readOnlyHint": "当前为只读版本,如需修改请先创建草稿。",
"draftHint": "草稿中的修改需保存后才会写入版本;发布后对后续新注单生效。",
"activeHint": "当前版本已生效,新注单将按此配置计算。"
},
"tabs": {

View File

@@ -27,6 +27,7 @@
"playerAllPlayersHint": "不选择玩家时,会按日期范围对全量玩家做一次人工对账。",
"createSummaryAll": "将对 {{from}} 至 {{to}} 的全量玩家发起人工对账。",
"createSummaryPlayer": "将对玩家 {{player}} 在 {{from}} 至 {{to}} 的数据发起人工对账。",
"createSummaryPending": "请选择完整的对账日期范围后,再创建任务。",
"jobsTitle": "对账任务",
"jobsDesc": "在右侧操作中查看差异明细与分页。",
"refresh": "刷新",

View File

@@ -1,26 +1,55 @@
{
"title": "结算中心",
"header": {
"subtitle": "信用占成账务",
"statusRunning": "账期进行中",
"statusIdle": "等待开期",
"statusCompleted": "账期已结清"
"subtitle": "账期关账、账单确认与收付登记",
"subtitleList": "账期列表:开账、关账;列表已含账期汇总,行内「查看详情」进入账单与下注流水。",
"period": {
"title": "期",
"statusCompleted": "已结清",
"pipelineShare": "流水 {{count}} 笔",
"billTodo": "待确认 {{p}} · 待收付 {{a}}",
"openTitle": "开账",
"openBtn": "开账",
"closeNeedLedger": "本期暂无流水,请先完成开奖结算。",
"closeDialogTitle": "确认关账",
"closeDialogDesc": "将汇总 {{range}} 内的流水并生成账单。",
"closeDialogShare": "流水 {{count}} 笔",
"closeDialogEmpty": "本期暂无占成流水,关账后不会生成账单。",
"closeDialogUnsettled": "仍有 {{count}} 笔注单未结算",
"closeDialogIrreversible": "关账后不可撤销,差错请通过调账或冲正处理。",
"closeDialogConfirm": "确认关账"
},
"subnav": {
"label": "结算中心导航"
"periodDetail": {
"back": "返回账期列表",
"notFound": "账期不存在或已切换站点,请返回列表。"
},
"periodTable": {
"title": "账期管理",
"statusFilter": "状态",
"range": "账期",
"ledgerCount": "流水",
"pending": "待确认",
"awaiting": "待收付",
"shareLedger": "占成流水",
"gameWinLoss": "输赢合计",
"platformWinLoss": "平台输赢",
"agentWinLoss": "代理输赢",
"basicRebate": "退水合计",
"unsettledTickets": "未结算注单",
"openReportHint": "进行中账期:占成/输赢来自账期内流水;账单数在关账后更新。",
"viewDetail": "查看详情",
"close": "关账",
"closeNow": "立即关账",
"hasOpen": "已有进行中账期 {{range}},须先关账才能开新期。",
"emptyOpenHint": "暂无账期,请点击工具栏「开账」创建。",
"emptyReadOnly": "暂无账期记录。",
"emptyFiltered": "筛选结果为空,请重置筛选。",
"emptyFilteredOpen": "当前筛选未包含进行中的账期,请选「全部」或「进行中」。",
"readOnlyHint": "绑定代理账号不可开/关账期,仅可查看与收付。"
},
"nav": {
"aria": "结算中心导航",
"group": {
"hub": "工作台",
"finance": "账务",
"ledger": "账务流水",
"bills": "账单管理"
},
"overview": "概览",
"periods": "账期管理",
"ledger": "账务流水",
"periods": "账期",
"bills": "账单",
"ledger": "账务流水",
"creditLedger": "信用流水",
"playerBills": "玩家账单",
"agentBills": "代理账单",
@@ -33,6 +62,7 @@
},
"filters": {
"period": "账期范围",
"statusAll": "全部",
"allPeriods": "全部账期",
"statusOpen": "进行中",
"statusClosed": "已关账",
@@ -54,7 +84,9 @@
"badDebtIntro": "坏账核销产生的调账流水,关联原账单。"
},
"creditLedger": {
"intro": "账期内信用占用、账单收付、调账与坏账等流水;行内「⋯」可确认账单、登记收付、调账、冲正或坏账(需关联账单)。",
"periodIntro": "待开奖显示「下注冻结」(非扣款);已开奖每注单仅一条「开奖结算」输赢,不会与冻结重复出现。",
"emptyPeriod": "本账期暂无下注流水。请确认信用盘玩家已在账期内下注并完成开奖结算。",
"intro": "查询账期内的额度变动、收付与调账记录。",
"columns": {
"txn": "流水号",
"player": "玩家",
@@ -82,15 +114,30 @@
},
"reason": {
"payment_record": "账单收付",
"bet_hold": "下注占用",
"bet_hold": "下注冻结",
"freezeAmount": "冻结 {{amount}}",
"game_settlement": "开奖结算",
"game_settlement_win": "开奖结算入账",
"bet_hold_release": "占用释放",
"game_settlement_loss": "开奖结算扣款",
"settlement_confirm": "账期结算确认"
"settlement_payout": "结算收付入账",
"settlement_confirm": "账期结算确认",
"adjustment": "补差",
"reversal": "冲正",
"bad_debt": "坏账核销",
"share_ledger": "占成流水"
}
},
"columns": {
"billId": "账单 ID",
"period": "账期",
"type": "类型",
"playerAccount": "玩家账号",
"playerId": "玩家 ID",
"directAgent": "直属代理",
"superiorAgent": "上级代理",
"play": "玩法",
"drawNo": "期号",
"owner": "本方",
"counterparty": "对方",
"gross": "输赢",
@@ -132,6 +179,51 @@
"viewBill": "查看账单",
"billDetail": "账单详情"
},
"billDisplay": {
"settlementFlow": "谁付谁",
"settlementAmount": "结算金额",
"pays": "应付",
"paysShort": "应付",
"howAmountWorks": "金额怎么来的",
"playerBreakdownIntro": "玩家只与直属代理结算,净额 = 输赢 回水。",
"agentBreakdownIntro": "代理只与直属上级结算,净额 = 团队净额 本级占成。",
"playerGross": "游戏输赢",
"playerLostHint": "玩家输了,应付代理",
"playerWonHint": "玩家赢了,代理应付玩家",
"playerNet": "玩家应付净额",
"playerNetReceive": "代理应付玩家",
"teamGross": "团队游戏输赢",
"teamGrossHint": "含本级及下级玩家的合计",
"teamGrossShort": "团队",
"playerGrossShort": "玩家",
"teamRebate": "团队回水",
"teamNet": "团队净额",
"rebate": "回水",
"agentShareKeep": "{{agent}} 本级占成",
"agentShareKeepHint": "本级按占成比例留下的利润",
"agentNet": "{{agent}} 应付上级",
"agentNetReceive": "上级应付 {{agent}}",
"billOwner": "账单主体",
"billCounterparty": "结算对手",
"unpaidPendingConfirm": "确认账单后可登记收付",
"unpaidAwaitingPayment": "请登记线下收付",
"fullySettled": "本期已结清",
"recordReceiptFrom": "登记收款({{payer}} 付给 {{payee}}",
"recordPayoutTo": "登记付款({{payer}} 付给 {{payee}}",
"rebateAllocationsHint": "各层级代理对回水的承担明细。",
"payment": "收付",
"flowHint": {
"playerPayAgent": "玩家应向直属代理结算",
"agentPayPlayer": "直属代理应向玩家结算",
"agentPayUpstream": "本级代理应向上级 / 平台结算",
"upstreamPayAgent": "上级 / 平台应向本级代理结算",
"adjustment": "补差单独结转,不改变原账单主体关系",
"badDebt": "核销未结金额,并生成坏账归档记录",
"reversal": "冲正原账单影响,按冲正规则回退",
"generic": "按账单结算关系执行收付或调账"
},
"hierarchyHint": "同一账期会生成多笔账单:玩家先与直属代理结,代理扣除本级占成后再向上级缴纳。因此「输赢」可能相同,但「结算金额」会逐级减少。"
},
"ledgerPanel": {
"search": "搜索",
"searchBtn": "搜索",
@@ -144,30 +236,67 @@
"optional": "可选",
"billStatus": "账单状态",
"dateRange": "时间范围",
"rowPosted": "已记账",
"category": {
"all": "全部",
"credit": "信用占用",
"payment": "收付",
"adjustment": "调账 / 冲正",
"badDebt": "坏账",
"actionable": "待操作"
}
"rowPosted": "已记账"
},
"billsPanel": {
"intro": "关账后生成的占成账单;可按类型与状态筛选,打开详情进行确认与收付。",
"billId": "账单 ID",
"ownerKeyword": "本方 / 对方",
"ownerKeywordPh": "玩家账号、代理名称",
"status": "账单状态",
"billType": "账单类型",
"filterAll": "全部状态",
"filterAllTypes": "全部类型",
"filterAdjustment": "调账 / 冲正",
"optional": "可选",
"searchBtn": "搜索",
"reset": "重置",
"refresh": "刷新",
"clientFilterHint": "本方筛选:显示 {{shown}} / {{total}} 条",
"category": {
"all": "全部",
"player": "玩家账单",
"agent": "代理账单",
"pendingConfirm": "待确认",
"awaitingPayment": "待收付"
}
},
"quickFilter": {
"title": "当前想看哪一层结算",
"desc": "先区分结玩家还是结代理,再做状态和账单检索会更直观。",
"allTitle": "全部账单",
"allHint": "同时查看玩家、代理、补差和坏账单据",
"playerTitle": "结玩家",
"playerHint": "玩家与直属代理之间的信用结算",
"agentTitle": "结代理",
"agentHint": "代理与上级 / 平台之间的层级结算"
},
"activeHint": {
"all": "当前聚焦:账期内全部结算单据",
"player": "当前聚焦:玩家与直属代理的信用结算",
"agent": "当前聚焦:代理向上级 / 平台缴纳或收取结算"
},
"layer": {
"player": "玩家与直属代理结算",
"agent": "代理与上级 / 平台结算",
"adjustment": "结算差异调账",
"badDebt": "坏账核销归档",
"reversal": "历史账单冲正",
"generic": "结算辅助单据"
},
"rowHint": {
"playerOwner": "玩家主体",
"agentOwner": "本级代理",
"adjustmentOwner": "调账归属方",
"badDebtOwner": "坏账归属方",
"reversalOwner": "冲正归属方",
"playerUpline": "直属代理的上级",
"agentUpline": "本单结算上级"
},
"hierarchyHint": "同一账期会生成多笔账单:玩家先与直属代理结,代理扣除本级占成后再向上级缴纳。因此「输赢」可能相同,但「结算金额」会逐级减少。"
},
"panels": {
"workbench": { "title": "工作台" },
"overview": { "title": "结算概览" },
"ledger": { "title": "账务流水" },
"bills": { "title": "账单" },
"bills": { "title": "全部账单" },
"creditLedger": { "title": "信用流水" },
"playerBills": { "title": "玩家账单" },
"agentBills": { "title": "代理账单" },
@@ -178,13 +307,17 @@
"reports": { "title": "账期报表" },
"badDebt": { "title": "坏账核销" }
},
"billsPanel": {
"emptyFiltered": "当前筛选下暂无账单,请改为「全部状态」或重置筛选。",
"emptyClosed": "本期已关账但暂无账单。常见原因:账期内无信用盘玩家的已结算注单,或占成流水不在本账期时间范围内。"
},
"empty": {
"noSite": "请选择接入站点。",
"noPeriods": "请先在「账期管理」开期并关账。",
"noSite": "请选择站点。",
"noPeriods": "请先关账当前账期。",
"noClosed": "请先关账生成账单。",
"noBadDebt": "暂无坏账核销记录。",
"noCreditLedger": "所选账期内暂无信用流水。请确认信用盘玩家已下注且账期时间范围正确。",
"billsNeedClose": "占成账单须先关账才会出现;若上方「信用流水」有数据而账单为空,请完成开奖结算后执行关账。"
"noBadDebt": "暂无坏账记录。",
"noCreditLedger": "账期内暂无流水。",
"billsNeedClose": "账单在关账后生成。请返回账期列表,对本期执行关账」后再查看。"
},
"periods": {
"loadFailed": "账期列表加载失败"

View File

@@ -3,9 +3,9 @@
"subnavLabel": "钱包子页",
"subnavTransactions": "主站钱包流水",
"subnavTransferOrders": "主站转账单",
"scopeHint": "本模块为主站钱包模式:钱包流水与主站转账单。信用盘玩家的下注占用、结算记账请查看",
"scopeHintSettlementLink": "结算中心 → 信用流水",
"scopeHintSettlement": "结算中心 → 信用流水",
"scopeHint": "本模块为主站钱包模式:钱包流水与主站转账单。信用盘玩家的账期结账请查看",
"scopeHintSettlementLink": "结算中心",
"scopeHintSettlement": "结算中心",
"ledgerChannel": "账本",
"ledgerCredit": "信用流水",
"ledgerWallet": "钱包流水",

View File

@@ -0,0 +1,51 @@
/** Base UI Select 会直接显示 `value`;筛选用哨兵时需手动映射展示文案。 */
export const ADMIN_SELECT_FILTER_ALL = "__all__";
export function isAdminSelectFilterAll(raw: unknown): boolean {
const v = raw == null ? "" : String(raw);
return v === "" || v === ADMIN_SELECT_FILTER_ALL;
}
export function adminSelectFilterLabel(
raw: unknown,
allLabel: string,
resolveOptionLabel: (value: string) => string,
): string {
const v = raw == null ? "" : String(raw);
if (isAdminSelectFilterAll(v)) {
return allLabel;
}
return resolveOptionLabel(v);
}
export type AdminSiteOption = { code: string; name?: string | null };
export function adminSiteSelectLabel(
raw: unknown,
sites: readonly AdminSiteOption[],
allLabel: string,
): string {
return adminSelectFilterLabel(raw, allLabel, (code) => {
const site = sites.find((s) => s.code === code);
if (!site) {
return code;
}
return site.name ? `${site.name} (${site.code})` : site.code;
});
}
export function adminSiteCodeLabel(
raw: unknown,
sites: readonly AdminSiteOption[],
placeholder: string,
): string {
const v = raw == null ? "" : String(raw);
if (v === "") {
return placeholder;
}
const site = sites.find((s) => s.code === v);
if (!site) {
return v;
}
return site.name ? `${site.name} (${site.code})` : site.code;
}

View File

@@ -0,0 +1,13 @@
/** 后台展示用站点名:优先中文名,不暴露内部 code。 */
export function formatAdminSiteLabel(
name?: string | null,
code?: string | null,
): string {
const trimmedName = name?.trim();
if (trimmedName) {
return trimmedName;
}
const trimmedCode = code?.trim();
return trimmedCode ?? "—";
}

View File

@@ -58,6 +58,11 @@ const STATUS_TONE_MAP: Record<string, AdminStatusTone> = {
completed: "success",
failed: "danger",
// 代理账期账单
confirmed: "warning",
partial_paid: "warning",
overdue: "danger",
// 注单
pending_confirm: "info",
partial_pending_confirm: "warning",

View File

@@ -2,20 +2,25 @@ import type { LucideIcon } from "lucide-react";
import {
CalendarClock,
CircleDollarSign,
ClipboardList,
Coins,
FileSpreadsheet,
Globe,
KeyRound,
Landmark,
LayoutDashboard,
LogIn,
Network,
Percent,
Scale,
ScrollText,
Receipt,
Settings,
ShieldAlert,
ShieldCheck,
SlidersHorizontal,
Ticket,
Trophy,
UserCog,
Users,
Wallet,
} from "lucide-react";
@@ -29,9 +34,9 @@ export const adminNavIconBySegment: Record<AdminNavItem["segment"], LucideIcon>
agents: Network,
players: Users,
draws: CalendarClock,
rules_plays: SlidersHorizontal,
rules_odds: SlidersHorizontal,
jackpot: CircleDollarSign,
rules_plays: ClipboardList,
rules_odds: Percent,
jackpot: Trophy,
risk_cap: ShieldAlert,
tickets: Ticket,
wallet: Wallet,
@@ -41,9 +46,9 @@ export const adminNavIconBySegment: Record<AdminNavItem["segment"], LucideIcon>
settlement_center: Receipt,
reconcile: Scale,
audit: ScrollText,
admin_users: ShieldCheck,
admin_roles: ShieldCheck,
currencies: CircleDollarSign,
admin_users: UserCog,
admin_roles: KeyRound,
currencies: Coins,
integration: Globe,
settings: Settings,
};

View File

@@ -37,6 +37,7 @@ export type AdminNavItem = {
segment: AdminNavSegment;
nav_group?: AdminNavGroup;
platform_only?: boolean;
agent_hidden?: boolean;
activeMatchPrefix?: string;
requiredAny?: readonly string[];
};

View File

@@ -4,6 +4,7 @@ import type { ComponentType } from "react";
import { ChevronRight, Network, Pencil, Plus, Trash2, Users } from "lucide-react";
import { useTranslation } from "react-i18next";
import { AdminSubnav, AdminSubnavButton } from "@/components/admin/admin-subnav";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
@@ -218,19 +219,23 @@ export function AgentLineDetailPanel({
</div>
</header>
<div className="flex items-center gap-0 overflow-x-auto border-b border-border/60 bg-card px-5 sm:px-6">
<AdminSubnav
aria-label={t("detailTabs", { defaultValue: "代理详情" })}
className="overflow-x-auto bg-card px-4 sm:px-5"
>
{tabs
.filter((tab) => tab.visible)
.map((tab) => (
<TabButton
<AdminSubnavButton
key={tab.key}
active={detailTab === tab.key}
onClick={() => onDetailTabChange(tab.key)}
label={tab.label}
count={tab.count}
/>
>
{tab.label}
</AdminSubnavButton>
))}
</div>
</AdminSubnav>
<div className="min-h-0 flex-1 overflow-y-auto bg-muted/15 px-5 py-5 sm:px-6 sm:py-6">
{detailTab === "overview" ? (
@@ -569,7 +574,7 @@ function DownlineTable({
{child.email ?? "—"}
</TableCell>
<TableCell className="text-right tabular-nums text-xs">
{summary ? `${ratioToPercentUi(summary.total_share_rate)}%` : "—"}
{summary ? `${summary.total_share_rate ?? 0}%` : "—"}
</TableCell>
<TableCell className="text-right tabular-nums text-xs">
{summary ? formatCredit(summary.credit_limit) : "—"}
@@ -660,40 +665,3 @@ function MetricCard({
</div>
);
}
function TabButton({
active,
onClick,
label,
count,
}: {
active: boolean;
onClick: () => void;
label: string;
count?: number;
}): React.ReactElement {
return (
<button
type="button"
onClick={onClick}
className={cn(
"relative -mb-px shrink-0 border-b-2 px-4 py-3 text-sm font-medium transition-colors",
active
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:border-border hover:text-foreground",
)}
>
{label}
{count !== undefined && count > 0 ? (
<span
className={cn(
"ml-1.5 inline-flex min-w-[1.25rem] items-center justify-center rounded-full px-1.5 py-0.5 text-[10px] font-medium tabular-nums",
active ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground",
)}
>
{count}
</span>
) : null}
</button>
);
}

View File

@@ -21,6 +21,7 @@ import {
import { Switch } from "@/components/ui/switch";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import { percentUiToRatio } from "@/lib/admin-rate-percent";
import { adminSiteCodeLabel } from "@/lib/admin-select-display";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminIntegrationSiteRow } from "@/types/api/admin-integration-site";
@@ -128,8 +129,11 @@ export function AgentLineProvisionWizard(): React.ReactElement {
disabled={sitesLoading || unboundSites.length === 0}
>
<SelectTrigger>
<SelectValue
placeholder={
<SelectValue>
{(v) =>
adminSiteCodeLabel(
v,
unboundSites,
sitesLoading
? t("common:loading", { defaultValue: "加载中…" })
: unboundSites.length === 0
@@ -138,9 +142,10 @@ export function AgentLineProvisionWizard(): React.ReactElement {
})
: t("agents:lineProvision.siteCodePlaceholder", {
defaultValue: "选择站点",
})
}),
)
}
/>
</SelectValue>
</SelectTrigger>
<SelectContent>
{unboundSites.map((site) => (
@@ -248,7 +253,15 @@ export function AgentLineProvisionWizard(): React.ReactElement {
}
>
<SelectTrigger>
<SelectValue />
<SelectValue>
{(v) =>
v === "daily"
? t("agents:profile.cycleDaily", { defaultValue: "日结" })
: v === "monthly"
? t("agents:profile.cycleMonthly", { defaultValue: "月结" })
: t("agents:profile.cycleWeekly", { defaultValue: "周结" })
}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="daily">

View File

@@ -122,7 +122,9 @@ export function AgentProfileFields({
>
<div className="space-y-2">
<Label htmlFor={`${idPrefix}-share-rate`}>
{t("profile.totalShareRate", { defaultValue: "占成比例 (%)" })}
{parentCaps
? t("profile.relativeShareRate", { defaultValue: "占成比例(占上级 %" })
: t("profile.totalShareRate", { defaultValue: "占成比例 (%)" })}
</Label>
<Input
id={`${idPrefix}-share-rate`}
@@ -133,6 +135,14 @@ export function AgentProfileFields({
value={shareRate}
onChange={(e) => onShareRateChange(e.target.value)}
/>
{parentCaps && shareRate ? (
<p className="text-xs text-muted-foreground">
{t("profile.actualShareRate", {
defaultValue: "实际占成 {{rate}}%",
rate: Number((Number(parentCaps.total_share_rate) * Number(shareRate) / 100).toFixed(2)),
})}
</p>
) : null}
</div>
<div className="space-y-2">
<Label htmlFor={`${idPrefix}-credit-limit`}>
@@ -200,7 +210,13 @@ export function AgentProfileFields({
}
>
<SelectTrigger id={`${idPrefix}-settlement-cycle`}>
<SelectValue />
<SelectValue>
{settlementCycle === "daily"
? t("profile.cycleDaily", { defaultValue: "日结" })
: settlementCycle === "monthly"
? t("profile.cycleMonthly", { defaultValue: "月结" })
: t("profile.cycleWeekly", { defaultValue: "周结" })}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="daily">{t("profile.cycleDaily", { defaultValue: "日结" })}</SelectItem>

View File

@@ -160,7 +160,13 @@ export function AgentsConsole(): React.ReactElement {
};
const applyProfileRowToForm = (row: AgentProfileRow) => {
const parentRate = row.parent_caps?.total_share_rate ?? 0;
if (parentRate > 0 && row.total_share_rate != null) {
const relative = (row.total_share_rate / parentRate) * 100;
setProfileShareRate(percentValueToUi(relative));
} else {
setProfileShareRate(percentValueToUi(row.total_share_rate ?? 0));
}
setProfileCreditLimit(String(row.credit_limit ?? 0));
setProfileRebateLimit(ratioToPercentUi(row.rebate_limit ?? 0));
setProfileDefaultRebate(ratioToPercentUi(row.default_player_rebate ?? 0));
@@ -173,8 +179,9 @@ export function AgentsConsole(): React.ReactElement {
setProfileRiskTags((row.risk_tags ?? []).join(", "));
};
const profilePayload = () => ({
total_share_rate: Number.parseFloat(profileShareRate) || 0,
const profilePayload = () => {
const shareRate = Number.parseFloat(profileShareRate) || 0;
const base = {
credit_limit: Number.parseInt(profileCreditLimit, 10) || 0,
rebate_limit: percentUiToRatio(profileRebateLimit),
default_player_rebate: percentUiToRatio(profileDefaultRebate),
@@ -183,7 +190,12 @@ export function AgentsConsole(): React.ReactElement {
can_create_child_agent: profileCanCreateChild,
can_create_player: profileCanCreatePlayer,
risk_tags: parseRiskTagsInput(profileRiskTags),
});
};
if (profileParentCaps) {
return { ...base, relative_share_rate: shareRate };
}
return { ...base, total_share_rate: shareRate };
};
const validateProfileFields = (): string | null => {
const shareRate = Number.parseFloat(profileShareRate);
@@ -443,15 +455,30 @@ export function AgentsConsole(): React.ReactElement {
setNodeStatus(1);
setNodeUsername("");
setNodePassword("");
resetProfileForm("create");
setProfileLoading(false);
setProfileLoaded(true);
setEditingNodeNeedsPrimaryAccount(false);
setNodeDialogOpen(true);
if (canManageProfile) {
void getAgentNodeProfile(node.id)
.then((p) => setProfileParentCaps(p.parent_caps ?? null))
.catch(() => setProfileParentCaps(null));
.then((p) => {
// 使用父代理自身的 caps不是 p.parent_caps(祖父)
setProfileParentCaps({
agent_node_id: node.id,
total_share_rate: p.total_share_rate ?? 100,
rebate_limit: p.rebate_limit ?? 0,
available_credit: p.available_credit ?? 0,
});
setProfileAvailableCredit(p.available_credit ?? null);
resetProfileForm("create");
})
.catch(() => {
setProfileParentCaps(null);
setProfileAvailableCredit(null);
resetProfileForm("create");
});
} else {
resetProfileForm("create");
}
};

View File

@@ -1,18 +1,21 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
AdminSubnav,
AdminSubnavBar,
AdminSubnavLink,
} from "@/components/admin/admin-subnav";
import { useAdminSiteCodeOptions } from "@/hooks/use-admin-site-code-options";
import { isAgentLineSubnavTabVisible } from "@/modules/agents/agent-line-subnav-visibility";
import { cn } from "@/lib/utils";
import { useAdminProfile } from "@/stores/admin-session";
import { useAgentManagementSiteStore } from "@/stores/agent-management-site";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { PRD_INTEGRATION_ACCESS_ANY } from "@/lib/admin-prd";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useAdminProfile } from "@/stores/admin-session";
import { useAgentManagementSiteStore } from "@/stores/agent-management-site";
const primaryTabs: {
href: string;
@@ -86,50 +89,8 @@ export function AgentsSubnav(): React.ReactElement {
return <></>;
}
return (
<div className="flex w-full flex-wrap items-center justify-between gap-3 rounded-lg bg-muted/50 p-1">
<nav
aria-label={t("subnav.label", { defaultValue: "代理管理导航" })}
className="inline-flex max-w-full flex-wrap items-center gap-1"
>
{visiblePrimaryTabs.map((tab) => {
const active = isTabActive(pathname, tab.href, tab.matchPrefix);
return (
<Link
key={tab.href}
href={tab.href}
className={cn(
"rounded-md px-3 py-2 text-sm font-medium transition-colors",
active
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{t(tab.labelKey)}
</Link>
);
})}
{showProvision ? (
<>
<span className="mx-1 hidden h-5 w-px bg-border/80 sm:inline-block" aria-hidden />
<Link
href={provisionTab.href}
className={cn(
"rounded-md px-3 py-2 text-sm font-medium transition-colors",
isTabActive(pathname, provisionTab.href, provisionTab.matchPrefix)
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{t(provisionTab.labelKey)}
</Link>
</>
) : null}
</nav>
{canSwitchSite && siteOptions.length > 0 && selectSiteId !== null ? (
const siteSelector =
canSwitchSite && siteOptions.length > 0 && selectSiteId !== null ? (
<Select
value={String(selectSiteId)}
onValueChange={(value) => setAdminSiteId(Number(value))}
@@ -147,7 +108,30 @@ export function AgentsSubnav(): React.ReactElement {
))}
</SelectContent>
</Select>
) : null;
return (
<AdminSubnavBar trailing={siteSelector}>
<AdminSubnav aria-label={t("subnav.label", { defaultValue: "代理管理导航" })}>
{visiblePrimaryTabs.map((tab) => (
<AdminSubnavLink
key={tab.href}
href={tab.href}
active={isTabActive(pathname, tab.href, tab.matchPrefix)}
>
{t(tab.labelKey)}
</AdminSubnavLink>
))}
{showProvision ? (
<AdminSubnavLink
href={provisionTab.href}
active={isTabActive(pathname, provisionTab.href, provisionTab.matchPrefix)}
>
{t(provisionTab.labelKey)}
</AdminSubnavLink>
) : null}
</div>
</AdminSubnav>
</AdminSubnavBar>
);
}

View File

@@ -25,14 +25,6 @@ export const CONFIG_NAV_GROUPS: readonly ConfigNavGroup[] = [
href: "/admin/config/plays",
key: "plays",
},
{
href: "/admin/config/odds",
key: "odds",
},
{
href: "/admin/config/rebate",
key: "rebate",
},
{
href: "/admin/config/jackpot",
key: "jackpot",

View File

@@ -1,10 +1,9 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { AdminSubnav, AdminSubnavLink } from "@/components/admin/admin-subnav";
import { CONFIG_NAV_GROUPS } from "@/modules/config/config-nav-model";
export function ConfigSubNav() {
@@ -13,27 +12,15 @@ export function ConfigSubNav() {
const links = CONFIG_NAV_GROUPS.flatMap((group) => group.items);
return (
<nav
className="flex w-full flex-wrap gap-1 rounded-xl border border-border/60 bg-muted/40 p-1.5"
aria-label={t("nav.aria")}
>
<AdminSubnav aria-label={t("nav.aria")}>
{links.map(({ href, key }) => {
const active = pathname === href || pathname.startsWith(`${href}/`);
return (
<Link
key={href}
href={href}
className={cn(
"rounded-lg px-4 py-2.5 text-sm font-medium transition-colors",
active
? "bg-card text-primary shadow-sm"
: "text-muted-foreground hover:bg-card/60 hover:text-foreground",
)}
>
<AdminSubnavLink key={href} href={href} active={active}>
{t(`nav.items.${key}`)}
</Link>
</AdminSubnavLink>
);
})}
</nav>
</AdminSubnav>
);
}

View File

@@ -14,6 +14,8 @@ type ConfigVersionActionsProps = {
loadingDetail?: boolean;
saving?: boolean;
publishLabel?: string;
/** 合并编辑页由底部操作栏承接保存/发布时隐藏 */
suppressDraftActions?: boolean;
onRefresh: () => void;
onNewDraft: () => void;
onSaveDraft: () => void;
@@ -28,6 +30,7 @@ export function ConfigVersionActions({
loadingDetail = false,
saving = false,
publishLabel,
suppressDraftActions = false,
onRefresh,
onNewDraft,
onSaveDraft,
@@ -59,7 +62,7 @@ export function ConfigVersionActions({
<Plus className="size-3.5" aria-hidden />
{t("versionActions.newDraft")}
</Button>
{isDraft ? (
{isDraft && !suppressDraftActions ? (
<>
<Button
type="button"

View File

@@ -0,0 +1,34 @@
import type { OddsItemRow } from "@/types/api/admin-config";
function oddsItemFingerprint(row: OddsItemRow): string {
return [
row.play_code,
row.prize_scope,
row.odds_value,
row.rebate_rate,
row.commission_rate,
row.currency_code,
].join("|");
}
/** 草稿行是否与已保存版本存在差异。 */
export function oddsDraftIsDirty(draftRows: OddsItemRow[], savedRows: OddsItemRow[]): boolean {
if (draftRows.length !== savedRows.length) {
return true;
}
const saved = new Map(savedRows.map((row) => [`${row.play_code}|${row.prize_scope}`, row]));
for (const draft of draftRows) {
const key = `${draft.play_code}|${draft.prize_scope}`;
const baseline = saved.get(key);
if (!baseline) {
return true;
}
if (oddsItemFingerprint(draft) !== oddsItemFingerprint(baseline)) {
return true;
}
}
return false;
}

View File

@@ -55,11 +55,18 @@ import type {
OddsVersionDetail,
} from "@/types/api/admin-config";
import { ConfigWorkflowSection } from "@/modules/config/config-workflow-section";
import { OddsConfigDraftBar } from "@/modules/config/doc/odds-config-draft-bar";
import { oddsDraftIsDirty } from "@/modules/config/doc/odds-config-dirty";
import { OddsConfigPlayNav } from "@/modules/config/doc/odds-config-play-nav";
import { OddsConfigSummaryPanel } from "@/modules/config/doc/odds-config-summary-panel";
import {
OddsConfigSummaryPanel,
playRebatePercentFromScopes,
} from "@/modules/config/doc/odds-config-summary-panel";
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
buildOddsPlayFilterGroups,
filterOddsPlayTypesByCategory,
@@ -507,6 +514,13 @@ export function OddsConfigDocScreen({
? resolveAdminPlayTypeDisplayName(resolvedPlayCode, i18n.language, sortedTypes.find((t) => t.play_code === resolvedPlayCode))
: "—";
const isDirty = useMemo(() => {
if (!resolvedDetail || !isDraft) {
return false;
}
return oddsDraftIsDirty(resolvedDraftRows, resolvedDetail.items);
}, [isDraft, resolvedDetail, resolvedDraftRows]);
const filtersInner = (
<>
<ConfigChipGroup label={t("odds.category", { ns: "config" })}>
@@ -602,6 +616,7 @@ export function OddsConfigDocScreen({
loadingList={resolvedLoadingList}
loadingDetail={resolvedLoadingDetail}
saving={saving}
suppressDraftActions={mergedLayout && isDraft}
onRefresh={() => void refreshList()}
onNewDraft={() => void handleNewDraft()}
onSaveDraft={() => void handleSave()}
@@ -635,24 +650,89 @@ export function OddsConfigDocScreen({
/>
);
const mainBlock = (
<>
{resolvedError ? <p className="text-sm text-destructive">{resolvedError}</p> : null}
{resolvedLoadingDetail || resolvedLoadingTypes ? (
<AdminLoadingState
className={cn(mergedLayout ? "py-6" : "py-8")}
minHeight="6rem"
label={t("odds.loadingDetails", { ns: "config" })}
const rebateField = (
<div className="rounded-lg border border-border/60 bg-muted/20 p-4">
<div className="grid max-w-xs gap-1.5">
<Label htmlFor="odds-rebate-rate">{t("odds.rebateRate", { ns: "config" })}</Label>
{canEditDraft ? (
<Input
id="odds-rebate-rate"
type="text"
inputMode="decimal"
className="h-9 font-mono tabular-nums"
disabled={saving}
value={rebatePercentUi}
placeholder={t("odds.placeholders.rebateRate", { ns: "config" })}
onChange={(e) => setRebateForPlayPercent(e.target.value)}
/>
) : resolvedPlayCode ? (
<div className={cn(!mergedLayout && embedded ? "rounded-xl border border-border/60 bg-card p-4" : undefined)}>
<div className="grid grid-cols-2 gap-x-4 gap-y-4 sm:grid-cols-3">
{PRIZE_SCOPE_ORDER.map((scope) => {
) : (
<ConfigReadonlyValue mono className="h-9 w-full max-w-xs justify-center">
{rebatePercentUi}
</ConfigReadonlyValue>
)}
</div>
<p className="mt-2 text-xs text-muted-foreground">{t("odds.rebateRateHint", { ns: "config" })}</p>
</div>
);
const scopeEditorRows = PRIZE_SCOPE_ORDER.map((scope) => {
const row = scopeRows[scope];
const hint = embedded ? null : PRIZE_SCOPE_MULTIPLIER_HINT[scope];
const hint = mergedLayout ? null : PRIZE_SCOPE_MULTIPLIER_HINT[scope];
const idx = row ? rowIndex(resolvedPlayCode, scope) : -1;
return (
return { scope, row, hint, idx };
});
const mergedOddsTable = (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("odds.table.prizeScope", { ns: "config" })}</TableHead>
<TableHead className="w-[10rem] text-right">{t("odds.table.multiplier", { ns: "config" })}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{scopeEditorRows.map(({ scope, row, hint, idx }) => (
<TableRow key={scope}>
<TableCell className="font-medium">
{prizeScopeLabel(scope, t)}
{hint ? <span className="ml-1 text-xs font-normal text-muted-foreground">{hint}</span> : null}
</TableCell>
<TableCell className="text-right">
{row && idx >= 0 ? (
canEditDraft ? (
<Input
type="text"
inputMode="decimal"
className="ml-auto h-9 w-full max-w-[9rem] font-mono tabular-nums"
disabled={saving}
value={oddsMultiplierLabel(row.odds_value)}
placeholder={t("odds.placeholders.multiplier", { ns: "config" })}
onChange={(e) =>
updateOddsForScope(scope, {
odds_value: parseOddsMultiplierInput(e.target.value),
})
}
/>
) : (
<ConfigReadonlyValue mono className="ml-auto h-9 w-full max-w-[9rem] justify-center">
{oddsMultiplierLabel(row.odds_value)}
</ConfigReadonlyValue>
)
) : (
<span className="text-xs text-destructive">
{t("odds.missingScopeRow", { ns: "config", scope })}
</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
const classicOddsGrid = (
<div className="grid grid-cols-2 gap-x-4 gap-y-4 sm:grid-cols-3">
{scopeEditorRows.map(({ scope, row, hint, idx }) => (
<div key={scope} className="grid min-w-0 gap-1.5">
<Label className="truncate text-xs font-medium text-muted-foreground">
{prizeScopeLabel(scope, t)}
@@ -682,8 +762,7 @@ export function OddsConfigDocScreen({
<p className="text-xs text-destructive">{t("odds.missingScopeRow", { ns: "config", scope })}</p>
)}
</div>
);
})}
))}
<div className="grid min-w-0 gap-1.5">
<Label className="truncate text-xs font-medium text-muted-foreground">
{t("odds.rebateRate", { ns: "config" })}
@@ -705,9 +784,33 @@ export function OddsConfigDocScreen({
)}
</div>
</div>
);
const mainBlock = (
<>
{resolvedError ? <p className="text-sm text-destructive">{resolvedError}</p> : null}
{resolvedLoadingDetail || resolvedLoadingTypes ? (
<AdminLoadingState
className={cn(mergedLayout ? "py-6" : "py-8")}
minHeight="6rem"
label={t("odds.loadingDetails", { ns: "config" })}
/>
) : resolvedPlayCode ? (
<div className={cn(!mergedLayout && embedded ? "rounded-xl border border-border/60 bg-card p-4" : undefined)}>
{mergedLayout ? (
<div className="space-y-4">
{mergedOddsTable}
{rebateField}
</div>
) : (
<>
{classicOddsGrid}
{!embedded ? (
<p className="mt-3 text-xs text-muted-foreground">{t("odds.rebateRateHint", { ns: "config" })}</p>
) : null}
</>
)}
</div>
) : null}
</>
@@ -785,34 +888,51 @@ export function OddsConfigDocScreen({
if (embedded && mergedLayout) {
return (
<div className="space-y-6">
<div className="space-y-4">
<div className="overflow-hidden rounded-xl border border-border/60 bg-card">{toolbarBlock}</div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_min(100%,300px)] xl:items-start">
<div className="space-y-6">
<ConfigWorkflowSection step={1} title={t("odds.sections.playScope", { ns: "config" })}>
{filtersBlock}
</ConfigWorkflowSection>
<ConfigWorkflowSection
step={2}
title={t("odds.sections.oddsConfig", { ns: "config" })}
description={t("odds.currentSelection", {
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_min(100%,260px)] xl:items-start">
<div className="overflow-hidden rounded-xl border border-border/60 bg-card">
<div className="grid gap-0 lg:grid-cols-[minmax(0,13rem)_minmax(0,1fr)]">
<aside className="border-b border-border/50 px-4 py-4 lg:border-r lg:border-b-0">
<OddsConfigPlayNav
catTab={catTab}
onCatTabChange={setCatTab}
onPlayCodeChange={setPlayCode}
types={sortedTypes}
resolvedPlayCode={resolvedPlayCode}
/>
</aside>
<div className="min-w-0">
<div className="border-b border-border/50 px-4 py-3 sm:px-5">
<h3 className="text-base font-semibold">{activePlayLabel}</h3>
<p className="text-sm text-muted-foreground">
{t("odds.currentSelection", {
ns: "config",
category: activeCatLabel,
play: activePlayLabel,
})}
>
{mainBlock}
</ConfigWorkflowSection>
{rebateSection}
</p>
</div>
<div className="px-4 py-4 sm:px-5">{mainBlock}</div>
{isDraft && canManage ? (
<OddsConfigDraftBar
isDirty={isDirty}
saving={saving}
loadingDetail={resolvedLoadingDetail}
onSave={() => void handleSave()}
onPublish={() => void requestPublishConfirm()}
/>
) : null}
</div>
</div>
</div>
<OddsConfigSummaryPanel
catTabLabel={activeCatLabel}
playLabel={activePlayLabel}
compact
detail={resolvedDetail}
draftRows={resolvedDraftRows}
types={sortedTypes}
scopeRows={scopeRows}
playRebatePercent={playRebatePercentFromScopes(scopeRows, PRIZE_SCOPE_ORDER)}
activeHead={activeHead ?? null}
/>
</div>
{dialogs}

View File

@@ -0,0 +1,51 @@
"use client";
import { Rocket, Save } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type OddsConfigDraftBarProps = {
isDirty: boolean;
saving: boolean;
loadingDetail?: boolean;
onSave: () => void;
onPublish: () => void;
className?: string;
};
export function OddsConfigDraftBar({
isDirty,
saving,
loadingDetail = false,
onSave,
onPublish,
className,
}: OddsConfigDraftBarProps) {
const { t } = useTranslation("config");
const busy = saving || loadingDetail;
return (
<div
className={cn(
"sticky bottom-0 z-10 flex flex-col gap-3 border-t border-border/60 bg-card/95 px-4 py-3 backdrop-blur sm:flex-row sm:items-center sm:justify-between sm:px-5",
className,
)}
>
<p className="text-sm text-muted-foreground">
{isDirty ? t("odds.draftBar.unsaved") : t("odds.draftBar.saved")}
</p>
<div className="flex flex-wrap items-center gap-2">
<Button type="button" variant="outline" size="sm" disabled={busy} onClick={onSave}>
<Save className="size-3.5" aria-hidden />
{t("versionActions.saveDraft")}
</Button>
<Button type="button" size="sm" disabled={busy} onClick={onPublish}>
<Rocket className="size-3.5" aria-hidden />
{t("versionActions.publishCurrent")}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,158 @@
"use client";
import { useTranslation } from "react-i18next";
import { ConfigChip, ConfigChipGroup } from "@/modules/config/config-chip-group";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
buildOddsPlayFilterGroups,
filterOddsPlayTypesByCategory,
type OddsCategoryTab,
} from "@/modules/config/doc/odds-play-type-groups";
import { resolveAdminPlayTypeDisplayName } from "@/lib/admin-play-types";
import { cn } from "@/lib/utils";
import type { AdminPlayTypeRow } from "@/types/api/admin-config";
const PLAY_SELECT_NONE = "__none__";
type OddsConfigPlayNavProps = {
catTab: OddsCategoryTab;
onCatTabChange: (tab: OddsCategoryTab) => void;
onPlayCodeChange: (code: string) => void;
types: AdminPlayTypeRow[];
resolvedPlayCode: string;
className?: string;
};
export function OddsConfigPlayNav({
catTab,
onCatTabChange,
onPlayCodeChange,
types,
resolvedPlayCode,
className,
}: OddsConfigPlayNavProps) {
const { t, i18n } = useTranslation("config");
const catTabs = [
{ id: "all" as const, label: t("odds.tabs.all") },
{ id: "d4" as const, label: "4D" },
{ id: "d3" as const, label: "3D" },
{ id: "d2" as const, label: "2D" },
];
const filteredTypes = filterOddsPlayTypesByCategory(catTab, types);
const playGroups = buildOddsPlayFilterGroups(catTab, types);
const playSelectValue = resolvedPlayCode || PLAY_SELECT_NONE;
const playLabel = (code: string): string => {
const row = types.find((item) => item.play_code === code);
return resolveAdminPlayTypeDisplayName(code, i18n.language, row);
};
return (
<div className={cn("space-y-4", className)}>
<ConfigChipGroup label={t("odds.category")}>
{catTabs.map((tab) => (
<ConfigChip
key={tab.id}
active={catTab === tab.id}
onClick={() => onCatTabChange(tab.id)}
>
{tab.label}
</ConfigChip>
))}
</ConfigChipGroup>
{/* 小屏:下拉快速切换玩法 */}
<div className="space-y-1.5 lg:hidden">
<p className="text-sm font-medium">{t("odds.playType")}</p>
<Select
modal={false}
value={playSelectValue}
onValueChange={(value) => {
if (value != null && value !== PLAY_SELECT_NONE) {
onPlayCodeChange(value);
}
}}
disabled={filteredTypes.length === 0}
>
<SelectTrigger className="h-9 w-full">
<SelectValue>
{(v) => {
const code = v == null || v === "" || v === PLAY_SELECT_NONE ? "" : String(v);
return code ? playLabel(code) : t("odds.playSelectPlaceholder");
}}
</SelectValue>
</SelectTrigger>
<SelectContent>
{playGroups.length > 0 ? (
playGroups.map((group) => (
<SelectGroup key={group.key}>
<SelectLabel>{t(`odds.playGroups.${group.key}`)}</SelectLabel>
{group.types.map((type) => (
<SelectItem key={type.play_code} value={type.play_code}>
{playLabel(type.play_code)}
</SelectItem>
))}
</SelectGroup>
))
) : (
<SelectItem value={PLAY_SELECT_NONE} disabled>
{t("odds.noPlayTypes")}
</SelectItem>
)}
</SelectContent>
</Select>
</div>
{/* 大屏:侧栏玩法列表,点选即切换 */}
<nav className="hidden lg:block" aria-label={t("odds.playType")}>
<p className="mb-2 text-sm font-medium">{t("odds.playType")}</p>
{filteredTypes.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("odds.noPlayTypes")}</p>
) : (
<div className="max-h-[min(28rem,calc(100vh-16rem))] space-y-4 overflow-y-auto pr-1">
{playGroups.map((group) => (
<div key={group.key} className="space-y-1">
<p className="px-2 text-xs font-medium text-muted-foreground">
{t(`odds.playGroups.${group.key}`)}
</p>
<ul className="space-y-0.5">
{group.types.map((type) => {
const active = resolvedPlayCode === type.play_code;
return (
<li key={type.play_code}>
<button
type="button"
className={cn(
"w-full rounded-md px-2.5 py-2 text-left text-sm transition-colors",
active
? "bg-primary font-medium text-primary-foreground shadow-sm"
: "text-foreground hover:bg-muted/80",
)}
onClick={() => onPlayCodeChange(type.play_code)}
aria-current={active ? "true" : undefined}
>
{playLabel(type.play_code)}
</button>
</li>
);
})}
</ul>
</div>
))}
</div>
)}
</nav>
</div>
);
}

View File

@@ -5,28 +5,21 @@ import { useTranslation } from "react-i18next";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { ConfigStatusBadge } from "@/modules/config/config-status-badge";
import { inferRebatePercentFromDimension, rateToPercentUi } from "@/modules/config/doc/odds-rebate-rates";
import { rateToPercentUi } from "@/modules/config/doc/odds-rebate-rates";
import { prizeScopeLabel, type PrizeScopeCode } from "@/modules/config/doc/prize-scopes";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { cn } from "@/lib/utils";
import type { AdminPlayTypeRow, OddsItemRow, OddsVersionDetail } from "@/types/api/admin-config";
function oddsMultiplierLabel(oddsValue: number): string {
return (oddsValue / 10000).toFixed(4);
}
type SummaryRow = {
label: string;
value: string;
};
import type { ConfigVersionSummary, OddsItemRow, OddsVersionDetail } from "@/types/api/admin-config";
type OddsConfigSummaryPanelProps = {
catTabLabel: string;
playLabel: string;
catTabLabel?: string;
playLabel?: string;
detail: OddsVersionDetail | null;
draftRows: OddsItemRow[];
types: AdminPlayTypeRow[];
scopeRows: Partial<Record<PrizeScopeCode, OddsItemRow>>;
playRebatePercent: string;
scopeRows?: Partial<Record<PrizeScopeCode, OddsItemRow>>;
playRebatePercent?: string;
activeHead?: ConfigVersionSummary | null;
/** 合并页:仅展示版本与操作提示,不重复主编辑区数值 */
compact?: boolean;
className?: string;
};
@@ -34,63 +27,32 @@ export function OddsConfigSummaryPanel({
catTabLabel,
playLabel,
detail,
draftRows,
types,
scopeRows,
playRebatePercent,
activeHead = null,
compact = false,
className,
}: OddsConfigSummaryPanelProps) {
const { t } = useTranslation("config");
const formatDt = useAdminDateTimeFormatter();
const isDraft = detail?.status === "draft";
const isActive = detail?.status === "active";
const rows: SummaryRow[] = [
{ label: t("odds.category"), value: catTabLabel },
{ label: t("odds.playType"), value: playLabel || "—" },
];
for (const scope of ["first", "second", "third", "starter", "consolation"] as PrizeScopeCode[]) {
const row = scopeRows[scope];
rows.push({
label: prizeScopeLabel(scope, t),
value: row ? oddsMultiplierLabel(row.odds_value) : "—",
});
}
rows.push({
label: t("odds.rebateRate"),
value: playRebatePercent,
});
rows.push(
{
label: t("rebate.fields.d2"),
value: inferRebatePercentFromDimension(2, draftRows, types),
},
{
label: t("rebate.fields.d3"),
value: inferRebatePercentFromDimension(3, draftRows, types),
},
{
label: t("rebate.fields.d4"),
value: inferRebatePercentFromDimension(4, draftRows, types),
},
);
const versionLabel = detail ? `v${detail.version_no}` : "—";
return (
<aside
className={cn(
"lg:sticky lg:top-24 lg:max-h-[calc(100vh-7rem)] lg:overflow-y-auto",
"xl:sticky xl:top-24 xl:max-h-[calc(100vh-7rem)] xl:overflow-y-auto",
className,
)}
>
<div className="overflow-hidden rounded-xl border border-border/60 bg-card">
<div className="flex items-center gap-2 border-b border-border/50 px-4 py-3.5">
<FileText className="size-4 text-primary" aria-hidden />
<h3 className="text-base font-semibold">{t("odds.summary.title")}</h3>
<h3 className="text-base font-semibold">
{compact ? t("odds.summary.contextTitle") : t("odds.summary.title")}
</h3>
</div>
<div className="space-y-4 px-4 py-4">
@@ -100,29 +62,50 @@ export function OddsConfigSummaryPanel({
{detail ? <ConfigStatusBadge status={detail.status} /> : null}
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-muted-foreground">{t("odds.summary.statusLabel")}</span>
{detail && !isDraft ? (
<span className="inline-flex items-center rounded-md border border-border/60 bg-muted/40 px-2 py-0.5 text-xs font-medium text-muted-foreground">
{t("odds.summary.readOnlyTag")}
</span>
) : isDraft ? (
<span className="inline-flex items-center rounded-md border border-primary/25 bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
{t("versionStatus.draft")}
</span>
) : (
<span className="text-sm"></span>
)}
{activeHead ? (
<div className="space-y-1 text-sm">
<p className="text-muted-foreground">{t("odds.summary.activeVersion")}</p>
<p className="font-mono font-medium">
v{activeHead.version_no}
{activeHead.effective_at ? ` · ${formatDt(activeHead.effective_at)}` : ""}
</p>
</div>
) : null}
<dl className="space-y-2.5">
{rows.map((row) => (
<div key={row.label} className="grid grid-cols-[minmax(0,1fr)_auto] gap-3 text-sm">
<dt className="text-muted-foreground">{row.label}</dt>
<dd className="font-mono text-right tabular-nums text-foreground">{row.value}</dd>
{!compact && catTabLabel && playLabel ? (
<dl className="space-y-2 text-sm">
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-3">
<dt className="text-muted-foreground">{t("odds.category")}</dt>
<dd>{catTabLabel}</dd>
</div>
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-3">
<dt className="text-muted-foreground">{t("odds.playType")}</dt>
<dd>{playLabel}</dd>
</div>
))}
</dl>
) : null}
{!compact && scopeRows ? (
<dl className="space-y-2.5">
{(["first", "second", "third", "starter", "consolation"] as PrizeScopeCode[]).map((scope) => {
const row = scopeRows[scope];
return (
<div key={scope} className="grid grid-cols-[minmax(0,1fr)_auto] gap-3 text-sm">
<dt className="text-muted-foreground">{prizeScopeLabel(scope, t)}</dt>
<dd className="font-mono text-right tabular-nums text-foreground">
{row ? oddsMultiplierLabel(row.odds_value) : "—"}
</dd>
</div>
);
})}
{playRebatePercent ? (
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-3 text-sm">
<dt className="text-muted-foreground">{t("odds.rebateRate")}</dt>
<dd className="font-mono text-right tabular-nums text-foreground">{playRebatePercent}</dd>
</div>
) : null}
</dl>
) : null}
{detail && !isDraft ? (
<Alert className="border-sky-500/30 bg-sky-500/5 text-foreground">
@@ -131,6 +114,12 @@ export function OddsConfigSummaryPanel({
{t("odds.summary.readOnlyHint")}
</AlertDescription>
</Alert>
) : isDraft ? (
<Alert className="border-primary/25 bg-primary/5 text-foreground">
<AlertDescription className="text-xs leading-relaxed">
{t("odds.summary.draftHint")}
</AlertDescription>
</Alert>
) : isActive ? (
<Alert className="border-emerald-500/30 bg-emerald-500/5 text-foreground">
<AlertDescription className="text-xs leading-relaxed">
@@ -144,6 +133,10 @@ export function OddsConfigSummaryPanel({
);
}
function oddsMultiplierLabel(oddsValue: number): string {
return (oddsValue / 10000).toFixed(4);
}
/** 当前玩法在摘要中展示的回水百分比(与赔率区输入一致)。 */
export function playRebatePercentFromScopes(
scopeRows: Partial<Record<PrizeScopeCode, OddsItemRow>>,

View File

@@ -134,7 +134,15 @@ export function RiskCapRuntimePanel() {
disabled={drawsLoading || draws.length === 0}
>
<SelectTrigger id="risk-cap-draw" className="font-mono">
<SelectValue placeholder={t("riskCap.runtime.drawPlaceholder", { ns: "config" })} />
<SelectValue>
{(v) => {
if (v == null || v === "") {
return t("riskCap.runtime.drawPlaceholder", { ns: "config" });
}
const draw = draws.find((d) => String(d.id) === String(v));
return draw ? `${draw.draw_no} · ${draw.status}` : String(v);
}}
</SelectValue>
</SelectTrigger>
<SelectContent>
{draws.map((d) => (

View File

@@ -24,6 +24,7 @@ import { useConfirmAction } from "@/hooks/use-confirm-action";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminDrawShowData } from "@/types/api/admin-draws";
import { canManageDrawResults } from "@/lib/draw-access";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { useAdminProfile } from "@/stores/admin-session";
import { cn } from "@/lib/utils";

View File

@@ -1,16 +1,14 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { buttonVariants } from "@/components/ui/button";
import { AdminSubnav, AdminSubnavLink } from "@/components/admin/admin-subnav";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { PRD_DRAW_FINANCE_ACCESS_ANY, PRD_RISK_ACCESS_ANY } from "@/lib/admin-prd";
import { PRD_RISK_ACCESS_ANY } from "@/lib/admin-prd";
import { canManageDrawResults, canViewDrawFinance, canViewDrawResults } from "@/lib/draw-access";
import { useAdminProfile } from "@/stores/admin-session";
import { cn } from "@/lib/utils";
const segments = [
{ suffix: "", key: "status", label: "subnav.status", requiresManage: false },
@@ -94,7 +92,7 @@ export function DrawSubnav({ drawId }: { drawId: string }): React.ReactElement {
);
return (
<nav className="mb-6 flex flex-wrap gap-2 border-b border-border pb-3">
<AdminSubnav aria-label={t("subnav.aria", { defaultValue: "期号导航" })} className="mb-6">
{visibleSegments.map(({ suffix, key, label }) => {
const href = `${base}${suffix}`;
const active =
@@ -107,17 +105,11 @@ export function DrawSubnav({ drawId }: { drawId: string }): React.ReactElement {
: pathname === href || pathname.startsWith(`${href}/`);
return (
<Link
key={key}
href={href}
className={cn(
buttonVariants({ variant: active ? "default" : "outline", size: "sm" }),
)}
>
<AdminSubnavLink key={key} href={href} active={active}>
{t(label)}
</Link>
</AdminSubnavLink>
);
})}
</nav>
</AdminSubnav>
);
}

View File

@@ -11,6 +11,7 @@ import { AdminListPaginationFooter } from "@/components/admin/admin-list-paginat
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { AdminPlayerIdentityCells, AdminPlayerIdentityHeads } from "@/components/admin/admin-player-identity-columns";
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
import { AdminSubnav, AdminSubnavButton } from "@/components/admin/admin-subnav";
import { ModuleScaffold } from "@/components/admin/module-scaffold";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -327,24 +328,20 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
{filterBlock}
{err ? <p className="text-destructive text-sm">{err}</p> : null}
<div className="flex flex-wrap gap-2 border-b border-border/70 pb-3">
<Button
type="button"
size="sm"
variant={recordTab === "payout" ? "default" : "outline"}
<AdminSubnav aria-label={t("recordTabs", { defaultValue: "奖池记录" })}>
<AdminSubnavButton
active={recordTab === "payout"}
onClick={() => setRecordTab("payout")}
>
{t("payoutRecords")}
</Button>
<Button
type="button"
size="sm"
variant={recordTab === "contribution" ? "default" : "outline"}
</AdminSubnavButton>
<AdminSubnavButton
active={recordTab === "contribution"}
onClick={() => setRecordTab("contribution")}
>
{t("contributionRecords")}
</Button>
</div>
</AdminSubnavButton>
</AdminSubnav>
<div className="space-y-6">
{recordTab === "payout" ? payoutTable : contributionTable}

View File

@@ -270,20 +270,17 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
</div>
<Tabs defaultValue="overview" className="gap-4">
<TabsList variant="line" className="w-full justify-start border-b rounded-none bg-transparent p-0">
<TabsTrigger value="overview" className="rounded-none px-3">
{t("tabOverview")}
</TabsTrigger>
<TabsTrigger value="tickets" className="rounded-none px-3">
{t("tabTickets")}
</TabsTrigger>
<TabsTrigger value="wallet" className="rounded-none px-3">
<TabsList
variant="line"
className="h-auto w-full justify-start gap-1 rounded-none border-b border-border/60 bg-transparent p-0 px-1"
>
<TabsTrigger value="overview">{t("tabOverview")}</TabsTrigger>
<TabsTrigger value="tickets">{t("tabTickets")}</TabsTrigger>
<TabsTrigger value="wallet">
{isCreditPlayer ? t("tabCreditLedger") : t("tabWalletTxns")}
</TabsTrigger>
{showTransferTab ? (
<TabsTrigger value="transfers" className="rounded-none px-3">
{t("tabTransferOrders")}
</TabsTrigger>
<TabsTrigger value="transfers">{t("tabTransferOrders")}</TabsTrigger>
) : null}
</TabsList>

View File

@@ -64,6 +64,7 @@ import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
import { adminPlayerDetailPath } from "@/lib/admin-player-paths";
import { PlayerFundingModeBadge } from "@/components/admin/player-funding-badges";
import { playerBalanceCells } from "@/lib/admin-player-display";
import { ADMIN_SELECT_FILTER_ALL, adminSiteSelectLabel } from "@/lib/admin-select-display";
import { formatAdminMinorUnits } from "@/lib/money";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminPlayerRow } from "@/types/api/admin-player";
@@ -419,17 +420,27 @@ export function PlayersConsole(): React.ReactElement {
{t("filterSite")}
</Label>
<Select
value={siteFilter || "__all__"}
value={siteFilter || ADMIN_SELECT_FILTER_ALL}
onValueChange={(value) => {
setSiteFilter(value === "__all__" ? "" : value);
setSiteFilter(
value == null || value === ADMIN_SELECT_FILTER_ALL ? "" : value,
);
setPage(1);
}}
>
<SelectTrigger id="player-site-filter" className="w-full sm:w-[12rem]">
<SelectValue placeholder={t("filterAllSites")} />
<SelectValue>
{(v) =>
adminSiteSelectLabel(
v,
isSuperAdmin ? siteOptions : profile?.accessible_sites ?? [],
t("filterAllSites"),
)
}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">{t("filterAllSites")}</SelectItem>
<SelectItem value={ADMIN_SELECT_FILTER_ALL}>{t("filterAllSites")}</SelectItem>
{(isSuperAdmin ? siteOptions : profile?.accessible_sites ?? []).map((site) => (
<SelectItem key={site.code} value={site.code}>
{site.name ? `${site.name} (${site.code})` : site.code}

View File

@@ -79,7 +79,7 @@ function itemStatusLabel(status: string, t: (key: string) => string): string {
function reconcileTypeLabel(type: string, t: (key: string) => string): string {
switch (type) {
case "wallet_transfer":
return t("reconcileTypeWalletTransfer");
return t("reconcileTypeFixed");
default:
return type;
}
@@ -237,6 +237,7 @@ export function ReconcileConsole(): React.ReactElement {
const selectedJobItemCount = getJobSummaryValue(selectedJob?.summary_json, "item_count");
const selectedJobMismatchCount = getJobSummaryValue(selectedJob?.summary_json, "mismatch_count");
const selectedJobMatchedCount = Math.max(0, selectedJobItemCount - selectedJobMismatchCount);
const hasSelectedRange = dateFrom.trim() !== "" && dateTo.trim() !== "";
return (
<div className="flex w-full max-w-none flex-col gap-6">
@@ -381,15 +382,19 @@ export function ReconcileConsole(): React.ReactElement {
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 rounded-xl border bg-muted/10 px-4 py-3">
<div className="min-w-0 text-sm text-muted-foreground">
{selectedPlayer
{hasSelectedRange
? selectedPlayer
? t("createSummaryPlayer", {
player: selectedPlayer.site_player_id,
from: dateFrom || "—",
to: dateTo || "—",
from: dateFrom,
to: dateTo,
})
: t("createSummaryAll", {
from: dateFrom || "—",
to: dateTo || "—",
from: dateFrom,
to: dateTo,
})
: t("createSummaryPending", {
defaultValue: "请选择完整的对账日期范围后,再创建任务。",
})}
</div>
<Button

View File

@@ -1,9 +1,9 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { AdminSubnav, AdminSubnavLink } from "@/components/admin/admin-subnav";
const tabs = [
{ category: "profit", href: "/admin/reports/profit" },
@@ -18,24 +18,15 @@ export function ReportsSubnav(): React.ReactElement {
const pathname = usePathname();
return (
<nav aria-label={t("title")} className="flex w-full flex-wrap items-end gap-1 border-b border-border/60 px-1">
<AdminSubnav aria-label={t("title")}>
{tabs.map((tab) => {
const active = pathname === tab.href || pathname.startsWith(`${tab.href}/`);
return (
<Link
key={tab.href}
href={tab.href}
className={cn(
"border-b-2 px-4 py-3 text-sm font-medium transition-colors",
active
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:border-border/80 hover:text-foreground",
)}
>
<AdminSubnavLink key={tab.href} href={tab.href} active={active}>
{t(`categories.${tab.category}`)}
</Link>
</AdminSubnavLink>
);
})}
</nav>
</AdminSubnav>
);
}

View File

@@ -4,7 +4,12 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import { useTranslation } from "react-i18next";
import { buttonVariants } from "@/components/ui/button";
import {
AdminSubnav,
AdminSubnavBar,
AdminSubnavLink,
adminSubnavItemClassName,
} from "@/components/admin/admin-subnav";
import { cn } from "@/lib/utils";
const segments = [
@@ -28,28 +33,26 @@ export function RiskSubnav({ drawId }: { drawId: string }) {
const base = `/admin/draws/${drawId}/risk`;
return (
<nav className="mb-6 flex flex-wrap gap-2 border-b border-border pb-3">
<AdminSubnavBar className="mb-6">
<AdminSubnav aria-label={t("subnavLabel", { defaultValue: "风控导航" })}>
{segments.map(({ suffix, key, label }) => {
const href = `${base}${suffix}`;
const active =
key === "pools" ? isPoolsTabActive(pathname, base) : pathname === href;
return (
<Link
key={key}
href={href}
className={cn(buttonVariants({ variant: active ? "default" : "outline", size: "sm" }))}
>
<AdminSubnavLink key={key} href={href} active={active}>
{t(label)}
</Link>
</AdminSubnavLink>
);
})}
</AdminSubnav>
<Link
href="/admin/draws"
className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "ml-auto")}
className={cn(adminSubnavItemClassName(false), "text-muted-foreground")}
>
{t("changeDraw")}
</Link>
</nav>
</AdminSubnavBar>
);
}

View File

@@ -1,54 +1,30 @@
"use client";
import { useEffect, useState } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
import { PRD_RULES_ODDS_ACCESS_ANY } from "@/lib/admin-prd";
import { ConfigDocPage } from "@/modules/config/config-doc-page";
import { OddsConfigDocScreen } from "@/modules/config/doc/odds-config-doc-screen";
import { RebateConfigDocScreen } from "@/modules/config/doc/rebate-config-doc-screen";
import { useOddsConfigWorkspace } from "@/modules/config/use-odds-config-workspace";
import { RulesPageShell } from "@/modules/rules/rules-page-shell";
/** 赔率与回水:共用赔率版本线,主栏步骤 + 右侧配置摘要。 */
/** 赔率与回水:共用赔率版本线,主栏步骤 + 右侧配置摘要。 */
export function RulesOddsConfigScreen() {
const { t } = useTranslation("config");
const [sharedVersionId, setSharedVersionId] = useState("");
const workspace = useOddsConfigWorkspace(sharedVersionId, setSharedVersionId);
useEffect(() => {
const scrollToRebate = () => {
if (window.location.hash !== "#rebate") {
return;
}
document.getElementById("rebate")?.scrollIntoView({ behavior: "smooth", block: "start" });
};
scrollToRebate();
window.addEventListener("hashchange", scrollToRebate);
return () => window.removeEventListener("hashchange", scrollToRebate);
}, []);
const rebateSection = (
<div id="rebate">
<RebateConfigDocScreen embedded mergedSection workspace={workspace} />
</div>
);
return (
<RulesPageShell>
<AdminPermissionGate requiredAny={PRD_RULES_ODDS_ACCESS_ANY}>
<ConfigDocPage
title={t("nav.rulesOddsTitle")}
description={t("nav.rulesOddsDescription")}
description={t("nav.rulesOddsDescriptionShort")}
contentClassName="pt-2"
>
<OddsConfigDocScreen
embedded
mergedLayout
workspace={workspace}
rebateSection={rebateSection}
/>
<OddsConfigDocScreen embedded mergedLayout workspace={workspace} />
</ConfigDocPage>
</AdminPermissionGate>
</RulesPageShell>

View File

@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { ArrowRight } from "lucide-react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -16,30 +17,16 @@ import {
} from "@/api/admin-agent-settlement";
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
import {
SettlementBillAmountBreakdown,
SettlementBillPartiesRow,
SettlementBillSummaryHeader,
} from "@/modules/settlement/settlement-bill-breakdown";
import { describeBillPaymentDirection } from "@/modules/settlement/settlement-bill-display";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
function parseBillMeta(metaJson: SettlementBillRow["meta_json"]): {
share_profit?: number;
platform_share_profit?: number;
} {
if (metaJson == null || metaJson === "") {
return {};
}
try {
const parsed =
typeof metaJson === "string" ? (JSON.parse(metaJson) as Record<string, unknown>) : metaJson;
return {
share_profit: parsed.share_profit != null ? Number(parsed.share_profit) : undefined,
platform_share_profit:
parsed.platform_share_profit != null ? Number(parsed.platform_share_profit) : undefined,
};
} catch {
return {};
}
}
type AgentBillDetailProps = {
billId: number;
currencyCode: string;
@@ -53,17 +40,17 @@ export function AgentBillDetail({
canManage = true,
onUpdated,
}: AgentBillDetailProps): React.ReactElement {
const { t } = useTranslation(["agents", "common"]);
const { t } = useTranslation(["agents", "settlementCenter", "common"]);
const [bill, setBill] = useState<SettlementBillRow | null>(null);
const [payments, setPayments] = useState<SettlementPaymentRow[]>([]);
const [rebateAllocations, setRebateAllocations] = useState<RebateAllocationRow[]>([]);
const [tierEdge, setTierEdge] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [payAmount, setPayAmount] = useState("");
const [payMethod, setPayMethod] = useState("");
const [payProof, setPayProof] = useState("");
const [adjustAmount, setAdjustAmount] = useState("");
const [badDebtReason, setBadDebtReason] = useState("");
const [rebateDetailsOpen, setRebateDetailsOpen] = useState(false);
const load = useCallback(async () => {
setLoading(true);
@@ -72,7 +59,6 @@ export function AgentBillDetail({
setBill(data.bill);
setPayments(data.payments ?? []);
setRebateAllocations(data.rebate_allocations ?? []);
setTierEdge(data.tier_edge ?? null);
setPayAmount(String(data.bill.unpaid_amount ?? 0));
} finally {
setLoading(false);
@@ -87,20 +73,12 @@ export function AgentBillDetail({
return <AdminLoadingState />;
}
const owner =
bill.owner_label ??
`${bill.owner_type}#${bill.owner_id}`;
const counterparty =
bill.counterparty_label === "platform"
? t("settlementBills.platform", { defaultValue: "平台" })
: bill.counterparty_label ?? `${bill.counterparty_type}#${bill.counterparty_id}`;
const direction = describeBillPaymentDirection(bill, t);
const locked = ["confirmed", "partial_paid", "settled", "overdue"].includes(bill.status);
const ownerOwes = bill.net_amount > 0;
const paymentTitle = ownerOwes
? t("settlementBills.recordReceipt", { defaultValue: "登记款" })
: t("settlementBills.recordPayout", { defaultValue: "登记付款" });
const paymentSubmit = ownerOwes
const paymentTitle = direction.ownerOwes
? t("settlementBills.submitReceipt", { defaultValue: "登记收款" })
: t("settlementBills.submitPayout", { defaultValue: "登记款" });
const paymentSubmit = direction.ownerOwes
? t("settlementBills.submitReceipt", { defaultValue: "确认收款" })
: t("settlementBills.submitPayout", { defaultValue: "确认付款" });
const canWriteOff =
@@ -108,111 +86,138 @@ export function AgentBillDetail({
bill.unpaid_amount > 0 &&
["confirmed", "partial_paid", "overdue"].includes(bill.status) &&
!["adjustment", "reversal", "bad_debt"].includes(bill.bill_type);
const meta = parseBillMeta(bill.meta_json);
const hasSubtreeFields =
bill.gross_win_loss != null ||
bill.rebate_amount != null ||
bill.platform_rounding_adjustment != null ||
meta.share_profit != null;
const rebateAllocationSummary = Object.values(
rebateAllocations.reduce<Record<string, { key: string; label: string; amount: number; rows: number }>>(
(acc, row) => {
const label = row.participant_label ?? `${row.participant_type}#${row.participant_id}`;
const key = `${row.participant_type}:${row.participant_id}:${row.allocation_rule}`;
const current = acc[key];
if (current) {
current.amount += row.allocated_amount;
current.rows += 1;
return acc;
}
acc[key] = {
key,
label: `${label} · ${row.allocation_rule}`,
amount: row.allocated_amount,
rows: 1,
};
return acc;
},
{},
),
).sort((a, b) => b.amount - a.amount || a.label.localeCompare(b.label, "zh-CN"));
return (
<div className="space-y-4 text-sm">
<div>
<span className="text-muted-foreground">{t("settlementBills.columns.party", { defaultValue: "本方" })}: </span>
{owner}
</div>
<div>
<span className="text-muted-foreground">
{t("settlementBills.columns.counterparty", { defaultValue: "对方" })}:{" "}
</span>
{counterparty}
</div>
<div>
<span className="text-muted-foreground">{t("settlementBills.columns.type", { defaultValue: "类型" })}: </span>
{bill.bill_type} / {bill.status}
{tierEdge ? ` · ${tierEdge}` : ""}
</div>
{hasSubtreeFields ? (
<div className="space-y-1 rounded-md border border-border/60 p-3">
<p className="font-medium">
{t("settlementBills.subtreeSummary", { defaultValue: "子树汇总" })}
</p>
{bill.gross_win_loss != null ? (
<div>
<span className="text-muted-foreground">
{t("settlementBills.grossWinLoss", { defaultValue: "输赢 (gross_win_loss)" })}:{" "}
</span>
{formatDashboardMoneyMinor(bill.gross_win_loss, currencyCode)}
</div>
) : null}
{bill.rebate_amount != null ? (
<div>
<span className="text-muted-foreground">
{t("settlementBills.rebateAmount", { defaultValue: "回水" })}:{" "}
</span>
{formatDashboardMoneyMinor(bill.rebate_amount, currencyCode)}
</div>
) : null}
{meta.share_profit != null ? (
<div>
<span className="text-muted-foreground">
{t("settlementBills.shareProfit", { defaultValue: "占成利润" })}:{" "}
</span>
{formatDashboardMoneyMinor(meta.share_profit, currencyCode)}
</div>
) : null}
{bill.platform_rounding_adjustment != null && bill.platform_rounding_adjustment !== 0 ? (
<div>
<span className="text-muted-foreground">
{t("settlementBills.platformRounding", { defaultValue: "平台尾差" })}:{" "}
</span>
{formatDashboardMoneyMinor(bill.platform_rounding_adjustment, currencyCode)}
</div>
) : null}
</div>
) : null}
<div>
<span className="text-muted-foreground">{t("settlementBills.columns.net", { defaultValue: "净额" })}: </span>
{formatDashboardMoneyMinor(bill.net_amount, currencyCode)}
</div>
<div>
<span className="text-muted-foreground">{t("settlementBills.columns.unpaid", { defaultValue: "未结" })}: </span>
{formatDashboardMoneyMinor(bill.unpaid_amount, currencyCode)}
</div>
{rebateAllocations.length > 0 ? (
<div className="space-y-1 rounded-md border border-border/60 p-3">
<p className="font-medium">{t("settlementBills.rebateAllocations", { defaultValue: "回水分摊" })}</p>
<ul className="space-y-1 text-muted-foreground">
{rebateAllocations.map((row) => (
<li key={row.id}>
{row.participant_type}#{row.participant_id} · {row.allocation_rule} ·{" "}
{formatDashboardMoneyMinor(row.allocated_amount, currencyCode)}
</li>
))}
</ul>
</div>
) : null}
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.35fr)_minmax(340px,0.95fr)]">
<div className="space-y-5 text-sm">
<SettlementBillSummaryHeader bill={bill} currencyCode={currencyCode} />
<SettlementBillPartiesRow bill={bill} />
<SettlementBillAmountBreakdown bill={bill} currencyCode={currencyCode} />
{payments.length > 0 ? (
<div className="space-y-1 rounded-md border border-border/60 p-3">
<p className="font-medium">{t("settlementBills.paymentsHistory", { defaultValue: "收付记录" })}</p>
<ul className="space-y-1 text-muted-foreground">
<div className="space-y-2 rounded-xl border border-border/70 p-4">
<p className="font-medium">
{t("settlementBills.paymentsHistory", { defaultValue: "收付记录" })}
</p>
<ul className="space-y-1.5 text-muted-foreground">
{payments.map((p) => (
<li key={p.id}>
{formatDashboardMoneyMinor(p.amount, currencyCode)}
{p.method ? ` · ${p.method}` : ""}
<li key={p.id} className="flex justify-between gap-2">
<span>
{p.method
? `${p.method}`
: t("settlementCenter:billDisplay.payment", { defaultValue: "收付" })}
{p.remark ? ` · ${p.remark}` : ""}
</span>
<span className="shrink-0 tabular-nums">
{formatDashboardMoneyMinor(p.amount, currencyCode)}
</span>
</li>
))}
</ul>
</div>
) : null}
</div>
<div className="space-y-5 text-sm">
{rebateAllocations.length > 0 ? (
<div className="space-y-2 rounded-xl border border-border/70 p-4">
<p className="font-medium">
{t("settlementBills.rebateAllocations", { defaultValue: "回水分摊" })}
</p>
<p className="text-xs text-muted-foreground">
{t("settlementCenter:billDisplay.rebateAllocationsHint", {
defaultValue: "各层级代理对回水的承担明细。",
})}
</p>
<div className="space-y-3">
<ul className="space-y-1.5 text-muted-foreground">
{rebateAllocationSummary.map((row) => (
<li key={row.key} className="flex justify-between gap-2">
<span className="min-w-0">
{row.label}
<span className="ml-2 text-xs text-muted-foreground/75">
{t("common:count", { defaultValue: "{{count}} 条", count: row.rows })}
</span>
</span>
<span className="shrink-0 tabular-nums">
{formatDashboardMoneyMinor(row.amount, currencyCode)}
</span>
</li>
))}
</ul>
<button
type="button"
className="text-xs font-medium text-primary underline-offset-4 hover:underline"
onClick={() => setRebateDetailsOpen((open) => !open)}
>
{rebateDetailsOpen
? t("settlementCenter:billDisplay.hideRawRebateAllocations", {
defaultValue: "收起原始明细",
})
: t("settlementCenter:billDisplay.showRawRebateAllocations", {
defaultValue: "展开原始明细",
})}
</button>
{rebateDetailsOpen ? (
<ul className="max-h-[280px] space-y-1.5 overflow-y-auto rounded-lg border border-dashed border-border/70 p-3 pr-2 text-muted-foreground">
{rebateAllocations.map((row) => (
<li key={row.id} className="flex justify-between gap-2">
<span>
{row.participant_label ?? `${row.participant_type}#${row.participant_id}`} ·{" "}
{row.allocation_rule}
</span>
<span className="shrink-0 tabular-nums">
{formatDashboardMoneyMinor(row.allocated_amount, currencyCode)}
</span>
</li>
))}
</ul>
) : null}
</div>
</div>
) : null}
{canManage && bill.status === "pending_confirm" ? (
<div className="space-y-3 rounded-xl border border-border/70 bg-muted/15 p-4">
<div className="space-y-1">
<p className="font-medium">
{t("settlementBills.confirm", { defaultValue: "确认账单" })}
</p>
<p className="text-xs text-muted-foreground">
{t("settlementCenter:billDisplay.confirmHint", {
defaultValue: "确认后才可以登记收款或付款。",
})}
</p>
</div>
<Button
type="button"
className="w-full"
onClick={() =>
void postSettlementBillConfirm(billId)
.then(load)
@@ -222,27 +227,58 @@ export function AgentBillDetail({
>
{t("settlementBills.confirm", { defaultValue: "确认账单" })}
</Button>
</div>
) : null}
{canManage && ["confirmed", "partial_paid", "overdue"].includes(bill.status) && bill.unpaid_amount > 0 ? (
<div className="space-y-2 rounded-md border border-border/60 p-3">
<div className="space-y-3 rounded-xl border border-border/70 p-4">
<div className="space-y-1">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="font-medium">{paymentTitle}</p>
<div className="grid gap-2 sm:grid-cols-2">
<span className="rounded-full bg-amber-50 px-2.5 py-1 text-xs font-medium text-amber-700 dark:bg-amber-950/30 dark:text-amber-300">
{t("settlementCenter:columns.unpaid", { defaultValue: "未结" })}{" "}
{formatDashboardMoneyMinor(bill.unpaid_amount, currencyCode)}
</span>
</div>
<div className="flex flex-wrap items-center gap-1 text-xs text-muted-foreground">
<span>{direction.payer}</span>
<ArrowRight className="size-3.5 shrink-0" aria-hidden />
<span>{direction.payee}</span>
</div>
</div>
<div className="grid gap-2">
<div className="space-y-1">
<Label>{t("settlementBills.paymentAmount", { defaultValue: "收付金额" })}</Label>
<Input value={payAmount} onChange={(e) => setPayAmount(e.target.value)} />
<Input
value={payAmount}
onChange={(e) => setPayAmount(e.target.value)}
placeholder={String(bill.unpaid_amount)}
/>
</div>
<div className="space-y-1">
<Label>{t("settlementBills.paymentMethod", { defaultValue: "方式" })}</Label>
<Input value={payMethod} onChange={(e) => setPayMethod(e.target.value)} placeholder="cash" />
<Label>{t("settlementBills.paymentMethod", { defaultValue: "收付方式" })}</Label>
<Input
value={payMethod}
onChange={(e) => setPayMethod(e.target.value)}
placeholder={t("settlementBills.paymentMethodPlaceholder", {
defaultValue: "例如:现金 / 银行转账",
})}
/>
</div>
<div className="space-y-1 sm:col-span-2">
<div className="space-y-1">
<Label>{t("settlementBills.paymentProof", { defaultValue: "凭证/备注" })}</Label>
<Input value={payProof} onChange={(e) => setPayProof(e.target.value)} />
<Input
value={payProof}
onChange={(e) => setPayProof(e.target.value)}
placeholder={t("settlementBills.paymentProofPlaceholder", {
defaultValue: "可填写流水号、截图说明或备注",
})}
/>
</div>
</div>
<Button
type="button"
className="w-full"
onClick={() =>
void postSettlementBillPayment(billId, {
amount: Number(payAmount),
@@ -260,15 +296,31 @@ export function AgentBillDetail({
) : null}
{canWriteOff ? (
<div className="space-y-2 rounded-md border border-border/60 p-3">
<p className="font-medium">{t("settlementBills.badDebtWriteOff", { defaultValue: "坏账核销" })}</p>
<div className="space-y-3 rounded-xl border border-border/70 p-4">
<div className="space-y-1">
<p className="font-medium">
{t("settlementBills.badDebtWriteOff", { defaultValue: "坏账核销" })}
</p>
<p className="text-xs text-muted-foreground">
{t("settlementBills.badDebtHint", {
defaultValue: "仅在确认无法收回时使用,核销后会生成坏账记录。",
})}
</p>
</div>
<div className="space-y-1">
<Label>{t("settlementBills.badDebtReason", { defaultValue: "核销原因" })}</Label>
<Input value={badDebtReason} onChange={(e) => setBadDebtReason(e.target.value)} />
<Input
value={badDebtReason}
onChange={(e) => setBadDebtReason(e.target.value)}
placeholder={t("settlementBills.badDebtReasonPlaceholder", {
defaultValue: "例如:客户失联、确认坏账",
})}
/>
</div>
<Button
type="button"
variant="destructive"
className="w-full"
onClick={() =>
void postSettlementBillBadDebtWriteOff(billId, {
reason: badDebtReason.trim() || undefined,
@@ -286,21 +338,40 @@ export function AgentBillDetail({
) : null}
{canManage && locked ? (
<div className="space-y-2 rounded-md border border-dashed border-border/60 p-3">
<p className="font-medium">{t("settlementBills.adjustment", { defaultValue: "补差/冲正单" })}</p>
<div className="space-y-3 rounded-xl border border-dashed border-border/70 p-4">
<div className="space-y-1">
<p className="font-medium">
{t("settlementBills.adjustment", { defaultValue: "补差/冲正单" })}
</p>
<p className="text-xs text-muted-foreground">
{t("settlementBills.adjustmentHint", {
defaultValue: "正数表示补收,负数表示冲减;提交后会生成一张独立调账单。",
})}
</p>
</div>
<div className="space-y-1">
<Label>{t("settlementBills.adjustmentAmount", { defaultValue: "调整金额(可负)" })}</Label>
<Input value={adjustAmount} onChange={(e) => setAdjustAmount(e.target.value)} type="number" />
<Input
value={adjustAmount}
onChange={(e) => setAdjustAmount(e.target.value)}
type="number"
placeholder={t("settlementBills.adjustmentAmountPlaceholder", {
defaultValue: "输入正数或负数",
})}
/>
</div>
<Button
type="button"
variant="outline"
className="w-full"
onClick={() =>
void postSettlementBillAdjustment(billId, {
amount: Number(adjustAmount),
reason: "manual_adjustment",
})
.then(() => toast.success(t("settlementBills.adjustmentCreated", { defaultValue: "已创建补差单" })))
.then(() =>
toast.success(t("settlementBills.adjustmentCreated", { defaultValue: "已创建补差单" })),
)
.then(onUpdated)
}
>
@@ -309,5 +380,6 @@ export function AgentBillDetail({
</div>
) : null}
</div>
</div>
);
}

View File

@@ -1,307 +0,0 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
getSettlementPeriods,
postSettlementPeriod,
postSettlementPeriodClose,
type SettlementPeriodCloseResult,
type SettlementPeriodRow,
} from "@/api/admin-agent-settlement";
import { settlementPeriodStatusLabel } from "@/modules/settlement/settlement-status-label";
import { AdminPageCard } from "@/components/admin/admin-page-card";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import {
defaultSettlementPeriodPreset,
formatSettlementPeriodSpan,
settlementPeriodPresetRange,
type SettlementPeriodPresetKey,
} from "@/lib/agent-settlement-period-range";
import { normalizeAgentSettlementCycle } from "@/lib/agent-settlement-cycle";
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { cn } from "@/lib/utils";
type AgentPeriodsConsoleProps = {
adminSiteId: number;
canManagePeriods: boolean;
settlementCycle?: string | null;
siteCurrencyCode?: string;
/** 嵌入结算中心主区时不重复外层卡片标题 */
embedded?: boolean;
onPeriodsChange?: (periods: SettlementPeriodRow[]) => void;
onPeriodClosed?: (result: SettlementPeriodCloseResult) => void;
};
const PRESET_KEYS: SettlementPeriodPresetKey[] = ["this_week", "last_week", "this_month"];
export function AgentPeriodsConsole({
adminSiteId,
canManagePeriods,
settlementCycle,
siteCurrencyCode = "NPR",
embedded = false,
onPeriodsChange,
onPeriodClosed,
}: AgentPeriodsConsoleProps): React.ReactElement | null {
const { t } = useTranslation(["agents", "settlementCenter", "common"]);
const [rows, setRows] = useState<SettlementPeriodRow[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState(false);
const [periodStart, setPeriodStart] = useState("");
const [periodEnd, setPeriodEnd] = useState("");
const [advancedOpen, setAdvancedOpen] = useState(false);
const cycle = normalizeAgentSettlementCycle(settlementCycle);
const applyPreset = useCallback(
(key: SettlementPeriodPresetKey) => {
const range = settlementPeriodPresetRange(key);
setPeriodStart(range.period_start);
setPeriodEnd(range.period_end);
},
[],
);
const onPeriodsChangeRef = useRef(onPeriodsChange);
const onPeriodClosedRef = useRef(onPeriodClosed);
onPeriodsChangeRef.current = onPeriodsChange;
onPeriodClosedRef.current = onPeriodClosed;
const load = useCallback(async () => {
setLoading(true);
setLoadError(false);
try {
const data = await getSettlementPeriods({ admin_site_id: adminSiteId });
const items = data.items ?? [];
setRows(items);
onPeriodsChangeRef.current?.(items);
} catch {
setRows([]);
setLoadError(true);
onPeriodsChangeRef.current?.([]);
} finally {
setLoading(false);
}
}, [adminSiteId]);
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
if (!canManagePeriods || periodStart !== "" || periodEnd !== "") {
return;
}
applyPreset(defaultSettlementPeriodPreset(cycle));
}, [applyPreset, canManagePeriods, cycle, periodEnd, periodStart]);
async function openPeriod(): Promise<void> {
if (!periodStart || !periodEnd) {
toast.error(t("settlementPeriods.datesRequired", { defaultValue: "请填写账期起止" }));
return;
}
try {
await postSettlementPeriod({
admin_site_id: adminSiteId,
period_start: periodStart,
period_end: periodEnd,
});
toast.success(t("settlementPeriods.opened", { defaultValue: "账期已开启" }));
await load();
} catch {
toast.error(t("settlementPeriods.openFailed", { defaultValue: "开期失败" }));
}
}
async function closePeriod(id: number): Promise<void> {
try {
const result = await postSettlementPeriodClose(id);
await load();
onPeriodClosedRef.current?.(result);
} catch {
toast.error(t("settlementPeriods.closeFailed", { defaultValue: "关账失败" }));
}
}
const presetLabel = (key: SettlementPeriodPresetKey): string => {
switch (key) {
case "this_week":
return t("settlementPeriods.presetThisWeek", { defaultValue: "本周" });
case "last_week":
return t("settlementPeriods.presetLastWeek", { defaultValue: "上周" });
case "this_month":
return t("settlementPeriods.presetThisMonth", { defaultValue: "本月" });
}
};
const body = (
<>
{canManagePeriods ? (
<div className={embedded ? "space-y-4" : "mb-4 space-y-3"}>
<div className="flex flex-wrap gap-2">
{PRESET_KEYS.map((key) => (
<Button
key={key}
type="button"
size="sm"
variant="outline"
onClick={() => applyPreset(key)}
>
{presetLabel(key)}
</Button>
))}
<Button type="button" size="sm" onClick={() => void openPeriod()}>
{t("settlementPeriods.openWithPreset", { defaultValue: "按上方时间开期" })}
</Button>
</div>
<button
type="button"
className="text-xs text-primary underline"
onClick={() => setAdvancedOpen((open) => !open)}
>
{advancedOpen
? t("settlementPeriods.hideAdvanced", { defaultValue: "收起自定义时间" })
: t("settlementPeriods.showAdvanced", { defaultValue: "自定义起止时间" })}
</button>
{advancedOpen ? (
<div className="flex flex-wrap items-end gap-3 pt-1">
<div className="space-y-1">
<Label>{t("settlementPeriods.start", { defaultValue: "开始" })}</Label>
<Input
type="datetime-local"
value={periodStart}
onChange={(e) => setPeriodStart(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label>{t("settlementPeriods.end", { defaultValue: "结束" })}</Label>
<Input
type="datetime-local"
value={periodEnd}
onChange={(e) => setPeriodEnd(e.target.value)}
/>
</div>
<Button type="button" variant="secondary" onClick={() => void openPeriod()}>
{t("settlementPeriods.open", { defaultValue: "开期" })}
</Button>
</div>
) : null}
</div>
) : null}
{loading ? (
<AdminLoadingState />
) : loadError ? (
<p className="text-sm text-destructive">
{t("settlementPeriods.loadFailed", { defaultValue: "账期列表加载失败,请稍后重试。" })}
</p>
) : rows.length === 0 ? (
<AdminNoResourceState />
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("settlementPeriods.range", { defaultValue: "账期" })}</TableHead>
<TableHead>{t("settlementPeriods.status", { defaultValue: "状态" })}</TableHead>
<TableHead className="text-right">
{t("settlementPeriods.billCounts", { defaultValue: "账单笔数" })}
</TableHead>
<TableHead className="text-right">
{t("settlementPeriods.pendingConfirm", { defaultValue: "待确认" })}
</TableHead>
<TableHead className="text-right">
{t("settlementPeriods.awaitingPayment", { defaultValue: "待收付" })}
</TableHead>
<TableHead className="text-right">
{t("settlementPeriods.totalUnpaid", { defaultValue: "未结合计" })}
</TableHead>
<TableHead className="text-right" />
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => {
const summary = row.summary;
const billCountLabel =
summary != null
? t("settlementPeriods.billCountsValue", {
defaultValue: "玩家 {{player}} · 代理 {{agent}}",
player: summary.player_bills,
agent: summary.agent_bills,
})
: "—";
return (
<TableRow key={row.id}>
<TableCell className="text-sm">
{formatSettlementPeriodSpan(row.period_start, row.period_end)}
</TableCell>
<TableCell>
<span
className={cn(
"text-xs font-medium",
row.status === "open"
? "text-amber-700"
: row.status === "completed"
? "text-emerald-700"
: "text-muted-foreground",
)}
>
{settlementPeriodStatusLabel(row.status, t)}
</span>
</TableCell>
<TableCell className="text-right text-xs text-muted-foreground">
{billCountLabel}
</TableCell>
<TableCell className="text-right text-xs tabular-nums">
{summary?.pending_confirm ?? "—"}
</TableCell>
<TableCell className="text-right text-xs tabular-nums">
{summary?.awaiting_payment ?? "—"}
</TableCell>
<TableCell className="text-right text-xs tabular-nums">
{summary != null
? formatDashboardMoneyMinor(summary.total_unpaid, siteCurrencyCode)
: "—"}
</TableCell>
<TableCell className="text-right">
{row.status === "open" ? (
<Button type="button" size="sm" onClick={() => void closePeriod(row.id)}>
{t("settlementPeriods.close", { defaultValue: "关账并生成账单" })}
</Button>
) : null}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</>
);
if (embedded) {
return body;
}
return (
<AdminPageCard title={t("settlementPeriods.manageTitle", { defaultValue: "账期管理" })}>
{body}
</AdminPageCard>
);
}

View File

@@ -27,23 +27,43 @@ export function AgentSettlementPeriodSelect({
onChange,
className,
}: AgentSettlementPeriodSelectProps): React.ReactElement {
const { t } = useTranslation("agents");
const { t } = useTranslation(["agents", "settlementCenter"]);
const sorted = [...periods].sort((a, b) => b.id - a.id);
const periodLabel = (filter: AgentSettlementPeriodFilter): string => {
if (filter === "all") {
return t("settlementCenter:filters.allPeriods", {
defaultValue: t("agents:settlementBills.allPeriods", { defaultValue: "全部账期" }),
});
}
const row = periods.find((p) => p.id === filter);
if (!row) {
return t("agents:settlementBills.periodPlaceholder", { defaultValue: "选择账期" });
}
return `${formatSettlementPeriodSpan(row.period_start, row.period_end)} · ${periodStatusLabel(row.status, t)}`;
};
return (
<Select
modal={false}
value={value === "all" ? "all" : String(value)}
onValueChange={(next) => {
onChange(next === "all" ? "all" : Number(next));
}}
>
<SelectTrigger className={className ?? "h-9 w-full max-w-md"}>
<SelectValue placeholder={t("settlementBills.periodPlaceholder", { defaultValue: "选择账期" })} />
<SelectValue placeholder={t("agents:settlementBills.periodPlaceholder", { defaultValue: "选择账期" })}>
{() => periodLabel(value)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{t("settlementBills.allPeriods", { defaultValue: "全部账期" })}
{t("settlementCenter:filters.allPeriods", {
defaultValue: t("agents:settlementBills.allPeriods", { defaultValue: "全部账期" }),
})}
</SelectItem>
{sorted.map((row) => (
<SelectItem key={row.id} value={String(row.id)}>
@@ -62,10 +82,13 @@ function periodStatusLabel(
t: (key: string, opts?: { defaultValue?: string }) => string,
): string {
if (status === "open") {
return t("settlementPeriods.statusOpen", { defaultValue: "进行中" });
return t("agents:settlementPeriods.statusOpen", { defaultValue: "进行中" });
}
if (status === "closed") {
return t("settlementPeriods.statusClosed", { defaultValue: "已关账" });
return t("agents:settlementPeriods.statusClosed", { defaultValue: "已关账" });
}
if (status === "completed") {
return t("settlementCenter:filters.statusCompleted", { defaultValue: "已结清" });
}
return status;

View File

@@ -65,22 +65,26 @@ export function AgentSettlementReportsPanel({
void load();
}, [load]);
const reportTypeLabel = (type: AgentSettlementReportType): string =>
t(`settlementReports.types.${type}`, { defaultValue: type });
return (
<div className="space-y-4 rounded-lg border border-border/60 p-4">
<div className="flex flex-wrap items-end gap-3">
<div className="space-y-1">
<Label>{t("settlementReports.type", { defaultValue: "报表类型" })}</Label>
<Select
modal={false}
value={reportType}
onValueChange={(v) => setReportType(v as AgentSettlementReportType)}
>
<SelectTrigger className="w-52">
<SelectValue />
<SelectValue>{() => reportTypeLabel(reportType)}</SelectValue>
</SelectTrigger>
<SelectContent>
{REPORT_TYPES.map((key) => (
<SelectItem key={key} value={key}>
{t(`settlementReports.types.${key}`, { defaultValue: key })}
{reportTypeLabel(key)}
</SelectItem>
))}
</SelectContent>

View File

@@ -0,0 +1,196 @@
"use client";
import { ArrowRight } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { SettlementBillRow } from "@/api/admin-agent-settlement";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { cn } from "@/lib/utils";
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
import {
buildBillAmountBreakdown,
describeBillPaymentDirection,
resolveBillPartyName,
} from "@/modules/settlement/settlement-bill-display";
import {
settlementBillStatusLabel,
settlementBillTypeLabel,
} from "@/modules/settlement/settlement-status-label";
type SettlementBillSummaryHeaderProps = {
bill: SettlementBillRow;
currencyCode: string;
};
export function SettlementBillSummaryHeader({
bill,
currencyCode,
}: SettlementBillSummaryHeaderProps): React.ReactElement {
const { t } = useTranslation(["settlementCenter", "agents"]);
const direction = describeBillPaymentDirection(bill, t);
const unpaid = bill.unpaid_amount > 0;
return (
<div className="space-y-4 rounded-xl border border-border/70 bg-muted/15 p-4">
<div className="flex flex-wrap items-center gap-2">
<AdminStatusBadge status={bill.status}>
{settlementBillStatusLabel(bill.status, t)}
</AdminStatusBadge>
<span className="text-sm text-muted-foreground">
{settlementBillTypeLabel(bill.bill_type, t)}
</span>
</div>
<div className="flex flex-wrap items-center gap-2 text-base">
<span className="font-semibold text-foreground">{direction.payer}</span>
<span className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2.5 py-0.5 text-xs font-medium text-primary">
{t("settlementCenter:billDisplay.pays", { defaultValue: "应付" })}
<ArrowRight className="size-3.5" aria-hidden />
</span>
<span className="font-semibold text-foreground">{direction.payee}</span>
</div>
<div>
<p className="text-xs text-muted-foreground">
{t("settlementCenter:billDisplay.settlementAmount", { defaultValue: "本期结算金额" })}
</p>
<p className="mt-1 text-2xl font-bold tabular-nums tracking-tight text-foreground">
{formatDashboardMoneyMinor(direction.amount, currencyCode)}
</p>
</div>
<div className="grid gap-2 sm:grid-cols-2">
<div className="rounded-lg border border-border/50 bg-background/80 px-3 py-2">
<p className="text-xs text-muted-foreground">
{t("settlementCenter:columns.paid", { defaultValue: "已收付" })}
</p>
<p className="mt-0.5 font-medium tabular-nums">
{formatDashboardMoneyMinor(bill.paid_amount ?? 0, currencyCode)}
</p>
</div>
<div
className={cn(
"rounded-lg border px-3 py-2",
unpaid
? "border-amber-200/80 bg-amber-50/80 dark:border-amber-900/50 dark:bg-amber-950/20"
: "border-border/50 bg-background/80",
)}
>
<p className="text-xs text-muted-foreground">
{t("settlementCenter:columns.unpaid", { defaultValue: "未结" })}
</p>
<p
className={cn(
"mt-0.5 font-semibold tabular-nums",
unpaid && "text-amber-900 dark:text-amber-200",
)}
>
{formatDashboardMoneyMinor(bill.unpaid_amount, currencyCode)}
</p>
{unpaid ? (
<p className="mt-1 text-xs text-muted-foreground">
{bill.status === "pending_confirm"
? t("settlementCenter:billDisplay.unpaidPendingConfirm", {
defaultValue: "确认账单后可登记收付",
})
: t("settlementCenter:billDisplay.unpaidAwaitingPayment", {
defaultValue: "请登记线下收付",
})}
</p>
) : (
<p className="mt-1 text-xs text-emerald-700 dark:text-emerald-400">
{t("settlementCenter:billDisplay.fullySettled", { defaultValue: "本期已结清" })}
</p>
)}
</div>
</div>
</div>
);
}
type SettlementBillAmountBreakdownProps = {
bill: SettlementBillRow;
currencyCode: string;
};
export function SettlementBillAmountBreakdown({
bill,
currencyCode,
}: SettlementBillAmountBreakdownProps): React.ReactElement | null {
const { t } = useTranslation(["settlementCenter", "agents"]);
const lines = buildBillAmountBreakdown(bill, t);
if (lines.length === 0) {
return null;
}
return (
<div className="space-y-3 rounded-xl border border-border/70 p-4">
<p className="font-medium text-foreground">
{t("settlementCenter:billDisplay.howAmountWorks", { defaultValue: "金额怎么来的" })}
</p>
<div className="space-y-2">
{lines.map((line) => {
const prefix =
line.kind === "subtract"
? ""
: line.kind === "add" && lines.indexOf(line) > 0
? "+"
: line.kind === "subtotal" || line.kind === "total"
? "="
: "";
return (
<div
key={line.key}
className={cn(
"flex items-start justify-between gap-3 text-sm",
(line.kind === "subtotal" || line.kind === "total") &&
"border-t border-border/60 pt-2 font-medium",
line.kind === "total" && "text-base",
)}
>
<div className="min-w-0">
<span className="text-muted-foreground">
{prefix ? <span className="mr-1.5 tabular-nums">{prefix}</span> : null}
{line.label}
</span>
</div>
<span className="shrink-0 tabular-nums">
{formatDashboardMoneyMinor(line.amount, currencyCode)}
</span>
</div>
);
})}
</div>
</div>
);
}
type SettlementBillPartiesRowProps = {
bill: SettlementBillRow;
};
export function SettlementBillPartiesRow({ bill }: SettlementBillPartiesRowProps): React.ReactElement {
const { t } = useTranslation(["settlementCenter", "agents"]);
const owner = resolveBillPartyName(bill, "owner", t);
const counterparty = resolveBillPartyName(bill, "counterparty", t);
return (
<div className="grid gap-2 text-sm sm:grid-cols-2">
<div className="rounded-lg border border-border/60 bg-muted/15 px-3 py-2">
<p className="text-xs text-muted-foreground">
{t("settlementCenter:billDisplay.billOwner", { defaultValue: "账单主体" })}
</p>
<p className="mt-1 font-medium text-foreground">{owner}</p>
</div>
<div className="rounded-lg border border-border/60 bg-muted/15 px-3 py-2">
<p className="text-xs text-muted-foreground">
{t("settlementCenter:billDisplay.billCounterparty", { defaultValue: "结算对手" })}
</p>
<p className="mt-1 font-medium text-foreground">{counterparty}</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,320 @@
import type { TFunction } from "i18next";
import type { SettlementBillRow } from "@/api/admin-agent-settlement";
export type BillPartyRole = "owner" | "counterparty";
export type BillPaymentDirection = {
payer: string;
payee: string;
ownerOwes: boolean;
amount: number;
};
export function billLayerLabel(
bill: SettlementBillRow,
t: TFunction<["agents", "settlementCenter"]>,
): string {
if (bill.bill_type === "player") {
return t("settlementCenter:billsPanel.layer.player", {
defaultValue: "玩家与直属代理结算",
});
}
if (bill.bill_type === "agent") {
return t("settlementCenter:billsPanel.layer.agent", {
defaultValue: "代理与上级 / 平台结算",
});
}
if (bill.bill_type === "adjustment") {
return t("settlementCenter:billsPanel.layer.adjustment", {
defaultValue: "结算差异调账",
});
}
if (bill.bill_type === "bad_debt") {
return t("settlementCenter:billsPanel.layer.badDebt", {
defaultValue: "坏账核销归档",
});
}
if (bill.bill_type === "reversal") {
return t("settlementCenter:billsPanel.layer.reversal", {
defaultValue: "历史账单冲正",
});
}
return t("settlementCenter:billsPanel.layer.generic", {
defaultValue: "结算辅助单据",
});
}
export function billDirectionHint(
bill: SettlementBillRow,
t: TFunction<["agents", "settlementCenter"]>,
): string {
if (bill.bill_type === "player") {
return bill.net_amount > 0
? t("settlementCenter:billDisplay.flowHint.playerPayAgent", {
defaultValue: "玩家应向直属代理结算",
})
: t("settlementCenter:billDisplay.flowHint.agentPayPlayer", {
defaultValue: "直属代理应向玩家结算",
});
}
if (bill.bill_type === "agent") {
return bill.net_amount > 0
? t("settlementCenter:billDisplay.flowHint.agentPayUpstream", {
defaultValue: "本级代理应向上级 / 平台结算",
})
: t("settlementCenter:billDisplay.flowHint.upstreamPayAgent", {
defaultValue: "上级 / 平台应向本级代理结算",
});
}
if (bill.bill_type === "adjustment") {
return t("settlementCenter:billDisplay.flowHint.adjustment", {
defaultValue: "补差单独结转,不改变原账单主体关系",
});
}
if (bill.bill_type === "bad_debt") {
return t("settlementCenter:billDisplay.flowHint.badDebt", {
defaultValue: "核销未结金额,并生成坏账归档记录",
});
}
if (bill.bill_type === "reversal") {
return t("settlementCenter:billDisplay.flowHint.reversal", {
defaultValue: "冲正原账单影响,按冲正规则回退",
});
}
return t("settlementCenter:billDisplay.flowHint.generic", {
defaultValue: "按账单结算关系执行收付或调账",
});
}
export type BillBreakdownLine = {
key: string;
label: string;
amount: number;
kind: "add" | "subtract" | "subtotal" | "total";
hint?: string;
};
export function parseBillMeta(metaJson: SettlementBillRow["meta_json"]): {
share_profit?: number;
platform_share_profit?: number;
} {
if (metaJson == null || metaJson === "") {
return {};
}
try {
const parsed =
typeof metaJson === "string" ? (JSON.parse(metaJson) as Record<string, unknown>) : metaJson;
return {
share_profit: parsed.share_profit != null ? Number(parsed.share_profit) : undefined,
platform_share_profit:
parsed.platform_share_profit != null ? Number(parsed.platform_share_profit) : undefined,
};
} catch {
return {};
}
}
export function resolveBillPartyName(
bill: SettlementBillRow,
role: BillPartyRole,
t: TFunction<["agents", "settlementCenter"]>,
): string {
const platformLabel = t("agents:settlementBills.platform", { defaultValue: "平台" });
const fallbackPartyName = (type: string, id: number): string =>
type === "platform" ? platformLabel : `${type}#${id}`;
if (role === "owner") {
if (bill.owner_type === "platform") {
return platformLabel;
}
if (bill.bill_type === "player") {
return bill.player_username ?? bill.owner_label ?? fallbackPartyName(bill.owner_type, bill.owner_id);
}
return bill.owner_party_label ?? bill.owner_label ?? fallbackPartyName(bill.owner_type, bill.owner_id);
}
if (
bill.counterparty_type === "platform" ||
bill.counterparty_label === "platform" ||
bill.superior_agent_label === "platform"
) {
return platformLabel;
}
if (bill.bill_type === "player") {
return (
bill.direct_agent_label ??
bill.counterparty_label ??
fallbackPartyName(bill.counterparty_type, bill.counterparty_id)
);
}
return (
bill.superior_agent_label ??
bill.counterparty_label ??
fallbackPartyName(bill.counterparty_type, bill.counterparty_id)
);
}
export function describeBillPaymentDirection(
bill: SettlementBillRow,
t: TFunction<["agents", "settlementCenter"]>,
): BillPaymentDirection {
const owner = resolveBillPartyName(bill, "owner", t);
const counterparty = resolveBillPartyName(bill, "counterparty", t);
const ownerOwes = bill.net_amount > 0;
const amount = Math.abs(bill.net_amount);
return {
payer: ownerOwes ? owner : counterparty,
payee: ownerOwes ? counterparty : owner,
ownerOwes,
amount,
};
}
export function buildBillAmountBreakdown(
bill: SettlementBillRow,
t: TFunction<["agents", "settlementCenter"]>,
): BillBreakdownLine[] {
const meta = parseBillMeta(bill.meta_json);
const gross = bill.gross_win_loss ?? 0;
const rebate = bill.rebate_amount ?? 0;
const shareProfit = meta.share_profit ?? 0;
const rounding = bill.platform_rounding_adjustment ?? 0;
const teamNet = gross - rebate;
if (bill.bill_type === "player") {
const lines: BillBreakdownLine[] = [];
if (bill.gross_win_loss != null) {
lines.push({
key: "gross",
label: t("settlementCenter:billDisplay.playerGross", { defaultValue: "游戏输赢" }),
amount: gross,
kind: "add",
hint:
gross > 0
? t("settlementCenter:billDisplay.playerLostHint", { defaultValue: "玩家输了,应付代理" })
: gross < 0
? t("settlementCenter:billDisplay.playerWonHint", { defaultValue: "玩家赢了,代理应付玩家" })
: undefined,
});
}
if (bill.rebate_amount != null && rebate !== 0) {
lines.push({
key: "rebate",
label: t("settlementCenter:billDisplay.rebate", { defaultValue: "回水" }),
amount: rebate,
kind: "subtract",
});
}
if (rounding !== 0) {
lines.push({
key: "rounding",
label: t("agents:settlementBills.platformRounding", { defaultValue: "平台尾差" }),
amount: rounding,
kind: rounding > 0 ? "subtract" : "add",
});
}
lines.push({
key: "net",
label:
bill.net_amount > 0
? t("settlementCenter:billDisplay.playerNet", { defaultValue: "玩家应付净额" })
: t("settlementCenter:billDisplay.playerNetReceive", { defaultValue: "代理应付玩家" }),
amount: Math.abs(bill.net_amount),
kind: "total",
});
return lines;
}
if (bill.bill_type === "agent") {
const owner = resolveBillPartyName(bill, "owner", t);
const lines: BillBreakdownLine[] = [];
if (bill.gross_win_loss != null) {
lines.push({
key: "gross",
label: t("settlementCenter:billDisplay.teamGross", { defaultValue: "团队游戏输赢" }),
amount: gross,
kind: "add",
hint: t("settlementCenter:billDisplay.teamGrossHint", {
defaultValue: "含本级及下级玩家的合计",
}),
});
}
if (bill.rebate_amount != null && rebate !== 0) {
lines.push({
key: "rebate",
label: t("settlementCenter:billDisplay.teamRebate", { defaultValue: "团队回水" }),
amount: rebate,
kind: "subtract",
});
}
if (bill.gross_win_loss != null || bill.rebate_amount != null) {
lines.push({
key: "team-net",
label: t("settlementCenter:billDisplay.teamNet", { defaultValue: "团队净额" }),
amount: Math.abs(teamNet),
kind: "subtotal",
});
}
if (meta.share_profit != null) {
lines.push({
key: "share",
label: t("settlementCenter:billDisplay.agentShareKeep", {
defaultValue: "{{agent}} 本级占成",
agent: owner,
}),
amount: shareProfit,
kind: "subtract",
hint: t("settlementCenter:billDisplay.agentShareKeepHint", {
defaultValue: "本级按占成比例留下的利润",
}),
});
}
if (rounding !== 0) {
lines.push({
key: "rounding",
label: t("agents:settlementBills.platformRounding", { defaultValue: "平台尾差" }),
amount: rounding,
kind: rounding > 0 ? "subtract" : "add",
});
}
lines.push({
key: "net",
label:
bill.net_amount > 0
? t("settlementCenter:billDisplay.agentNet", {
defaultValue: "{{agent}} 应付上级",
agent: owner,
})
: t("settlementCenter:billDisplay.agentNetReceive", {
defaultValue: "上级应付 {{agent}}",
agent: owner,
}),
amount: Math.abs(bill.net_amount),
kind: "total",
});
return lines;
}
return [];
}
export function billGrossColumnHint(
bill: SettlementBillRow,
t: TFunction<["agents", "settlementCenter"]>,
): string | undefined {
if (bill.bill_type === "player") {
return t("settlementCenter:billDisplay.playerGrossShort", { defaultValue: "玩家" });
}
if (bill.bill_type === "agent") {
return t("settlementCenter:billDisplay.teamGrossShort", { defaultValue: "团队" });
}
return undefined;
}

View File

@@ -1,140 +0,0 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
getSettlementBills,
type SettlementBillListScope,
type SettlementBillRow,
} from "@/api/admin-agent-settlement";
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { useAsyncEffect } from "@/hooks/use-async-effect";
import type { AgentSettlementPeriodFilter } from "@/modules/settlement/agent-settlement-period-select";
import { SettlementBillsTable } from "@/modules/settlement/settlement-bills-table";
import { cn } from "@/lib/utils";
import { LotteryApiBizError } from "@/types/api/errors";
export type BillCategory = "all" | "player" | "agent" | "pending_confirm" | "awaiting_payment";
const CATEGORY_OPTIONS: { value: BillCategory; labelKey: string }[] = [
{ value: "all", labelKey: "billsPanel.category.all" },
{ value: "player", labelKey: "billsPanel.category.player" },
{ value: "agent", labelKey: "billsPanel.category.agent" },
{ value: "pending_confirm", labelKey: "billsPanel.category.pendingConfirm" },
{ value: "awaiting_payment", labelKey: "billsPanel.category.awaitingPayment" },
];
function categoryQuery(category: BillCategory): {
bill_type?: string;
scope?: SettlementBillListScope;
} {
switch (category) {
case "player":
return { bill_type: "player" };
case "agent":
return { bill_type: "agent" };
case "pending_confirm":
return { scope: "pending_confirm" };
case "awaiting_payment":
return { scope: "awaiting_payment" };
default:
return {};
}
}
type SettlementBillsPanelProps = {
adminSiteId: number;
periodFilter: AgentSettlementPeriodFilter;
currencyCode: string;
onOpenDetail: (billId: number) => void;
initialCategory?: BillCategory;
refreshKey?: number;
};
export function SettlementBillsPanel({
adminSiteId,
periodFilter,
currencyCode,
onOpenDetail,
initialCategory = "all",
refreshKey = 0,
}: SettlementBillsPanelProps): React.ReactElement {
const { t } = useTranslation("settlementCenter");
const [category, setCategory] = useState<BillCategory>(initialCategory);
useEffect(() => {
setCategory(initialCategory);
}, [initialCategory]);
const [rows, setRows] = useState<SettlementBillRow[]>([]);
const [loading, setLoading] = useState(true);
const periodId = periodFilter === "all" ? undefined : periodFilter;
const load = useCallback(async () => {
setLoading(true);
try {
const q = categoryQuery(category);
const data = await getSettlementBills({
admin_site_id: adminSiteId,
settlement_period_id: periodId,
bill_type: q.bill_type,
scope: q.scope,
});
setRows(data.items ?? []);
} catch (err: unknown) {
setRows([]);
toast.error(
err instanceof LotteryApiBizError
? err.message
: t("errors.loadBills", { defaultValue: "账单加载失败" }),
);
} finally {
setLoading(false);
}
}, [adminSiteId, category, periodId, t]);
useAsyncEffect(() => {
void load();
}, [load, refreshKey]);
return (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
{t("billsPanel.intro", {
defaultValue: "关账后生成的占成账单;可按类型与状态筛选,行内打开详情进行确认与收付。",
})}
</p>
<div className="flex flex-wrap gap-1.5 border-b border-border/60 pb-3">
{CATEGORY_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setCategory(opt.value)}
className={cn(
"rounded-full border px-3 py-1 text-xs font-medium transition-colors",
category === opt.value
? "border-primary/40 bg-primary/10 text-foreground"
: "border-transparent bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
{t(opt.labelKey, { defaultValue: opt.value })}
</button>
))}
</div>
{loading && rows.length === 0 ? (
<AdminLoadingState />
) : (
<SettlementBillsTable
rows={rows}
loading={loading}
currencyCode={currencyCode}
onOpenDetail={onOpenDetail}
/>
)}
</div>
);
}

View File

@@ -1,13 +1,23 @@
"use client";
import { ArrowRight, Eye } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { SettlementBillRow } from "@/api/admin-agent-settlement";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { cn } from "@/lib/utils";
import { formatSettlementPeriodSpan } from "@/lib/agent-settlement-period-range";
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
import { PlayerFundingModeBadge } from "@/components/admin/player-funding-badges";
import {
describeBillPaymentDirection,
} from "@/modules/settlement/settlement-bill-display";
import {
formatPlatformPartyLabel,
SettlementDashCell,
} from "@/modules/settlement/settlement-party-cells";
import {
settlementBillStatusLabel,
settlementBillTypeLabel,
@@ -21,27 +31,125 @@ import {
TableRow,
} from "@/components/ui/table";
type BillTypeFilter = "all" | "player" | "agent";
type SettlementBillsTableProps = {
rows: SettlementBillRow[];
loading: boolean;
currencyCode: string;
billTypeFilter?: BillTypeFilter;
emptyMessage?: string;
onOpenDetail: (billId: number) => void;
};
function billRowTone(row: SettlementBillRow): string {
if (row.bill_type === "player") {
return "border-l-2 border-l-sky-300/80";
}
if (row.bill_type === "agent") {
return "border-l-2 border-l-amber-300/80 bg-amber-50/20";
}
if (row.bill_type === "adjustment" || row.bill_type === "reversal") {
return "border-l-2 border-l-emerald-300/80 bg-emerald-50/20";
}
if (row.bill_type === "bad_debt") {
return "border-l-2 border-l-rose-300/80 bg-rose-50/20";
}
return "";
}
function billTypeTone(row: SettlementBillRow): string {
if (row.bill_type === "player") {
return "border-sky-200 bg-sky-50 text-sky-700";
}
if (row.bill_type === "agent") {
return "border-amber-200 bg-amber-50 text-amber-800";
}
if (row.bill_type === "adjustment" || row.bill_type === "reversal") {
return "border-emerald-200 bg-emerald-50 text-emerald-700";
}
if (row.bill_type === "bad_debt") {
return "border-rose-200 bg-rose-50 text-rose-700";
}
return "border-border/70 bg-muted/25 text-muted-foreground";
}
function signedMoneyClass(amount: number, emphasize = false): string {
if (amount < 0) {
return cn("text-destructive", emphasize && "font-medium");
}
if (amount > 0) {
return cn("text-emerald-700", emphasize && "font-medium");
}
return "text-muted-foreground";
}
function formatSignedMoney(amount: number, currencyCode: string): string {
if (amount === 0) {
return formatDashboardMoneyMinor(0, currencyCode);
}
const prefix = amount < 0 ? "" : "+";
return `${prefix}${formatDashboardMoneyMinor(Math.abs(amount), currencyCode)}`;
}
function unpaidMoneyClass(row: SettlementBillRow): string {
if (row.unpaid_amount <= 0) {
return "text-muted-foreground";
}
if (row.status === "overdue") {
return "font-medium text-destructive";
}
return "font-medium text-amber-800 dark:text-amber-300";
}
function ownerPartyLabel(row: SettlementBillRow): string | null {
if (row.bill_type === "player") {
return row.player_username ?? row.owner_label ?? null;
}
if (row.bill_type === "agent") {
return row.owner_party_label ?? row.owner_label ?? null;
}
return row.owner_label ?? null;
}
function fundingModeHint(row: SettlementBillRow, t: (key: string, options?: Record<string, unknown>) => string) {
if (row.owner_funding_mode !== "credit") {
return null;
}
return (
<span className="rounded-full border border-border/70 bg-muted/30 px-1.5 py-0.5 text-[11px] font-normal leading-none text-muted-foreground">
{t("columns.creditMode", { defaultValue: "信用盘" })}
</span>
);
}
export function SettlementBillsTable({
rows,
loading,
currencyCode,
billTypeFilter = "all",
emptyMessage,
onOpenDetail,
}: SettlementBillsTableProps): React.ReactElement {
const { t } = useTranslation(["settlementCenter", "agents", "common"]);
const agentView = billTypeFilter === "agent";
const playerView = billTypeFilter === "player";
const mixedView = billTypeFilter === "all";
if (loading) {
return <AdminLoadingState />;
}
if (rows.length === 0) {
return <AdminNoResourceState />;
return <AdminNoResourceState message={emptyMessage} />;
}
return (
@@ -49,69 +157,153 @@ export function SettlementBillsTable({
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("columns.billId", { defaultValue: "账单 ID" })}</TableHead>
<TableHead>{t("columns.period", { defaultValue: "账期" })}</TableHead>
<TableHead>{t("columns.type", { defaultValue: "类型" })}</TableHead>
{playerView ? (
<>
<TableHead>{t("columns.playerAccount", { defaultValue: "玩家账号" })}</TableHead>
<TableHead>{t("columns.playerId", { defaultValue: "玩家 ID" })}</TableHead>
<TableHead>{t("columns.directAgent", { defaultValue: "直属代理" })}</TableHead>
</>
) : null}
{agentView ? (
<TableHead>{t("columns.owner", { defaultValue: "本方" })}</TableHead>
<TableHead>{t("columns.counterparty", { defaultValue: "对方" })}</TableHead>
) : null}
{mixedView ? (
<TableHead>{t("columns.owner", { defaultValue: "本方" })}</TableHead>
) : null}
<TableHead>{t("billDisplay.settlementFlow", { defaultValue: "谁付谁" })}</TableHead>
<TableHead>{t("columns.superiorAgent", { defaultValue: "上级" })}</TableHead>
{!playerView ? (
<TableHead className="text-right">{t("columns.gross", { defaultValue: "输赢" })}</TableHead>
<TableHead className="text-right">{t("columns.net", { defaultValue: "净额" })}</TableHead>
) : null}
<TableHead className="text-right">{t("billDisplay.settlementAmount", { defaultValue: "结算金额" })}</TableHead>
<TableHead className="text-right">{t("columns.paid", { defaultValue: "已收付" })}</TableHead>
<TableHead className="text-right">{t("columns.unpaid", { defaultValue: "未结" })}</TableHead>
<TableHead>{t("columns.status", { defaultValue: "状态" })}</TableHead>
<TableHead />
<TableHead className="sticky right-0 z-10 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>
{rows.map((row) => (
<TableRow key={row.id}>
{rows.map((row) => {
const isPlayerBill = row.bill_type === "player";
const direction = describeBillPaymentDirection(row, t);
return (
<TableRow key={row.id} className={billRowTone(row)}>
<TableCell className="font-mono text-xs">{row.id}</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{formatSettlementPeriodSpan(row.period_start, row.period_end)}
</TableCell>
<TableCell>{settlementBillTypeLabel(row.bill_type, t)}</TableCell>
<TableCell>
<span
className={cn(
"inline-flex rounded-full border px-2 py-0.5 text-xs font-medium",
billTypeTone(row),
)}
>
{settlementBillTypeLabel(row.bill_type, t)}
</span>
</TableCell>
{playerView ? (
<>
<TableCell>
<div className="flex flex-wrap items-center gap-1.5">
<span>{row.owner_label ?? `${row.owner_type}#${row.owner_id}`}</span>
{row.owner_type === "player" && row.owner_funding_mode ? (
<PlayerFundingModeBadge
row={{
funding_mode: row.owner_funding_mode,
uses_credit: row.owner_funding_mode === "credit",
}}
/>
) : null}
<SettlementDashCell value={row.player_username ?? row.owner_label} />
{fundingModeHint(row, t)}
</div>
</TableCell>
<TableCell>
{row.counterparty_label === "platform"
? t("agents:settlementBills.platform", { defaultValue: "平台" })
: row.counterparty_label ?? `${row.counterparty_type}#${row.counterparty_id}`}
<TableCell className="font-mono text-xs">
<SettlementDashCell
value={row.player_site_player_id ?? row.player_id_display ?? row.owner_id}
mono
/>
</TableCell>
<TableCell className="text-right tabular-nums text-muted-foreground">
{row.gross_win_loss != null
? formatDashboardMoneyMinor(row.gross_win_loss, currencyCode)
: "—"}
<TableCell className="text-sm">
<SettlementDashCell value={row.direct_agent_label} />
</TableCell>
</>
) : null}
{agentView ? (
<TableCell className="text-sm">
<SettlementDashCell value={ownerPartyLabel(row)} />
</TableCell>
) : null}
{mixedView ? (
<TableCell className="text-sm">
{isPlayerBill ? (
<div className="flex flex-wrap items-center gap-1.5">
<SettlementDashCell value={ownerPartyLabel(row)} />
{fundingModeHint(row, t)}
</div>
) : (
<SettlementDashCell value={ownerPartyLabel(row)} />
)}
</TableCell>
) : null}
<TableCell className="min-w-[10rem] text-sm">
<div className="flex flex-wrap items-center gap-1 text-foreground">
<span className="font-medium">{direction.payer}</span>
<ArrowRight className="size-3.5 shrink-0 text-muted-foreground" aria-hidden />
<span className="font-medium">{direction.payee}</span>
</div>
</TableCell>
<TableCell className="text-sm">
{formatPlatformPartyLabel(row.superior_agent_label, t)}
</TableCell>
{!playerView ? (
<TableCell
className={cn(
"text-right tabular-nums",
row.gross_win_loss != null
? signedMoneyClass(row.gross_win_loss)
: "text-muted-foreground",
)}
>
{row.gross_win_loss != null ? (
<div>{formatSignedMoney(row.gross_win_loss, currencyCode)}</div>
) : (
"—"
)}
</TableCell>
) : null}
<TableCell className="text-right tabular-nums">
{formatDashboardMoneyMinor(row.net_amount, currencyCode)}
<div className={cn("font-semibold", signedMoneyClass(row.net_amount, true))}>
{formatDashboardMoneyMinor(direction.amount, currencyCode)}
</div>
</TableCell>
<TableCell className="text-right tabular-nums text-muted-foreground">
{formatDashboardMoneyMinor(row.paid_amount ?? 0, currencyCode)}
</TableCell>
<TableCell className="text-right tabular-nums">
<TableCell className={cn("text-right tabular-nums", unpaidMoneyClass(row))}>
{formatDashboardMoneyMinor(row.unpaid_amount, currencyCode)}
</TableCell>
<TableCell>{settlementBillStatusLabel(row.status, t)}</TableCell>
<TableCell>
<button
type="button"
className="text-sm text-primary underline"
onClick={() => onOpenDetail(row.id)}
<AdminStatusBadge status={row.status}>
{settlementBillStatusLabel(row.status, t)}
</AdminStatusBadge>
</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()}
>
{t("actions.detail", { defaultValue: "详情 / 收付" })}
</button>
<AdminRowActionsMenu
actions={[
{
key: "detail",
label: t("actions.detail", { defaultValue: "详情" }),
icon: Eye,
onClick: () => onOpenDetail(row.id),
},
]}
/>
</TableCell>
</TableRow>
))}
);
})}
</TableBody>
</Table>
</div>

View File

@@ -0,0 +1,35 @@
export type SettlementPeriodView = "bills" | "ledger";
const VALID_VIEWS: SettlementPeriodView[] = ["bills", "ledger"];
export function settlementCenterListHref(): string {
return "/admin/settlement-center";
}
export function settlementPeriodViewHref(
periodId: number,
view: SettlementPeriodView = "bills",
): string {
return `/admin/settlement-center?period=${periodId}&view=${view}`;
}
export function parseSettlementCenterView(
periodRaw: string | null,
viewRaw: string | null,
): { periodId: number | null; view: SettlementPeriodView } {
const periodId = periodRaw !== null && periodRaw !== "" ? Number(periodRaw) : NaN;
const normalizedView = viewRaw === "reports" ? "bills" : viewRaw;
const view =
normalizedView !== null && VALID_VIEWS.includes(normalizedView as SettlementPeriodView)
? (normalizedView as SettlementPeriodView)
: "bills";
return {
periodId: Number.isInteger(periodId) && periodId > 0 ? periodId : null,
view,
};
}
export function isSettlementPeriodView(value: string): value is SettlementPeriodView {
return VALID_VIEWS.includes(value as SettlementPeriodView);
}

View File

@@ -1,101 +0,0 @@
"use client";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
export type SettlementCenterSection =
| "overview"
| "periods"
| "ledger"
| "bills";
type TabDef = {
key: SettlementCenterSection;
labelKey: string;
defaultLabel: string;
group: "hub" | "finance";
badge?: string;
};
type SettlementCenterNavProps = {
active: SettlementCenterSection;
onChange: (section: SettlementCenterSection) => void;
counts: {
pendingConfirm: number;
awaitingPayment: number;
};
siteSelector?: React.ReactNode;
};
const TABS: TabDef[] = [
{ key: "overview", labelKey: "nav.overview", defaultLabel: "概览", group: "hub" },
{ key: "periods", labelKey: "nav.periods", defaultLabel: "账期管理", group: "hub" },
{ key: "ledger", labelKey: "nav.ledger", defaultLabel: "账务流水", group: "finance" },
{
key: "bills",
labelKey: "nav.bills",
defaultLabel: "账单",
group: "finance",
},
];
export function SettlementCenterNav({
active,
onChange,
counts,
siteSelector,
}: SettlementCenterNavProps): React.ReactElement {
const { t } = useTranslation("settlementCenter");
const billBadge =
counts.pendingConfirm + counts.awaitingPayment > 0
? String(counts.pendingConfirm + counts.awaitingPayment)
: undefined;
const hubTabs = TABS.filter((tab) => tab.group === "hub");
const financeTabs = TABS.filter((tab) => tab.group === "finance");
function renderTab(tab: TabDef, showSeparatorBefore: boolean): React.ReactElement {
const isActive = active === tab.key;
const badge = tab.key === "bills" ? billBadge : tab.badge;
return (
<span key={tab.key} className="inline-flex items-center">
{showSeparatorBefore ? (
<span className="mx-1 hidden h-5 w-px bg-border/80 sm:inline-block" aria-hidden />
) : null}
<button
type="button"
onClick={() => onChange(tab.key)}
className={cn(
"inline-flex items-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{t(tab.labelKey, { defaultValue: tab.defaultLabel })}
{badge ? (
<span className="rounded-full bg-amber-100 px-1.5 py-0.5 text-xs font-semibold tabular-nums text-amber-900">
{badge}
</span>
) : null}
</button>
</span>
);
}
return (
<div className="flex w-full flex-wrap items-center justify-between gap-3 rounded-lg bg-muted/50 p-1">
<nav
aria-label={t("subnav.label", { defaultValue: "结算中心导航" })}
className="inline-flex max-w-full flex-wrap items-center gap-1"
>
{hubTabs.map((tab) => renderTab(tab, false))}
{financeTabs.map((tab, index) => renderTab(tab, index === 0))}
</nav>
{siteSelector ?? null}
</div>
);
}

View File

@@ -0,0 +1,111 @@
"use client";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { SettlementPeriodRow } from "@/api/admin-agent-settlement";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { SettlementCreditLedgerPanel } from "@/modules/settlement/settlement-credit-ledger-panel";
import { SettlementMainPanel } from "@/modules/settlement/settlement-main-panel";
import {
settlementCenterListHref,
settlementPeriodViewHref,
type SettlementPeriodView,
} from "@/modules/settlement/settlement-center-nav";
import { formatSettlementPeriodSpan } from "@/lib/agent-settlement-period-range";
import { settlementPeriodStatusLabel } from "@/modules/settlement/settlement-status-label";
import { AdminSubnav, AdminSubnavLink } from "@/components/admin/admin-subnav";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type SettlementCenterPeriodDetailProps = {
period: SettlementPeriodRow;
view: SettlementPeriodView;
adminSiteId: number;
currencyCode: string;
canOperateBills: boolean;
refreshKey: number;
onOpenBillDetail: (billId: number) => void;
};
export function SettlementCenterPeriodDetail({
period,
view,
adminSiteId,
currencyCode,
canOperateBills,
refreshKey,
onOpenBillDetail,
}: SettlementCenterPeriodDetailProps): React.ReactElement {
const { t } = useTranslation("settlementCenter");
const subViews: { key: SettlementPeriodView; label: string }[] = [
{ key: "bills", label: t("nav.bills", { defaultValue: "账单" }) },
{ key: "ledger", label: t("nav.ledger", { defaultValue: "账务流水" }) },
];
const pendingConfirm = period.summary?.pending_confirm ?? 0;
const awaitingPayment = period.summary?.awaiting_payment ?? 0;
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 flex-col gap-2">
<Link
href={settlementCenterListHref()}
className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "h-8 w-fit px-2")}
>
<ArrowLeft className="size-4" aria-hidden />
{t("periodDetail.back", { defaultValue: "返回账期列表" })}
</Link>
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-base font-semibold tracking-tight">
{formatSettlementPeriodSpan(period.period_start, period.period_end)}
</h2>
<AdminStatusBadge status={period.status}>
{settlementPeriodStatusLabel(period.status, t)}
</AdminStatusBadge>
</div>
</div>
</div>
<AdminSubnav aria-label={t("nav.aria", { defaultValue: "账期视图" })}>
{subViews.map((item) => (
<AdminSubnavLink
key={item.key}
href={settlementPeriodViewHref(period.id, item.key)}
active={view === item.key}
>
{item.label}
</AdminSubnavLink>
))}
</AdminSubnav>
{view === "bills" ? (
<SettlementMainPanel
key={`${adminSiteId}-${period.id}-${refreshKey}`}
adminSiteId={adminSiteId}
currencyCode={currencyCode}
periodFilter={period.id}
onOpenBillDetail={onOpenBillDetail}
refreshKey={refreshKey}
pendingConfirm={pendingConfirm}
awaitingPayment={awaitingPayment}
selectedPeriodStatus={period.status}
/>
) : null}
{view === "ledger" ? (
<SettlementCreditLedgerPanel
key={`${adminSiteId}-${period.id}-${refreshKey}`}
adminSiteId={adminSiteId}
settlementPeriodId={period.id}
currencyCode={currencyCode}
refreshKey={refreshKey}
/>
) : null}
</div>
);
}

View File

@@ -1,30 +1,21 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslation } from "react-i18next";
import { CalendarClock, CircleDollarSign, ClipboardCheck, Landmark } from "lucide-react";
import { toast } from "sonner";
import { getSettlementPeriods, type SettlementPeriodRow } from "@/api/admin-agent-settlement";
import { getAdminIntegrationSites } from "@/api/admin-integration-sites";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { AdminPageCard } from "@/components/admin/admin-page-card";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { AgentBillDetail } from "@/modules/settlement/agent-bill-detail";
import { AgentPeriodsConsole } from "@/modules/settlement/agent-periods-console";
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
import type { AgentSettlementPeriodFilter } from "@/modules/settlement/agent-settlement-period-select";
import { SettlementCenterPeriodDetail } from "@/modules/settlement/settlement-center-period-detail";
import {
SettlementCenterNav,
type SettlementCenterSection,
parseSettlementCenterView,
settlementPeriodViewHref,
type SettlementPeriodView,
} from "@/modules/settlement/settlement-center-nav";
import {
SettlementBillsPanel,
type BillCategory,
} from "@/modules/settlement/settlement-bills-panel";
import { SettlementLedgerPanel } from "@/modules/settlement/settlement-ledger-panel";
import { SettlementPeriodToolbar } from "@/modules/settlement/settlement-period-toolbar";
import { formatSettlementPeriodSpan } from "@/lib/agent-settlement-period-range";
import { SettlementPeriodWorkbench } from "@/modules/settlement/settlement-period-workbench";
import { formatAdminSiteLabel } from "@/lib/admin-site-display";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { PRD_SETTLEMENT_AGENT_MANAGE } from "@/lib/admin-prd";
import {
@@ -44,62 +35,33 @@ import { useAdminProfile } from "@/stores/admin-session";
type SiteOption = { id: number; label: string; currency_code: string };
function pickDefaultPeriodId(periods: SettlementPeriodRow[]): number | "all" {
const closed = periods
.filter((row) => row.status === "closed" || row.status === "completed")
.sort((a, b) => b.id - a.id);
if (closed[0]) {
return closed[0].id;
}
const open = periods.filter((row) => row.status === "open").sort((a, b) => b.id - a.id);
if (open[0]) {
return open[0].id;
}
return "all";
}
function sectionTitle(
section: SettlementCenterSection,
t: ReturnType<typeof useTranslation<["settlementCenter", "agents", "common"]>>["t"],
): string {
switch (section) {
case "overview":
return t("panels.overview.title", { defaultValue: "结算概览" });
case "periods":
return t("nav.periods", { defaultValue: "账期管理" });
case "ledger":
return t("panels.ledger.title", { defaultValue: "账务流水" });
case "bills":
return t("panels.bills.title", { defaultValue: "账单" });
default:
return "";
}
}
export function SettlementCenterShell(): React.ReactElement {
const { t } = useTranslation(["settlementCenter", "agents", "common"]);
const { t } = useTranslation(["settlementCenter", "common"]);
const router = useRouter();
const searchParams = useSearchParams();
const profile = useAdminProfile();
const boundAgent = profile?.agent ?? null;
const canManagePeriods =
const { periodId: activePeriodId, view: activeView } = parseSettlementCenterView(
searchParams.get("period"),
searchParams.get("view"),
);
const canOperateBills =
profile?.is_super_admin === true ||
adminHasAnyPermission(profile?.permissions, [PRD_SETTLEMENT_AGENT_MANAGE]);
const canManagePeriods = canOperateBills && boundAgent === null;
const [activeSection, setActiveSection] = useState<SettlementCenterSection>("overview");
const [siteOptions, setSiteOptions] = useState<SiteOption[]>([]);
const [adminSiteId, setAdminSiteId] = useState<number | null>(null);
const [periods, setPeriods] = useState<SettlementPeriodRow[]>([]);
const [periodFilter, setPeriodFilter] = useState<AgentSettlementPeriodFilter>("all");
const [periodFilterReady, setPeriodFilterReady] = useState(false);
const [periodsReady, setPeriodsReady] = useState(false);
const [detailBillId, setDetailBillId] = useState<number | null>(null);
const [billsInitialCategory, setBillsInitialCategory] = useState<BillCategory>("all");
const [listRevision, setListRevision] = useState(0);
const [refreshKey, setRefreshKey] = useState(0);
useEffect(() => {
if (boundAgent?.admin_site_id) {
const label = boundAgent.name
? `${boundAgent.name} (${boundAgent.site_code || boundAgent.code})`
: boundAgent.code;
const label = formatAdminSiteLabel(boundAgent.name, boundAgent.site_code ?? boundAgent.code);
setSiteOptions([{ id: boundAgent.admin_site_id, label, currency_code: "NPR" }]);
setAdminSiteId(boundAgent.admin_site_id);
return;
@@ -108,7 +70,7 @@ export function SettlementCenterShell(): React.ReactElement {
void getAdminIntegrationSites().then((sites) => {
const options = (sites.items ?? []).map((site) => ({
id: site.id,
label: site.name ? `${site.name} (${site.code})` : site.code,
label: formatAdminSiteLabel(site.name, site.code),
currency_code: site.currency_code ?? "NPR",
}));
setSiteOptions(options);
@@ -118,274 +80,67 @@ export function SettlementCenterShell(): React.ReactElement {
});
}, [adminSiteId, boundAgent]);
const loadPeriods = useCallback(async () => {
if (adminSiteId === null) {
setPeriods([]);
return;
const siteId = adminSiteId ?? siteOptions[0]?.id ?? null;
const siteLabel = siteOptions.find((s) => s.id === siteId)?.label ?? null;
const currency = siteOptions.find((s) => s.id === siteId)?.currency_code ?? "NPR";
const loadPeriods = useCallback(async (): Promise<SettlementPeriodRow[]> => {
if (siteId === null) {
return [];
}
try {
const data = await getSettlementPeriods({ admin_site_id: adminSiteId });
setPeriods(data.items ?? []);
const data = await getSettlementPeriods({ admin_site_id: siteId });
const items = data.items ?? [];
setPeriods(items);
setPeriodsReady(true);
return items;
} catch {
setPeriods([]);
toast.error(t("periods.loadFailed", { defaultValue: "账期列表加载失败" }));
setPeriodsReady(true);
toast.error(t("periods.loadFailed", { defaultValue: "账期加载失败" }));
return [];
}
}, [adminSiteId, t]);
}, [siteId, t]);
useEffect(() => {
if (canManagePeriods || adminSiteId === null) {
return;
}
setPeriodsReady(false);
void loadPeriods();
}, [adminSiteId, canManagePeriods, loadPeriods]);
}, [loadPeriods]);
const handlePeriodsChange = useCallback((items: SettlementPeriodRow[]) => {
setPeriods(items);
}, []);
const activePeriod =
activePeriodId !== null ? (periods.find((row) => row.id === activePeriodId) ?? null) : null;
useEffect(() => {
if (periodFilterReady || adminSiteId === null) {
return;
}
setPeriodFilter(periods.length === 0 ? "all" : pickDefaultPeriodId(periods));
setPeriodFilterReady(true);
}, [adminSiteId, periodFilterReady, periods]);
const activeCurrency =
siteOptions.find((site) => site.id === adminSiteId)?.currency_code ?? "NPR";
const openPeriod = useMemo(
() => periods.filter((row) => row.status === "open").sort((a, b) => b.id - a.id)[0] ?? null,
[periods],
);
const summaryTotals = useMemo(
() =>
periods.reduce(
(acc, row) => {
acc.pendingConfirm += row.summary?.pending_confirm ?? 0;
acc.awaitingPayment += row.summary?.awaiting_payment ?? 0;
acc.totalUnpaid += row.summary?.total_unpaid ?? 0;
return acc;
},
{ pendingConfirm: 0, awaitingPayment: 0, totalUnpaid: 0 },
),
[periods],
);
const handlePeriodClosed = useCallback(
(result?: { unsettled_ticket_count?: number }) => {
void loadPeriods();
setActiveSection("bills");
setBillsInitialCategory("pending_confirm");
setListRevision((n) => n + 1);
const unsettled = result?.unsettled_ticket_count ?? 0;
if (unsettled > 0) {
toast.warning(
t("toast.periodClosedUnsettled", {
defaultValue: "账期已关账;仍有 {{count}} 笔注单未结算,请尽快处理。",
count: unsettled,
}),
);
} else {
toast.success(t("toast.periodClosed", { defaultValue: "账期已关账" }));
}
},
[loadPeriods, t],
);
const selectSiteId = adminSiteId ?? siteOptions[0]?.id ?? null;
const selectedSiteLabel = siteOptions.find((site) => site.id === selectSiteId)?.label ?? null;
const panelTitle = sectionTitle(activeSection, t);
const allPeriodsCompleted =
periods.length > 0 && periods.every((row) => row.status === "completed");
const showPeriodToolbar =
(activeSection === "ledger" || activeSection === "bills") && periods.length > 0;
const selectedPeriod =
periodFilter !== "all" ? (periods.find((row) => row.id === periodFilter) ?? null) : openPeriod;
const pipelineCounts = selectedPeriod?.pipeline ?? {
credit_ledger_count: 0,
share_ledger_count: 0,
const openPeriodView = (periodId: number, view: SettlementPeriodView): void => {
router.push(settlementPeriodViewHref(periodId, view));
};
const overviewStats = [
{
label: t("overview.pendingConfirm", { defaultValue: "待确认" }),
value: String(summaryTotals.pendingConfirm),
icon: ClipboardCheck,
},
{
label: t("overview.awaitingPayment", { defaultValue: "待收付" }),
value: String(summaryTotals.awaitingPayment),
icon: CircleDollarSign,
},
{
label: t("overview.totalUnpaid", { defaultValue: "未结合计" }),
value: formatDashboardMoneyMinor(summaryTotals.totalUnpaid, activeCurrency),
icon: Landmark,
},
{
label: t("overview.openPeriod", { defaultValue: "进行中账期" }),
value: openPeriod
? formatSettlementPeriodSpan(openPeriod.period_start, openPeriod.period_end)
: "—",
icon: CalendarClock,
},
{
label: t("overview.creditLedger", { defaultValue: "信用流水(账期内)" }),
value: String(pipelineCounts.credit_ledger_count),
icon: CalendarClock,
},
{
label: t("overview.shareLedger", { defaultValue: "占成流水(账期内)" }),
value: String(pipelineCounts.share_ledger_count),
icon: CalendarClock,
},
];
function renderMainPanel(): React.ReactElement {
if (activeSection === "overview") {
return (
<div className="space-y-5">
<p className="text-sm text-muted-foreground">
{t("overview.pipelineHint", {
defaultValue: "账单须关账后生成;下方为账期内实时流水笔数。",
})}
</p>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-6">
{overviewStats.map((stat) => {
const Icon = stat.icon;
return (
<button
key={stat.label}
type="button"
className="rounded-xl border border-border/70 bg-card px-4 py-4 text-left transition-colors hover:border-primary/30 hover:bg-muted/30"
onClick={() => {
if (stat.label === t("overview.pendingConfirm", { defaultValue: "待确认" })) {
setBillsInitialCategory("pending_confirm");
setActiveSection("bills");
} else if (
stat.label === t("overview.awaitingPayment", { defaultValue: "待收付" })
) {
setBillsInitialCategory("awaiting_payment");
setActiveSection("bills");
} else if (
stat.label === t("overview.creditLedger", { defaultValue: "信用流水(账期内)" })
) {
setActiveSection("ledger");
}
}}
>
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">{stat.label}</p>
<Icon className="size-4 text-muted-foreground" />
</div>
<p className="mt-2 text-base font-semibold tabular-nums">{stat.value}</p>
</button>
);
})}
</div>
</div>
);
}
if (activeSection === "periods" && adminSiteId !== null) {
return (
<AgentPeriodsConsole
adminSiteId={adminSiteId}
canManagePeriods={canManagePeriods}
settlementCycle="weekly"
siteCurrencyCode={activeCurrency}
embedded
onPeriodsChange={handlePeriodsChange}
onPeriodClosed={handlePeriodClosed}
/>
);
}
if (activeSection === "ledger" && adminSiteId !== null && periodFilterReady) {
return (
<SettlementLedgerPanel
adminSiteId={adminSiteId}
periodFilter={periodFilter}
currencyCode={activeCurrency}
canManage={canManagePeriods}
onOpenBill={setDetailBillId}
refreshKey={listRevision}
/>
);
}
if (activeSection === "bills" && adminSiteId !== null && periodFilterReady) {
return (
<SettlementBillsPanel
adminSiteId={adminSiteId}
periodFilter={periodFilter}
currencyCode={activeCurrency}
onOpenDetail={setDetailBillId}
initialCategory={billsInitialCategory}
refreshKey={listRevision}
/>
);
}
return <AdminNoResourceState />;
}
const isListMode = activePeriodId === null;
return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
<header className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-xl font-semibold tracking-tight">
{t("title", { defaultValue: "结算中心" })}
</h1>
<AdminStatusBadge
status={openPeriod ? "processing" : allPeriodsCompleted ? "completed" : "idle"}
>
{openPeriod
? t("header.statusRunning", { defaultValue: "账期进行中" })
: allPeriodsCompleted
? t("header.statusCompleted", { defaultValue: "账期已结清" })
: t("header.statusIdle", { defaultValue: "等待开期" })}
</AdminStatusBadge>
</div>
<p className="text-sm text-muted-foreground">
{t("header.subtitle", { defaultValue: "信用占成账务" })}
<p className="mt-1 text-sm text-muted-foreground">
{isListMode
? t("subtitleList", { defaultValue: "账期列表:开账、关账,从行操作进入账单与报表。" })
: t("subtitle", { defaultValue: "账期关账、账单确认与收付登记" })}
</p>
</div>
{siteOptions.length <= 1 && selectedSiteLabel ? (
<p className="text-sm text-muted-foreground">{selectedSiteLabel}</p>
) : null}
</header>
{adminSiteId === null ? (
<p className="text-sm text-muted-foreground">{t("empty.noSite", { defaultValue: "请选择接入站点。" })}</p>
) : (
<div className="min-w-0 space-y-4">
<SettlementCenterNav
active={activeSection}
onChange={(section) => {
if (section === "bills") {
setBillsInitialCategory("all");
}
setActiveSection(section);
}}
counts={{
pendingConfirm: summaryTotals.pendingConfirm,
awaitingPayment: summaryTotals.awaitingPayment,
}}
siteSelector={
siteOptions.length > 1 && selectSiteId !== null ? (
{siteOptions.length >= 1 && siteId !== null ? (
<Select
value={String(selectSiteId)}
onValueChange={(value) => {
setAdminSiteId(Number(value));
setPeriodFilter("all");
setPeriodFilterReady(false);
value={String(siteId)}
onValueChange={(v) => {
setAdminSiteId(Number(v));
setPeriodsReady(false);
router.push("/admin/settlement-center");
}}
>
<SelectTrigger className="h-9 w-[220px] bg-background">
<SelectValue>{selectedSiteLabel}</SelectValue>
<SelectTrigger className="h-9 w-[220px]">
<SelectValue>{siteLabel}</SelectValue>
</SelectTrigger>
<SelectContent>
{siteOptions.map((site) => (
@@ -395,42 +150,70 @@ export function SettlementCenterShell(): React.ReactElement {
))}
</SelectContent>
</Select>
) : null
}
/>
) : null}
</div>
{showPeriodToolbar && periodFilterReady ? (
<SettlementPeriodToolbar
{siteId === null || !periodsReady ? (
<p className="text-sm text-muted-foreground">{t("empty.noSite", { defaultValue: "请选择站点。" })}</p>
) : isListMode ? (
<SettlementPeriodWorkbench
adminSiteId={siteId}
currencyCode={currency}
canManage={canManagePeriods}
periods={periods}
value={periodFilter}
onChange={(next) => {
setPeriodFilter(next);
setPeriodFilterReady(true);
onViewDetail={(id) => openPeriodView(id, "bills")}
onReloadPeriods={loadPeriods}
onPeriodOpened={() => {
setRefreshKey((n) => n + 1);
}}
onPeriodClosed={(result) => {
setRefreshKey((n) => n + 1);
const n = result?.unsettled_ticket_count ?? 0;
if (n > 0) {
toast.warning(
t("toast.periodClosedUnsettled", {
defaultValue: "已关账,仍有 {{count}} 笔注单未结算。",
count: n,
}),
);
}
}}
/>
) : null}
<AdminPageCard title={panelTitle}>{renderMainPanel()}</AdminPageCard>
</div>
) : activePeriod === null ? (
<p className="text-sm text-muted-foreground">
{t("periodDetail.notFound", { defaultValue: "账期不存在或已切换站点,请返回列表。" })}
</p>
) : (
<SettlementCenterPeriodDetail
period={activePeriod}
view={activeView}
adminSiteId={siteId}
currencyCode={currency}
canOperateBills={canOperateBills}
refreshKey={refreshKey}
onOpenBillDetail={setDetailBillId}
/>
)}
<Dialog open={detailBillId !== null} onOpenChange={(open) => !open && setDetailBillId(null)}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>
{t("actions.billDetail", { defaultValue: "账单详情 · 确认 / 收付" })}
</DialogTitle>
<DialogContent
className="grid !h-[min(92vh,980px)] !w-[calc(100vw-2rem)] !max-w-none sm:!w-[min(1040px,calc(100vw-2rem))] sm:!max-w-[1040px] grid-rows-[auto,minmax(0,1fr)] overflow-hidden p-0"
>
<DialogHeader className="border-b px-6 py-4">
<DialogTitle>{t("actions.billDetail", { defaultValue: "账单详情" })}</DialogTitle>
</DialogHeader>
{detailBillId !== null ? (
<div className="min-h-0 overflow-y-auto px-6 py-5">
<AgentBillDetail
billId={detailBillId}
currencyCode={activeCurrency}
canManage={canManagePeriods}
currencyCode={currency}
canManage={canOperateBills}
onUpdated={() => {
void loadPeriods();
setListRevision((n) => n + 1);
setRefreshKey((n) => n + 1);
}}
/>
</div>
) : null}
</DialogContent>
</Dialog>

View File

@@ -0,0 +1,361 @@
"use client";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
getCreditLedger,
type SettlementCreditLedgerRow,
} from "@/api/admin-agent-settlement";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import { AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { AdminPlayerIdentityCells, AdminPlayerIdentityHeads } from "@/components/admin/admin-player-identity-columns";
import { PlayerLedgerSourceBadge } from "@/components/admin/player-funding-badges";
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 { formatAdminMinorUnits } from "@/lib/money";
import { cn } from "@/lib/utils";
import { creditLedgerReasonLabel, settlementBillStatusLabel } from "@/modules/settlement/settlement-status-label";
const REASON_FILTERS = [
"all",
"bet_hold",
"game_settlement",
"payment_record",
"adjustment",
"reversal",
"bad_debt",
"settlement_payout",
"share_ledger",
] as const;
type ReasonFilter = (typeof REASON_FILTERS)[number];
const COL_SPAN = 11;
function signedLedgerAmount(row: SettlementCreditLedgerRow): number {
if (typeof row.signed_amount === "number") {
return row.signed_amount;
}
return row.direction === 1 ? row.amount : -row.amount;
}
function signedLedgerAmountClass(signed: number): string {
if (signed < 0) {
return "font-medium text-destructive";
}
if (signed > 0) {
return "font-medium text-emerald-700 dark:text-emerald-400";
}
return "text-muted-foreground";
}
function formatSignedLedgerAmount(signed: number, currencyCode: string): string {
if (signed === 0) {
return formatAdminMinorUnits(0, currencyCode);
}
const prefix = signed < 0 ? "" : "+";
return `${prefix}${formatAdminMinorUnits(Math.abs(signed), currencyCode)}`;
}
function reasonLabel(
value: ReasonFilter | string,
t: ReturnType<typeof useTranslation<["settlementCenter", "wallet", "common"]>>["t"],
): string {
if (value === "all") {
return t("filters.statusAll", { defaultValue: "全部" });
}
return creditLedgerReasonLabel(value, t);
}
type SettlementCreditLedgerPanelProps = {
adminSiteId: number;
settlementPeriodId: number;
currencyCode: string;
refreshKey?: number;
};
export function SettlementCreditLedgerPanel({
adminSiteId,
settlementPeriodId,
currencyCode,
refreshKey = 0,
}: SettlementCreditLedgerPanelProps): React.ReactElement {
const { t } = useTranslation(["settlementCenter", "wallet", "common"]);
const formatTs = useAdminDateTimeFormatter();
const [draftAccount, setDraftAccount] = useState("");
const [draftReason, setDraftReason] = useState<ReasonFilter>("all");
const [appliedAccount, setAppliedAccount] = useState("");
const [appliedReason, setAppliedReason] = useState<ReasonFilter>("all");
const [rows, setRows] = useState<SettlementCreditLedgerRow[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(20);
const [loading, setLoading] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await getCreditLedger({
admin_site_id: adminSiteId,
settlement_period_id: settlementPeriodId,
player_account: appliedAccount.trim() || undefined,
reason: appliedReason === "all" ? undefined : appliedReason,
page,
per_page: perPage,
});
setRows(res.items ?? []);
setTotal(res.total ?? 0);
} finally {
setLoading(false);
}
}, [adminSiteId, appliedAccount, appliedReason, page, perPage, settlementPeriodId]);
useEffect(() => {
void load();
}, [load, refreshKey]);
const lastPage = Math.max(1, Math.ceil(total / Math.max(1, perPage)));
const refLabel = (row: SettlementCreditLedgerRow): string => {
const parts: string[] = [];
if (row.biz_no) {
parts.push(row.biz_no);
}
if (row.draw_no) {
parts.push(row.draw_no);
}
if (row.play_code) {
parts.push(row.play_code);
}
if (row.ticket_item_id) {
parts.push(`#${row.ticket_item_id}`);
}
if (row.settlement_bill_id) {
parts.push(`#${row.settlement_bill_id}`);
}
return parts.length > 0 ? parts.join(" · ") : "—";
};
return (
<div className="space-y-4">
<div className="rounded-xl border border-border/70 bg-muted/20 p-4 text-sm text-muted-foreground">
<p className="font-medium text-foreground">
{t("panels.ledger.title", { defaultValue: "账务流水" })}
</p>
<p className="mt-1">
{t("ledger.groupIntro", {
defaultValue:
"账期内资金变动明细:信用占用、账单收付、调账与坏账。关账后生成的占成账单在「账单管理」。",
})}
</p>
</div>
<div className="admin-list-toolbar">
<div className="admin-list-field">
<Label htmlFor="scl-player" className="sm:shrink-0">
{t("creditLedger.columns.player", { defaultValue: "玩家" })}
</Label>
<Input
id="scl-player"
className="h-9"
value={draftAccount}
placeholder={t("ledgerPanel.playerAccountPh", { defaultValue: "用户名 / 站点玩家 ID" })}
onChange={(e) => setDraftAccount(e.target.value)}
/>
</div>
<div className="admin-list-field">
<Label htmlFor="scl-reason" className="sm:shrink-0">
{t("creditLedger.columns.reason", { defaultValue: "业务类型" })}
</Label>
<Select
modal={false}
value={draftReason}
onValueChange={(v) => setDraftReason((v ?? "all") as ReasonFilter)}
>
<SelectTrigger id="scl-reason" className="h-9 w-full sm:w-52">
<SelectValue>{() => reasonLabel(draftReason, t)}</SelectValue>
</SelectTrigger>
<SelectContent>
{REASON_FILTERS.map((value) => (
<SelectItem key={value} value={value}>
{reasonLabel(value, t)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="admin-list-actions">
<Button
type="button"
size="sm"
onClick={() => {
setAppliedAccount(draftAccount);
setAppliedReason(draftReason);
setPage(1);
}}
>
{t("ledgerPanel.searchBtn", { defaultValue: "搜索" })}
</Button>
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => {
setDraftAccount("");
setDraftReason("all");
setAppliedAccount("");
setAppliedReason("all");
setPage(1);
}}
>
{t("ledgerPanel.reset", { defaultValue: "重置筛选" })}
</Button>
<Button type="button" size="sm" variant="secondary" disabled={loading} onClick={() => void load()}>
{t("ledgerPanel.refresh", { defaultValue: "刷新当前页" })}
</Button>
</div>
</div>
{loading && rows.length === 0 ? <AdminLoadingState /> : null}
<div className="admin-table-shell overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="whitespace-nowrap">
{t("creditLedger.columns.txn", { defaultValue: "流水号" })}
</TableHead>
<AdminPlayerIdentityHeads />
<TableHead className="whitespace-nowrap">
{t("columns.directAgent", { defaultValue: "直属代理" })}
</TableHead>
<TableHead className="whitespace-nowrap">
{t("creditLedger.columns.channel", { defaultValue: "渠道" })}
</TableHead>
<TableHead className="whitespace-nowrap">
{t("creditLedger.columns.reason", { defaultValue: "业务类型" })}
</TableHead>
<TableHead className="whitespace-nowrap">
{t("columns.billId", { defaultValue: "账单 ID" })}
</TableHead>
<TableHead className="whitespace-nowrap">
{t("columns.status", { defaultValue: "状态" })}
</TableHead>
<TableHead className="whitespace-nowrap text-right">
{t("creditLedger.columns.amount", { defaultValue: "金额" })}
</TableHead>
<TableHead className="whitespace-nowrap">
{t("creditLedger.columns.ref", { defaultValue: "关联" })}
</TableHead>
<TableHead className="whitespace-nowrap">
{t("creditLedger.columns.time", { defaultValue: "时间" })}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading && rows.length === 0 ? (
<AdminTableLoadingRow colSpan={COL_SPAN} />
) : rows.length === 0 ? (
<AdminTableNoResourceRow
colSpan={COL_SPAN}
message={t("creditLedger.emptyPeriod", {
defaultValue: "本账期暂无账务流水。",
})}
/>
) : (
rows.map((row) => {
const signed = signedLedgerAmount(row);
return (
<TableRow key={row.row_key}>
<TableCell className="font-mono text-xs">{row.txn_no}</TableCell>
<AdminPlayerIdentityCells row={row} />
<TableCell className="text-xs">{row.direct_agent_label ?? "—"}</TableCell>
<TableCell>
<PlayerLedgerSourceBadge ledgerSource={row.ledger_source} />
</TableCell>
<TableCell className="text-xs">{creditLedgerReasonLabel(row.biz_type, t)}</TableCell>
<TableCell className="tabular-nums text-xs">
{row.settlement_bill_id ? `#${row.settlement_bill_id}` : "—"}
</TableCell>
<TableCell className="text-xs">
{row.bill_status ? settlementBillStatusLabel(row.bill_status, t) : row.status}
</TableCell>
<TableCell className="tabular-nums text-right text-xs">
<span className={cn(signedLedgerAmountClass(signed))}>
{row.biz_type === "bet_hold"
? t("creditLedger.reason.freezeAmount", {
defaultValue: "冻结 {{amount}}",
amount: formatAdminMinorUnits(row.amount, currencyCode),
})
: formatSignedLedgerAmount(signed, currencyCode)}
</span>
</TableCell>
<TableCell className="text-xs">
{row.ticket_item_id ? (
<Link
href={`/admin/tickets?ticket_item_id=${row.ticket_item_id}`}
className="text-primary underline-offset-2 hover:underline"
>
{refLabel(row)}
</Link>
) : row.settlement_bill_id ? (
<span>{refLabel(row)}</span>
) : (
"—"
)}
</TableCell>
<TableCell className="whitespace-nowrap font-mono text-[11px] text-muted-foreground">
{formatTs(row.created_at)}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
<AdminListPaginationFooter
selectId="settlement-credit-ledger-per-page"
total={total}
page={page}
lastPage={lastPage}
perPage={perPage}
loading={loading}
onPerPageChange={(next) => {
setPerPage(next);
setPage(1);
}}
onPageChange={setPage}
/>
</div>
);
}

View File

@@ -1,172 +0,0 @@
"use client";
import { useTranslation } from "react-i18next";
import type { SettlementLedgerRow } from "@/api/admin-agent-settlement";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { PlayerLedgerSourceBadge } from "@/components/admin/player-funding-badges";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
import { SettlementLedgerRowActions } from "@/modules/settlement/settlement-ledger-row-actions";
import {
creditLedgerReasonLabel,
settlementAdjustmentTypeLabel,
settlementBillStatusLabel,
} from "@/modules/settlement/settlement-status-label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
type SettlementCreditLedgerTableProps = {
rows: SettlementLedgerRow[];
loading: boolean;
currencyCode: string;
canManage: boolean;
onOpenBill: (billId: number) => void;
onRefresh: () => void;
showStatusColumn?: boolean;
};
function ledgerBizLabel(
row: SettlementLedgerRow,
t: ReturnType<typeof useTranslation<["settlementCenter", "agents"]>>["t"],
): string {
if (row.entry_kind === "payment") {
return t("creditLedger.reason.payment_record", { defaultValue: "账单收付" });
}
if (row.entry_kind === "adjustment") {
return settlementAdjustmentTypeLabel(row.biz_type, t);
}
return creditLedgerReasonLabel(row.biz_type, t);
}
function ledgerSourceForBadge(row: SettlementLedgerRow): string | null {
if (row.entry_kind === "credit") {
return "credit_ledger";
}
if (row.entry_kind === "payment") {
return "wallet_txn";
}
return null;
}
export function SettlementCreditLedgerTable({
rows,
loading,
currencyCode,
canManage,
onOpenBill,
onRefresh,
showStatusColumn = false,
}: SettlementCreditLedgerTableProps): React.ReactElement {
const { t } = useTranslation(["settlementCenter", "agents", "common"]);
const formatDt = useAdminDateTimeFormatter();
if (loading) {
return <AdminLoadingState />;
}
if (rows.length === 0) {
return <AdminNoResourceState />;
}
return (
<div className="admin-table-shell overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("creditLedger.columns.txn", { defaultValue: "流水号" })}</TableHead>
<TableHead>{t("creditLedger.columns.player", { defaultValue: "玩家" })}</TableHead>
<TableHead>{t("creditLedger.columns.reason", { defaultValue: "业务类型" })}</TableHead>
<TableHead>{t("creditLedger.columns.ref", { defaultValue: "关联" })}</TableHead>
<TableHead className="text-right">
{t("creditLedger.columns.amount", { defaultValue: "金额" })}
</TableHead>
<TableHead>{t("creditLedger.columns.channel", { defaultValue: "渠道" })}</TableHead>
{showStatusColumn ? (
<TableHead>{t("creditLedger.columns.status", { defaultValue: "状态" })}</TableHead>
) : null}
<TableHead>{t("creditLedger.columns.time", { defaultValue: "时间" })}</TableHead>
<TableHead className="sticky right-0 z-10 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>
{rows.map((row) => {
const signed = row.signed_amount ?? (row.direction === 1 ? row.amount : -row.amount);
const playerLabel =
row.username?.trim() ||
row.nickname?.trim() ||
row.site_player_id?.trim() ||
`#${row.player_id}`;
const badgeSource = ledgerSourceForBadge(row);
return (
<TableRow key={row.row_key ?? `${row.entry_kind}-${row.id}`}>
<TableCell className="font-mono text-xs">{row.txn_no}</TableCell>
<TableCell>
<span className="font-medium">{playerLabel}</span>
<span className="ml-1 text-xs text-muted-foreground">#{row.player_id}</span>
</TableCell>
<TableCell>{ledgerBizLabel(row, t)}</TableCell>
<TableCell className="text-xs text-muted-foreground">
{row.biz_no ?? (row.settlement_bill_id ? `bill#${row.settlement_bill_id}` : "—")}
</TableCell>
<TableCell
className={`text-right tabular-nums font-medium ${signed < 0 ? "text-destructive" : "text-emerald-700"}`}
>
{signed < 0 ? "" : "+"}
{formatDashboardMoneyMinor(Math.abs(signed), row.currency_code || currencyCode)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{badgeSource ? (
<PlayerLedgerSourceBadge ledgerSource={badgeSource} />
) : (
t("creditLedger.entryKind.adjustment", { defaultValue: "调账流水" })
)}
</TableCell>
{showStatusColumn ? (
<TableCell>
{row.bill_status ? (
<AdminStatusBadge status={row.bill_status}>
{settlementBillStatusLabel(row.bill_status, t)}
</AdminStatusBadge>
) : (
<AdminStatusBadge status="posted">
{t("ledgerPanel.rowPosted", { defaultValue: "已记账" })}
</AdminStatusBadge>
)}
</TableCell>
) : null}
<TableCell className="text-xs text-muted-foreground whitespace-nowrap">
{row.created_at ? formatDt(row.created_at) : "—"}
</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()}
>
<SettlementLedgerRowActions
row={row}
canManage={canManage}
onOpenBill={onOpenBill}
onRefresh={onRefresh}
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
);
}

View File

@@ -1,378 +0,0 @@
"use client";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { getCreditLedger, type SettlementLedgerRow } from "@/api/admin-agent-settlement";
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
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 { useAsyncEffect } from "@/hooks/use-async-effect";
import type { AgentSettlementPeriodFilter } from "@/modules/settlement/agent-settlement-period-select";
import { SettlementCreditLedgerTable } from "@/modules/settlement/settlement-credit-ledger-table";
import {
creditLedgerReasonLabel,
settlementAdjustmentTypeLabel,
settlementBillStatusLabel,
} from "@/modules/settlement/settlement-status-label";
import { cn } from "@/lib/utils";
import { LotteryApiBizError } from "@/types/api/errors";
export type LedgerCategory =
| "all"
| "credit"
| "payment"
| "adjustment"
| "bad_debt"
| "actionable";
type LedgerFilters = {
txnNo: string;
playerAccount: string;
playerId: string;
bizType: string;
billStatus: string;
createdFrom: string;
createdTo: string;
};
const emptyFilters: LedgerFilters = {
txnNo: "",
playerAccount: "",
playerId: "",
bizType: "",
billStatus: "",
createdFrom: "",
createdTo: "",
};
/** 下拉「不限」哨兵;请求时转为空串 */
const FILTER_ALL = "__all__";
function ledgerFilterSelectLabel(
raw: unknown,
t: ReturnType<typeof useTranslation<"settlementCenter">>["t"],
kind: "biz" | "billStatus",
): string {
const v = raw == null ? "" : String(raw);
if (v === "" || v === FILTER_ALL) {
return t("ledgerPanel.filterAll", { defaultValue: "不限" });
}
if (kind === "billStatus") {
return settlementBillStatusLabel(v, t);
}
if (v === "adjustment" || v === "reversal" || v === "bad_debt") {
return settlementAdjustmentTypeLabel(v, t);
}
return creditLedgerReasonLabel(v, t);
}
/** 与流水 biz_type / adjustment_type 一致 */
const CREDIT_BIZ_OPTIONS = [
"bet_hold",
"bet_hold_release",
"game_settlement_loss",
"settlement_confirm",
"payment_record",
"adjustment",
"reversal",
"bad_debt",
] as const;
/** 与 settlement_bills.status 一致 */
const BILL_STATUS_OPTIONS = [
"pending_confirm",
"confirmed",
"partial_paid",
"settled",
"overdue",
"reversed",
] as const;
const CATEGORY_OPTIONS: { value: LedgerCategory; labelKey: string }[] = [
{ value: "all", labelKey: "ledgerPanel.category.all" },
{ value: "credit", labelKey: "ledgerPanel.category.credit" },
{ value: "payment", labelKey: "ledgerPanel.category.payment" },
{ value: "adjustment", labelKey: "ledgerPanel.category.adjustment" },
{ value: "bad_debt", labelKey: "ledgerPanel.category.badDebt" },
{ value: "actionable", labelKey: "ledgerPanel.category.actionable" },
];
function categoryQueryParams(category: LedgerCategory): Record<string, string | boolean | undefined> {
switch (category) {
case "credit":
return { entry_kind: "credit" };
case "payment":
return { entry_kind: "payment" };
case "adjustment":
return { entry_kind: "adjustment" };
case "bad_debt":
return { bad_debt_only: true };
case "actionable":
return { actionable_only: true };
default:
return {};
}
}
type SettlementLedgerPanelProps = {
adminSiteId: number;
periodFilter: AgentSettlementPeriodFilter;
currencyCode: string;
canManage: boolean;
onOpenBill: (billId: number) => void;
refreshKey?: number;
};
export function SettlementLedgerPanel({
adminSiteId,
periodFilter,
currencyCode,
canManage,
onOpenBill,
refreshKey = 0,
}: SettlementLedgerPanelProps): React.ReactElement {
const { t } = useTranslation(["settlementCenter", "common"]);
const [category, setCategory] = useState<LedgerCategory>("all");
const [draft, setDraft] = useState<LedgerFilters>(emptyFilters);
const [applied, setApplied] = useState<LedgerFilters>(emptyFilters);
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(20);
const [rows, setRows] = useState<SettlementLedgerRow[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const periodId = periodFilter === "all" ? undefined : periodFilter;
const load = useCallback(async () => {
setLoading(true);
try {
const player_id =
applied.playerId.trim() === "" ? undefined : Number(applied.playerId);
const data = await getCreditLedger({
admin_site_id: adminSiteId,
settlement_period_id: periodId,
page,
per_page: perPage,
player_id:
player_id !== undefined && !Number.isNaN(player_id) && player_id > 0
? player_id
: undefined,
txn_no: applied.txnNo.trim() || undefined,
player_account: applied.playerAccount.trim() || undefined,
reason: applied.bizType.trim() || undefined,
bill_status: applied.billStatus.trim() || undefined,
created_from: applied.createdFrom.trim() || undefined,
created_to: applied.createdTo.trim() || undefined,
...categoryQueryParams(category),
});
setRows(data.items ?? []);
setTotal(data.total ?? 0);
} catch (err: unknown) {
setRows([]);
setTotal(0);
toast.error(
err instanceof LotteryApiBizError
? err.message
: t("errors.loadCreditLedger", { defaultValue: "账务流水加载失败" }),
);
} finally {
setLoading(false);
}
}, [adminSiteId, applied, category, page, perPage, periodId, t]);
useAsyncEffect(() => {
void load();
}, [load, refreshKey]);
const runSearch = () => {
setApplied({ ...draft });
setPage(1);
};
const resetFilters = () => {
setDraft(emptyFilters);
setApplied(emptyFilters);
setPage(1);
};
return (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">{t("creditLedger.intro")}</p>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<div className="grid gap-1.5">
<Label htmlFor="sl-txn">{t("creditLedger.columns.txn", { defaultValue: "流水号" })}</Label>
<Input
id="sl-txn"
placeholder={t("ledgerPanel.search", { defaultValue: "搜索" })}
value={draft.txnNo}
onChange={(e) => setDraft((d) => ({ ...d, txnNo: e.target.value }))}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="sl-account">{t("ledgerPanel.playerAccount", { defaultValue: "玩家账号" })}</Label>
<Input
id="sl-account"
placeholder={t("ledgerPanel.playerAccountPh", { defaultValue: "用户名 / 站点玩家 ID" })}
value={draft.playerAccount}
onChange={(e) => setDraft((d) => ({ ...d, playerAccount: e.target.value }))}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="sl-player">{t("ledgerPanel.playerId", { defaultValue: "玩家 ID" })}</Label>
<Input
id="sl-player"
inputMode="numeric"
placeholder={t("ledgerPanel.optional", { defaultValue: "可选" })}
value={draft.playerId}
onChange={(e) => setDraft((d) => ({ ...d, playerId: e.target.value }))}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="sl-biz">{t("creditLedger.columns.reason", { defaultValue: "业务类型" })}</Label>
<Select
modal={false}
value={draft.bizType === "" ? FILTER_ALL : draft.bizType}
onValueChange={(v) =>
setDraft((d) => ({
...d,
bizType: v == null || v === FILTER_ALL ? "" : String(v),
}))
}
>
<SelectTrigger id="sl-biz" className="h-9 w-full">
<SelectValue>
{(v) => ledgerFilterSelectLabel(v, t, "biz")}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value={FILTER_ALL}>
{t("ledgerPanel.filterAll", { defaultValue: "不限" })}
</SelectItem>
{CREDIT_BIZ_OPTIONS.map((value) => (
<SelectItem key={value} value={value}>
{ledgerFilterSelectLabel(value, t, "biz")}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label htmlFor="sl-bill-status">{t("ledgerPanel.billStatus", { defaultValue: "账单状态" })}</Label>
<Select
modal={false}
value={draft.billStatus === "" ? FILTER_ALL : draft.billStatus}
onValueChange={(v) =>
setDraft((d) => ({
...d,
billStatus: v == null || v === FILTER_ALL ? "" : String(v),
}))
}
>
<SelectTrigger id="sl-bill-status" className="h-9 w-full">
<SelectValue>
{(v) => ledgerFilterSelectLabel(v, t, "billStatus")}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value={FILTER_ALL}>
{t("ledgerPanel.filterAll", { defaultValue: "不限" })}
</SelectItem>
{BILL_STATUS_OPTIONS.map((value) => (
<SelectItem key={value} value={value}>
{ledgerFilterSelectLabel(value, t, "billStatus")}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="sm:col-span-2 lg:col-span-2">
<AdminDateRangeField
id="sl-created-range"
label={t("ledgerPanel.dateRange", { defaultValue: "时间范围" })}
from={draft.createdFrom}
to={draft.createdTo}
onRangeChange={(r) =>
setDraft((d) => ({ ...d, createdFrom: r.from, createdTo: r.to }))
}
/>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button type="button" size="sm" onClick={() => runSearch()}>
{t("ledgerPanel.searchBtn", { defaultValue: "搜索" })}
</Button>
<Button type="button" size="sm" variant="outline" onClick={() => resetFilters()}>
{t("ledgerPanel.reset", { defaultValue: "重置筛选" })}
</Button>
<Button type="button" size="sm" variant="secondary" onClick={() => void load()}>
{t("ledgerPanel.refresh", { defaultValue: "刷新当前页" })}
</Button>
</div>
<div className="flex flex-wrap gap-1.5 border-b border-border/60 pb-3">
{CATEGORY_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => {
setCategory(opt.value);
setPage(1);
}}
className={cn(
"rounded-full border px-3 py-1 text-xs font-medium transition-colors",
category === opt.value
? "border-primary/40 bg-primary/10 text-foreground"
: "border-transparent bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
{t(opt.labelKey, { defaultValue: opt.value })}
</button>
))}
</div>
{loading && rows.length === 0 ? (
<AdminLoadingState />
) : (
<>
<SettlementCreditLedgerTable
rows={rows}
loading={loading}
currencyCode={currencyCode}
canManage={canManage}
onOpenBill={onOpenBill}
onRefresh={() => void load()}
showStatusColumn
/>
<AdminListPaginationFooter
selectId="settlement-ledger-per-page"
total={total}
page={page}
lastPage={Math.max(1, Math.ceil(total / Math.max(1, perPage)))}
perPage={perPage}
loading={loading}
onPerPageChange={(next) => {
setPerPage(next);
setPage(1);
}}
onPageChange={setPage}
/>
</>
)}
</div>
);
}

View File

@@ -1,127 +0,0 @@
"use client";
import {
CircleDollarSign,
ClipboardCheck,
Eye,
SlidersHorizontal,
TriangleAlert,
Undo2,
User,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { postSettlementBillConfirm } from "@/api/admin-agent-settlement";
import type { SettlementLedgerRow } from "@/api/admin-agent-settlement";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { adminPlayerDetailPath } from "@/lib/admin-player-paths";
import { LotteryApiBizError } from "@/types/api/errors";
type SettlementLedgerRowActionsProps = {
row: SettlementLedgerRow;
canManage: boolean;
onOpenBill: (billId: number) => void;
onRefresh: () => void;
};
export function SettlementLedgerRowActions({
row,
canManage,
onOpenBill,
onRefresh,
}: SettlementLedgerRowActionsProps): React.ReactElement {
const { t } = useTranslation(["settlementCenter", "agents", "common"]);
const { request: requestConfirm, ConfirmDialog, busy } = useConfirmAction();
const billId = row.settlement_bill_id ?? null;
const actions = row.available_actions ?? [];
const show = (code: string): boolean => actions.includes(code);
const billAction = (code: string): boolean =>
canManage && billId !== null && show(code);
return (
<>
<AdminRowActionsMenu
busy={busy}
actions={[
{
key: "view_player",
label: t("creditLedger.actions.viewPlayer", { defaultValue: "玩家详情" }),
icon: User,
href: adminPlayerDetailPath(row.player_id),
hidden: !show("view_player"),
},
{
key: "view_bill",
label: t("creditLedger.actions.viewBill", { defaultValue: "账单详情" }),
icon: Eye,
onClick: () => onOpenBill(billId!),
hidden: !show("view_bill") || billId === null,
},
{
key: "confirm",
label: t("creditLedger.actions.confirm", { defaultValue: "确认账单" }),
icon: ClipboardCheck,
hidden: !billAction("confirm"),
onClick: () =>
requestConfirm({
title: t("agents:settlementBills.confirm", { defaultValue: "确认账单" }),
description: t("creditLedger.actions.confirmDesc", {
defaultValue: "确认后账单进入待收付状态。",
}),
onConfirm: async () => {
try {
await postSettlementBillConfirm(billId!);
toast.success(
t("agents:settlementBills.confirmed", { defaultValue: "已确认" }),
);
onRefresh();
} catch (err: unknown) {
toast.error(
err instanceof LotteryApiBizError
? err.message
: t("common:states.error", { defaultValue: "操作失败" }),
);
}
},
}),
},
{
key: "payment",
label: t("creditLedger.actions.payment", { defaultValue: "登记收付" }),
icon: CircleDollarSign,
hidden: !billAction("payment"),
onClick: () => onOpenBill(billId!),
},
{
key: "adjustment",
label: t("creditLedger.actions.adjustment", { defaultValue: "调账" }),
icon: SlidersHorizontal,
hidden: !billAction("adjustment"),
onClick: () => onOpenBill(billId!),
},
{
key: "reversal",
label: t("creditLedger.actions.reversal", { defaultValue: "冲正" }),
icon: Undo2,
hidden: !billAction("reversal"),
onClick: () => onOpenBill(billId!),
},
{
key: "bad_debt",
label: t("creditLedger.actions.badDebt", { defaultValue: "坏账核销" }),
icon: TriangleAlert,
destructive: true,
hidden: !billAction("bad_debt"),
onClick: () => onOpenBill(billId!),
},
]}
/>
<ConfirmDialog />
</>
);
}

View File

@@ -0,0 +1,342 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
getSettlementBills,
type SettlementBillListScope,
type SettlementBillRow,
} from "@/api/admin-agent-settlement";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
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 { useAsyncEffect } from "@/hooks/use-async-effect";
import type { AgentSettlementPeriodFilter } from "@/modules/settlement/agent-settlement-period-select";
import { SettlementBillsTable } from "@/modules/settlement/settlement-bills-table";
import { settlementBillStatusLabel } from "@/modules/settlement/settlement-status-label";
import { LotteryApiBizError } from "@/types/api/errors";
type BillTypeFilter = "all" | "player" | "agent";
type BillStatusFilter = "all" | SettlementBillListScope;
type BillFilters = {
billId: string;
ownerKeyword: string;
billType: BillTypeFilter;
statusScope: BillStatusFilter;
};
function filtersForPeriod(): BillFilters {
return {
billId: "",
ownerKeyword: "",
billType: "all",
statusScope: "all",
};
}
function apiQueryFromFilters(filters: BillFilters): {
bill_type?: string;
scope?: SettlementBillListScope;
bill_id?: number;
keyword?: string;
} {
const out: {
bill_type?: string;
scope?: SettlementBillListScope;
bill_id?: number;
keyword?: string;
} = {};
if (filters.billType === "player" || filters.billType === "agent") {
out.bill_type = filters.billType;
}
if (filters.statusScope !== "all") {
out.scope = filters.statusScope;
}
const id = Number(filters.billId.trim());
if (filters.billId.trim() !== "" && !Number.isNaN(id) && id > 0) {
out.bill_id = id;
}
const keyword = filters.ownerKeyword.trim();
if (keyword !== "") {
out.keyword = keyword;
}
return out;
}
export type SettlementMainPanelProps = {
adminSiteId: number;
currencyCode: string;
periodFilter: AgentSettlementPeriodFilter;
onOpenBillDetail: (billId: number) => void;
refreshKey?: number;
pendingConfirm: number;
awaitingPayment: number;
selectedPeriodStatus?: string | null;
};
export function SettlementMainPanel({
adminSiteId,
currencyCode,
periodFilter,
onOpenBillDetail,
refreshKey = 0,
pendingConfirm,
awaitingPayment,
selectedPeriodStatus,
}: SettlementMainPanelProps): React.ReactElement {
const { t } = useTranslation("settlementCenter");
const periodId = periodFilter === "all" ? undefined : periodFilter;
const periodOpen = selectedPeriodStatus === "open";
const initialFilters = useMemo(() => filtersForPeriod(), []);
const [draft, setDraft] = useState<BillFilters>(initialFilters);
const [applied, setApplied] = useState<BillFilters>(initialFilters);
const [rows, setRows] = useState<SettlementBillRow[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(20);
const [total, setTotal] = useState(0);
useEffect(() => {
setDraft(initialFilters);
setApplied(initialFilters);
setPage(1);
}, [initialFilters]);
const load = useCallback(async () => {
setLoading(true);
try {
const q = apiQueryFromFilters(applied);
const data = await getSettlementBills({
admin_site_id: adminSiteId,
settlement_period_id: periodId,
bill_type: q.bill_type,
scope: q.scope,
bill_id: q.bill_id,
keyword: q.keyword,
page,
per_page: perPage,
});
setRows(data.items ?? []);
setTotal(data.total ?? data.items?.length ?? 0);
} catch (err: unknown) {
setRows([]);
setTotal(0);
toast.error(
err instanceof LotteryApiBizError
? err.message
: t("errors.loadBills", { defaultValue: "账单加载失败" }),
);
} finally {
setLoading(false);
}
}, [adminSiteId, applied, page, perPage, periodId, t]);
useAsyncEffect(() => {
void load();
}, [load, refreshKey]);
const runSearch = () => {
setPage(1);
setApplied({ ...draft });
};
const resetFilters = () => {
setDraft(initialFilters);
setApplied(initialFilters);
setPage(1);
};
const statusOptionLabel = (value: BillStatusFilter): string => {
if (value === "all") {
return t("billsPanel.filterAll", { defaultValue: "全部状态" });
}
if (value === "pending_confirm") {
const label = t("billsPanel.category.pendingConfirm", { defaultValue: "待确认" });
return pendingConfirm > 0 ? `${label} (${pendingConfirm})` : label;
}
if (value === "awaiting_payment") {
const label = t("billsPanel.category.awaitingPayment", { defaultValue: "待收付" });
return awaitingPayment > 0 ? `${label} (${awaitingPayment})` : label;
}
if (value === "settled") {
return settlementBillStatusLabel("settled", t);
}
return t("billsPanel.filterAdjustment", { defaultValue: "调账 / 冲正" });
};
const emptyBillMessage = useMemo((): string | undefined => {
if (periodOpen) {
return t("empty.billsNeedClose", {
defaultValue: "账单在关账后生成。请返回账期列表,对本期执行「关账」后再查看。",
});
}
if (applied.statusScope !== "all") {
return t("billsPanel.emptyFiltered", {
defaultValue: "当前筛选下暂无账单,请改为「全部状态」或重置筛选。",
});
}
return t("billsPanel.emptyClosed", {
defaultValue:
"本期已关账但暂无账单。常见原因:账期内无信用盘玩家的已结算注单,或占成流水不在本账期时间范围内。",
});
}, [applied.statusScope, periodOpen, t]);
const billTypeLabel = (value: BillTypeFilter): string => {
switch (value) {
case "player":
return t("billsPanel.category.player", { defaultValue: "玩家账单" });
case "agent":
return t("billsPanel.category.agent", { defaultValue: "代理账单" });
default:
return t("billsPanel.filterAllTypes", { defaultValue: "全部类型" });
}
};
return (
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="grid gap-1.5">
<Label htmlFor="sb-bill-id">{t("billsPanel.billId", { defaultValue: "账单 ID" })}</Label>
<Input
id="sb-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();
}
}}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="sb-owner">{t("billsPanel.ownerKeyword", { defaultValue: "本方 / 对方" })}</Label>
<Input
id="sb-owner"
placeholder={t("billsPanel.ownerKeywordPh", { defaultValue: "玩家账号、代理名称" })}
value={draft.ownerKeyword}
onChange={(e) => setDraft((d) => ({ ...d, ownerKeyword: e.target.value }))}
onKeyDown={(e) => {
if (e.key === "Enter") {
runSearch();
}
}}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="sb-status">{t("billsPanel.status", { defaultValue: "账单状态" })}</Label>
<Select
modal={false}
value={draft.statusScope}
onValueChange={(v) =>
setDraft((d) => ({
...d,
statusScope: (v ?? "all") as BillStatusFilter,
}))
}
>
<SelectTrigger id="sb-status" className="h-9 w-full">
<SelectValue>{() => statusOptionLabel(draft.statusScope)}</SelectValue>
</SelectTrigger>
<SelectContent>
{(
[
"all",
"pending_confirm",
"awaiting_payment",
"settled",
"adjustment",
] as BillStatusFilter[]
).map((value) => (
<SelectItem key={value} value={value}>
{statusOptionLabel(value)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label htmlFor="sb-type">{t("billsPanel.billType", { defaultValue: "账单类型" })}</Label>
<Select
modal={false}
value={draft.billType}
onValueChange={(v) =>
setDraft((d) => ({
...d,
billType: (v ?? "all") as BillTypeFilter,
}))
}
>
<SelectTrigger id="sb-type" className="h-9 w-full">
<SelectValue>{() => billTypeLabel(draft.billType)}</SelectValue>
</SelectTrigger>
<SelectContent>
{(["all", "player", "agent"] as BillTypeFilter[]).map((value) => (
<SelectItem key={value} value={value}>
{billTypeLabel(value)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button type="button" size="sm" onClick={() => runSearch()}>
{t("billsPanel.searchBtn", { defaultValue: "搜索" })}
</Button>
<Button type="button" size="sm" variant="outline" onClick={() => resetFilters()}>
{t("billsPanel.reset", { defaultValue: "重置" })}
</Button>
<Button type="button" size="sm" variant="secondary" onClick={() => void load()}>
{t("billsPanel.refresh", { defaultValue: "刷新" })}
</Button>
</div>
{loading && rows.length === 0 ? (
<AdminLoadingState />
) : (
<>
<SettlementBillsTable
rows={rows}
loading={loading}
currencyCode={currencyCode}
billTypeFilter={applied.billType}
emptyMessage={emptyBillMessage}
onOpenDetail={onOpenBillDetail}
/>
<AdminListPaginationFooter
selectId="settlement-bills-per-page"
page={page}
perPage={perPage}
total={total}
lastPage={Math.max(1, Math.ceil(total / Math.max(1, perPage)))}
loading={loading}
onPageChange={setPage}
onPerPageChange={(next) => {
setPerPage(next);
setPage(1);
}}
/>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,35 @@
"use client";
type DashCellProps = {
value?: string | number | null;
mono?: boolean;
className?: string;
};
export function SettlementDashCell({
value,
mono = false,
className,
}: DashCellProps): React.ReactElement {
const text =
value === null || value === undefined || String(value).trim() === ""
? "—"
: String(value);
return (
<span className={[mono ? "font-mono text-xs" : "", className].filter(Boolean).join(" ") || undefined}>
{text}
</span>
);
}
export function formatPlatformPartyLabel(
label: string | null | undefined,
t: (key: string, opts?: { defaultValue?: string }) => string,
): string {
if (label === "platform") {
return t("agents:settlementBills.platform", { defaultValue: "平台" });
}
return label?.trim() || "—";
}

View File

@@ -0,0 +1,508 @@
"use client";
import { Plus } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
postSettlementPeriod,
postSettlementPeriodClose,
type SettlementPeriodCloseResult,
type SettlementPeriodRow,
} from "@/api/admin-agent-settlement";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { AdminPageCard } from "@/components/admin/admin-page-card";
import { SettlementPeriodsTable } from "@/modules/settlement/settlement-periods-table";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
formatSettlementPeriodSpan,
settlementPeriodPresetRange,
type SettlementPeriodPresetKey,
} from "@/lib/agent-settlement-period-range";
import { settlementPeriodStatusLabel } from "@/modules/settlement/settlement-status-label";
import { LotteryApiBizError } from "@/types/api/errors";
const PRESET_KEYS: SettlementPeriodPresetKey[] = ["this_week", "last_week", "this_month"];
type PeriodStatusFilter = "all" | "open" | "closed" | "completed";
const STATUS_FILTER_OPTIONS: PeriodStatusFilter[] = ["all", "open", "closed", "completed"];
type SettlementPeriodWorkbenchProps = {
adminSiteId: number;
currencyCode: string;
canManage: boolean;
periods: SettlementPeriodRow[];
onViewDetail: (periodId: number) => void;
onReloadPeriods: () => Promise<SettlementPeriodRow[]>;
onPeriodOpened?: (periodId: number) => void;
onPeriodClosed?: (result: SettlementPeriodCloseResult) => void;
};
export function SettlementPeriodWorkbench({
adminSiteId,
currencyCode,
canManage,
periods,
onViewDetail,
onReloadPeriods,
onPeriodOpened,
onPeriodClosed,
}: SettlementPeriodWorkbenchProps): React.ReactElement {
const { t } = useTranslation(["settlementCenter", "agents", "common"]);
const [draftStatus, setDraftStatus] = useState<PeriodStatusFilter>("all");
const [appliedStatus, setAppliedStatus] = useState<PeriodStatusFilter>("all");
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(10);
const [openDialogOpen, setOpenDialogOpen] = useState(false);
const [customStart, setCustomStart] = useState("");
const [customEnd, setCustomEnd] = useState("");
const [busy, setBusy] = useState(false);
const [reloading, setReloading] = useState(false);
const [closeDialogOpen, setCloseDialogOpen] = useState(false);
const [closeTarget, setCloseTarget] = useState<SettlementPeriodRow | null>(null);
const openPeriod = useMemo(
() => periods.find((row) => row.status === "open") ?? null,
[periods],
);
const filteredPeriods = useMemo(() => {
let list = [...periods];
if (appliedStatus !== "all") {
list = list.filter((row) => row.status === appliedStatus);
}
return list.sort((a, b) => b.id - a.id);
}, [periods, appliedStatus]);
const lastPage = Math.max(1, Math.ceil(filteredPeriods.length / perPage));
const pagedPeriods = useMemo(() => {
const start = (page - 1) * perPage;
return filteredPeriods.slice(start, start + perPage);
}, [filteredPeriods, page, perPage]);
useEffect(() => {
setPage(1);
}, [appliedStatus, perPage]);
useEffect(() => {
if (page > lastPage) {
setPage(lastPage);
}
}, [page, lastPage]);
const presetLabel = (key: SettlementPeriodPresetKey): string => {
switch (key) {
case "this_week":
return t("agents:settlementPeriods.presetThisWeek", { defaultValue: "本周" });
case "last_week":
return t("agents:settlementPeriods.presetLastWeek", { defaultValue: "上周" });
case "this_month":
return t("agents:settlementPeriods.presetThisMonth", { defaultValue: "本月" });
}
};
const statusFilterLabel = (value: PeriodStatusFilter): string => {
if (value === "all") {
return t("filters.statusAll", { defaultValue: "全部" });
}
return settlementPeriodStatusLabel(value, t);
};
async function openWithRange(periodStart: string, periodEnd: string): Promise<void> {
if (!canManage) {
return;
}
setBusy(true);
try {
const row = await postSettlementPeriod({
admin_site_id: adminSiteId,
period_start: periodStart,
period_end: periodEnd,
});
await onReloadPeriods();
onPeriodOpened?.(row.id);
setOpenDialogOpen(false);
setCustomStart("");
setCustomEnd("");
toast.success(t("agents:settlementPeriods.opened", { defaultValue: "账期已开启" }));
} catch (err: unknown) {
toast.error(
err instanceof LotteryApiBizError
? err.message
: t("agents:settlementPeriods.openFailed", { defaultValue: "开期失败" }),
);
} finally {
setBusy(false);
}
}
async function openWithPreset(key: SettlementPeriodPresetKey): Promise<void> {
const range = settlementPeriodPresetRange(key);
await openWithRange(range.period_start, range.period_end);
}
async function openCustom(): Promise<void> {
if (!customStart.trim() || !customEnd.trim()) {
toast.error(t("agents:settlementPeriods.datesRequired", { defaultValue: "请填写账期起止" }));
return;
}
await openWithRange(customStart, customEnd);
}
function requestClose(row: SettlementPeriodRow): void {
setCloseTarget(row);
setCloseDialogOpen(true);
}
async function confirmClose(): Promise<void> {
if (!closeTarget) {
return;
}
setBusy(true);
try {
const result = await postSettlementPeriodClose(closeTarget.id);
const items = await onReloadPeriods();
setCloseDialogOpen(false);
setCloseTarget(null);
onPeriodClosed?.(result);
const stillThere = items.find((row) => row.id === closeTarget.id);
if (stillThere?.status === "closed") {
toast.success(t("agents:settlementPeriods.closed", { defaultValue: "账期已关账,账单已生成" }));
}
} catch (err: unknown) {
toast.error(
err instanceof LotteryApiBizError
? err.message
: t("agents:settlementPeriods.closeFailed", { defaultValue: "关账失败" }),
);
} finally {
setBusy(false);
}
}
async function handleRefresh(): Promise<void> {
setReloading(true);
try {
await onReloadPeriods();
} finally {
setReloading(false);
}
}
function applyFilters(): void {
setAppliedStatus(draftStatus);
setPage(1);
}
function resetFilters(): void {
setDraftStatus("all");
setAppliedStatus("all");
setPage(1);
}
const shareCount = closeTarget?.pipeline?.share_ledger_count ?? 0;
const unsettledCount = closeTarget?.pipeline?.unsettled_ticket_count ?? 0;
const cardDescription = canManage
? t("subtitle", { defaultValue: "账期关账、账单确认与收付登记" })
: t("periodTable.readOnlyHint", {
defaultValue: "绑定代理账号不可开/关账期,仅可查看与收付。",
});
const openPeriodHiddenByFilter =
openPeriod !== null &&
appliedStatus !== "all" &&
appliedStatus !== "open" &&
!filteredPeriods.some((row) => row.id === openPeriod.id);
const tableEmptyMessage = useMemo(() => {
if (periods.length === 0) {
if (!canManage) {
return t("periodTable.emptyReadOnly", { defaultValue: "暂无账期记录。" });
}
return t("periodTable.emptyOpenHint", {
defaultValue: "暂无账期,请点击工具栏「开账」创建。",
});
}
if (openPeriodHiddenByFilter) {
return t("periodTable.emptyFilteredOpen", {
defaultValue: "当前筛选未包含进行中的账期,请选「全部」或「进行中」。",
});
}
return t("periodTable.emptyFiltered", { defaultValue: "筛选结果为空,请重置筛选。" });
}, [canManage, openPeriodHiddenByFilter, periods.length, t]);
return (
<>
<AdminPageCard
title={t("periodTable.title", { defaultValue: "账期管理" })}
description={cardDescription}
>
<div className="admin-list-toolbar">
<div className="admin-list-field">
<Label htmlFor="sp-status-filter" className="sm:shrink-0">
{t("periodTable.statusFilter", { defaultValue: "状态" })}
</Label>
<Select
modal={false}
value={draftStatus}
onValueChange={(v) => setDraftStatus((v ?? "all") as PeriodStatusFilter)}
>
<SelectTrigger id="sp-status-filter" className="h-9 w-full sm:w-40">
<SelectValue>{() => statusFilterLabel(draftStatus)}</SelectValue>
</SelectTrigger>
<SelectContent>
{STATUS_FILTER_OPTIONS.map((value) => (
<SelectItem key={value} value={value}>
{statusFilterLabel(value)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="admin-list-actions">
{canManage && openPeriod ? (
<Button
type="button"
size="sm"
disabled={busy}
onClick={() => requestClose(openPeriod)}
>
{t("periodTable.close", { defaultValue: "关账" })}
</Button>
) : null}
{canManage && !openPeriod ? (
<Button
type="button"
size="sm"
disabled={busy}
onClick={() => setOpenDialogOpen(true)}
>
<Plus className="size-4" aria-hidden />
{t("period.openBtn", { defaultValue: "开账" })}
</Button>
) : null}
<Button type="button" size="sm" onClick={() => applyFilters()}>
{t("ledgerPanel.searchBtn", { defaultValue: "搜索" })}
</Button>
<Button type="button" size="sm" variant="secondary" onClick={() => resetFilters()}>
{t("ledgerPanel.reset", { defaultValue: "重置筛选" })}
</Button>
<Button
type="button"
size="sm"
variant="secondary"
disabled={reloading}
onClick={() => void handleRefresh()}
>
{t("ledgerPanel.refresh", { defaultValue: "刷新当前页" })}
</Button>
</div>
</div>
{canManage && openPeriod ? (
<div className="flex flex-col gap-2 rounded-md border border-amber-200/80 bg-amber-50/60 px-3 py-2 text-sm text-amber-950 sm:flex-row sm:items-center sm:justify-between dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-100">
<p>
{t("periodTable.hasOpen", {
defaultValue: "已有进行中账期 {{range}},须先关账才能开新期。",
range: formatSettlementPeriodSpan(openPeriod.period_start, openPeriod.period_end),
})}
</p>
<Button
type="button"
size="sm"
variant="secondary"
className="shrink-0"
disabled={busy}
onClick={() => requestClose(openPeriod)}
>
{t("periodTable.closeNow", { defaultValue: "立即关账" })}
</Button>
</div>
) : null}
<SettlementPeriodsTable
periods={pagedPeriods}
loading={reloading}
canManage={canManage}
busy={busy}
currencyCode={currencyCode}
emptyMessage={tableEmptyMessage}
onViewDetail={onViewDetail}
onRequestClose={requestClose}
/>
<AdminListPaginationFooter
selectId="settlement-periods-per-page"
total={filteredPeriods.length}
page={page}
lastPage={lastPage}
perPage={perPage}
loading={reloading}
onPerPageChange={(next) => {
setPerPage(next);
setPage(1);
}}
onPageChange={setPage}
/>
</AdminPageCard>
<Dialog
open={openDialogOpen}
onOpenChange={(open) => {
setOpenDialogOpen(open);
if (!open) {
setCustomStart("");
setCustomEnd("");
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("period.openTitle", { defaultValue: "开账" })}</DialogTitle>
<DialogDescription>
{t("agents:settlementPeriods.openHint", {
defaultValue: "选择快捷账期或自定义起止时间。",
})}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
{PRESET_KEYS.map((key) => (
<Button
key={key}
type="button"
size="sm"
variant="secondary"
disabled={busy}
onClick={() => void openWithPreset(key)}
>
{presetLabel(key)}
</Button>
))}
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid gap-1.5">
<Label htmlFor="sp-dialog-start">
{t("agents:settlementPeriods.start", { defaultValue: "开始" })}
</Label>
<Input
id="sp-dialog-start"
type="datetime-local"
value={customStart}
onChange={(e) => setCustomStart(e.target.value)}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="sp-dialog-end">
{t("agents:settlementPeriods.end", { defaultValue: "结束" })}
</Label>
<Input
id="sp-dialog-end"
type="datetime-local"
value={customEnd}
onChange={(e) => setCustomEnd(e.target.value)}
/>
</div>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
disabled={busy}
onClick={() => setOpenDialogOpen(false)}
>
{t("common:cancel", { defaultValue: "取消" })}
</Button>
<Button type="button" disabled={busy} onClick={() => void openCustom()}>
{t("agents:settlementPeriods.open", { defaultValue: "开期" })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={closeDialogOpen}
onOpenChange={(open) => {
setCloseDialogOpen(open);
if (!open) {
setCloseTarget(null);
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("period.closeDialogTitle", { defaultValue: "确认关账" })}</DialogTitle>
<DialogDescription>
{closeTarget
? t("period.closeDialogDesc", {
defaultValue: "将汇总 {{range}} 内的流水并生成账单。",
range: formatSettlementPeriodSpan(
closeTarget.period_start,
closeTarget.period_end,
),
})
: null}
</DialogDescription>
</DialogHeader>
{closeTarget ? (
<ul className="list-inside list-disc space-y-1 text-sm text-muted-foreground">
<li>
{shareCount > 0
? t("period.closeDialogShare", {
defaultValue: "流水 {{count}} 笔",
count: shareCount,
})
: t("period.closeDialogEmpty", {
defaultValue: "本期暂无占成流水,关账后不会生成账单。",
})}
</li>
{unsettledCount > 0 ? (
<li className="text-amber-800">
{t("period.closeDialogUnsettled", {
defaultValue: "仍有 {{count}} 笔注单未结算",
count: unsettledCount,
})}
</li>
) : null}
<li>
{t("period.closeDialogIrreversible", {
defaultValue: "关账后不可撤销,差错请通过调账或冲正处理。",
})}
</li>
</ul>
) : null}
<DialogFooter>
<Button type="button" variant="outline" disabled={busy} onClick={() => setCloseDialogOpen(false)}>
{t("common:cancel", { defaultValue: "取消" })}
</Button>
<Button type="button" disabled={busy} onClick={() => void confirmClose()}>
{t("period.closeDialogConfirm", { defaultValue: "确认关账" })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,169 @@
"use client";
import { Eye, Lock } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { SettlementPeriodRow } from "@/api/admin-agent-settlement";
import { AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { formatSettlementPeriodSpan } from "@/lib/agent-settlement-period-range";
import { cn } from "@/lib/utils";
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
import { signedSettlementMoneyClass } from "@/modules/settlement/settlement-signed-money";
import { settlementPeriodStatusLabel } from "@/modules/settlement/settlement-status-label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
const COL_SPAN = 9;
type SettlementPeriodsTableProps = {
periods: SettlementPeriodRow[];
loading?: boolean;
canManage: boolean;
busy?: boolean;
currencyCode?: string;
emptyMessage?: string;
onViewDetail: (periodId: number) => void;
onRequestClose: (row: SettlementPeriodRow) => void;
};
export function SettlementPeriodsTable({
periods,
loading = false,
canManage,
busy = false,
currencyCode = "NPR",
emptyMessage,
onViewDetail,
onRequestClose,
}: SettlementPeriodsTableProps): React.ReactElement {
const { t } = useTranslation(["settlementCenter", "agents", "common"]);
const billCount = (row: SettlementPeriodRow): number =>
(row.summary?.player_bills ?? 0)
+ (row.summary?.agent_bills ?? 0)
+ (row.summary?.adjustment_bills ?? 0);
const winLossScope = periods.find((row) => row.pipeline?.win_loss_scope)?.pipeline?.win_loss_scope
?? "platform";
const winLossLabel =
winLossScope === "agent"
? t("periodTable.agentWinLoss", { defaultValue: "代理输赢" })
: t("periodTable.platformWinLoss", { defaultValue: "平台输赢" });
return (
<div className="admin-table-shell overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("periodTable.range", { defaultValue: "账期" })}</TableHead>
<TableHead>{t("columns.status", { defaultValue: "状态" })}</TableHead>
<TableHead className="text-right">
{t("periodTable.shareLedger", { defaultValue: "占成流水" })}
</TableHead>
<TableHead className="text-right">{winLossLabel}</TableHead>
<TableHead className="text-right">
{t("agents:settlementReports.summary.billCount", { defaultValue: "账单数" })}
</TableHead>
<TableHead className="text-right">
{t("periodTable.pending", { defaultValue: "待确认" })}
</TableHead>
<TableHead className="text-right">
{t("periodTable.awaiting", { defaultValue: "待收付" })}
</TableHead>
<TableHead className="text-right">
{t("agents:settlementReports.summary.totalUnpaid", { defaultValue: "未结合计" })}
</TableHead>
<TableHead className="sticky right-0 z-10 w-36 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={COL_SPAN} />
) : periods.length === 0 ? (
<AdminTableNoResourceRow colSpan={COL_SPAN} message={emptyMessage} />
) : (
periods.map((row) => {
const canCloseRow = canManage && row.status === "open";
return (
<TableRow key={row.id}>
<TableCell className="font-medium whitespace-nowrap">
{formatSettlementPeriodSpan(row.period_start, row.period_end)}
</TableCell>
<TableCell>
<AdminStatusBadge status={row.status}>
{settlementPeriodStatusLabel(row.status, t)}
</AdminStatusBadge>
</TableCell>
<TableCell className="text-right tabular-nums">
{row.pipeline?.share_ledger_count ?? "—"}
</TableCell>
<TableCell
className={cn(
"text-right tabular-nums whitespace-nowrap",
row.pipeline?.game_win_loss_total != null
? signedSettlementMoneyClass(row.pipeline.game_win_loss_total, true)
: undefined,
)}
>
{row.pipeline?.game_win_loss_total != null
? formatDashboardMoneyMinor(row.pipeline.game_win_loss_total, currencyCode)
: "—"}
</TableCell>
<TableCell className="text-right tabular-nums">
{billCount(row) > 0 ? billCount(row) : "—"}
</TableCell>
<TableCell className="text-right tabular-nums">
{row.summary?.pending_confirm ?? "—"}
</TableCell>
<TableCell className="text-right tabular-nums">
{row.summary?.awaiting_payment ?? "—"}
</TableCell>
<TableCell className="text-right tabular-nums whitespace-nowrap">
{(row.summary?.total_unpaid ?? 0) > 0
? formatDashboardMoneyMinor(row.summary?.total_unpaid ?? 0, currencyCode)
: "—"}
</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()}
>
<AdminRowActionsMenu
busy={busy}
actions={[
{
key: "detail",
label: t("periodTable.viewDetail", { defaultValue: "查看详情" }),
icon: Eye,
onClick: () => onViewDetail(row.id),
},
{
key: "close",
label: t("periodTable.close", { defaultValue: "关账" }),
icon: Lock,
hidden: !canCloseRow,
onClick: () => onRequestClose(row),
},
]}
/>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
);
}

View File

@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils";
/** 结算金额正负着色:负红、正绿、零灰 */
export function signedSettlementMoneyClass(amount: number, emphasize = false): string {
if (amount < 0) {
return cn("text-destructive", emphasize && "font-medium");
}
if (amount > 0) {
return cn("text-emerald-700 dark:text-emerald-400", emphasize && "font-medium");
}
return "text-muted-foreground";
}

View File

@@ -52,6 +52,13 @@ export function creditLedgerReasonLabel(
reason: string,
t: TFunction<"settlementCenter">,
): string {
if (reason === "game_settlement") {
return t("creditLedger.reason.game_settlement", { defaultValue: "开奖结算" });
}
if (reason === "bet_hold") {
return t("creditLedger.reason.bet_hold", { defaultValue: "下注冻结" });
}
const key = `creditLedger.reason.${reason}` as const;
return t(key, { defaultValue: reason });
}

View File

@@ -7,7 +7,7 @@ import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { PRD_SETTLEMENT_AGENT_ACCESS_ANY } from "@/lib/admin-prd";
import { useAdminProfile } from "@/stores/admin-session";
/** 钱包模块仅服务主站钱包玩家;信用盘流水在结算中心。 */
/** 钱包模块仅服务主站钱包玩家;信用盘结账在结算中心。 */
export function WalletScopeHint(): React.ReactElement {
const { t } = useTranslation("wallet");
const profile = useAdminProfile();
@@ -23,11 +23,11 @@ export function WalletScopeHint(): React.ReactElement {
})}
{canSettlement ? (
<Link href="/admin/settlement-center" className="mx-1 text-primary underline">
{t("scopeHintSettlementLink", { defaultValue: "结算中心 → 信用流水" })}
{t("scopeHintSettlementLink", { defaultValue: "结算中心" })}
</Link>
) : (
<span className="mx-1 font-medium text-foreground">
{t("scopeHintSettlement", { defaultValue: "结算中心 → 信用流水" })}
{t("scopeHintSettlement", { defaultValue: "结算中心" })}
</span>
)}

View File

@@ -1,11 +1,10 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useTranslation } from "react-i18next";
import { AdminSubnav, AdminSubnavLink } from "@/components/admin/admin-subnav";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { cn } from "@/lib/utils";
import { useAdminProfile } from "@/stores/admin-session";
const RECONCILE_PERMS = [
@@ -26,44 +25,23 @@ export function WalletSubnav(): React.ReactElement {
const perms = profile?.permissions;
return (
<nav
aria-label={t("subnavLabel")}
className="flex w-full flex-wrap items-end gap-1 border-b border-border/60 px-1"
>
<AdminSubnav aria-label={t("subnavLabel")}>
{tabs.map((tab) => {
const allowed = adminHasAnyPermission(perms, [...tab.requiredAny]);
const active = pathname === tab.href || pathname.startsWith(`${tab.href}/`);
if (!allowed) {
return (
<span
key={tab.href}
className={cn(
"border-b-2 border-transparent px-4 py-3 text-sm font-medium text-muted-foreground/45",
"cursor-not-allowed",
)}
title={t("noPermission")}
>
{t(tab.label)}
</span>
);
}
return (
<Link
<AdminSubnavLink
key={tab.href}
href={tab.href}
className={cn(
"border-b-2 px-4 py-3 text-sm font-medium transition-colors",
active
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:border-border/80 hover:text-foreground",
)}
active={active}
disabled={!allowed}
disabledTitle={t("noPermission")}
>
{t(tab.label)}
</Link>
</AdminSubnavLink>
);
})}
</nav>
</AdminSubnav>
);
}

View File

@@ -5,16 +5,24 @@ export type AdminPlayerTicketItemRow = {
currency_code: string | null;
play_code: string;
original_number: string | null;
/** @deprecated 历史字段,单位为 minor优先使用 total_bet_amount_minor */
total_bet_amount: number;
total_bet_amount_minor: number;
total_bet_amount_formatted: string;
/** @deprecated 历史字段,单位为 minor优先使用 actual_deduct_amount_minor */
actual_deduct_amount: number;
actual_deduct_amount_minor: number;
actual_deduct_amount_formatted: string;
status: string;
fail_reason_code: string | null;
fail_reason_text: string | null;
/** @deprecated 历史字段,单位为 minor优先使用 win_amount_minor */
win_amount: number;
win_amount_minor: number;
win_amount_formatted: string;
/** @deprecated 历史字段,单位为 minor优先使用 jackpot_win_amount_minor */
jackpot_win_amount: number;
jackpot_win_amount_minor: number;
jackpot_win_amount_formatted: string;
placed_at: string | null;
updated_at: string | null;

View File

@@ -11,11 +11,21 @@ export type AdminSettlementBatchRow = {
paid_at: string | null;
total_ticket_count: number;
total_win_count: number;
/** @deprecated 历史字段,单位为 minor优先使用 total_bet_amount_minor */
total_bet_amount: number;
total_bet_amount_minor: number;
/** @deprecated 历史字段,单位为 minor优先使用 total_actual_deduct_minor */
total_actual_deduct: number;
total_actual_deduct_minor: number;
/** @deprecated 历史字段,单位为 minor优先使用 total_payout_amount_minor */
total_payout_amount: number;
total_payout_amount_minor: number;
/** @deprecated 历史字段,单位为 minor优先使用 total_jackpot_payout_amount_minor */
total_jackpot_payout_amount: number;
total_jackpot_payout_amount_minor: number;
/** @deprecated 历史字段,单位为 minor优先使用 platform_profit_minor */
platform_profit: number;
platform_profit_minor: number;
started_at: string | null;
finished_at: string | null;
created_at: string | null;
@@ -49,11 +59,21 @@ export type AdminSettlementBatchShowData = {
paid_at: string | null;
total_ticket_count: number;
total_win_count: number;
/** @deprecated 历史字段,单位为 minor优先使用 total_bet_amount_minor */
total_bet_amount: number;
total_bet_amount_minor: number;
/** @deprecated 历史字段,单位为 minor优先使用 total_actual_deduct_minor */
total_actual_deduct: number;
total_actual_deduct_minor: number;
/** @deprecated 历史字段,单位为 minor优先使用 total_payout_amount_minor */
total_payout_amount: number;
total_payout_amount_minor: number;
/** @deprecated 历史字段,单位为 minor优先使用 total_jackpot_payout_amount_minor */
total_jackpot_payout_amount: number;
total_jackpot_payout_amount_minor: number;
/** @deprecated 历史字段,单位为 minor优先使用 platform_profit_minor */
platform_profit: number;
platform_profit_minor: number;
started_at: string | null;
finished_at: string | null;
created_at: string | null;

View File

@@ -14,16 +14,24 @@ export type AdminTicketItemRow = {
currency_code: string | null;
play_code: string;
original_number: string | null;
/** @deprecated 历史字段,单位为 minor优先使用 total_bet_amount_minor */
total_bet_amount: number;
total_bet_amount_minor: number;
total_bet_amount_formatted: string;
/** @deprecated 历史字段,单位为 minor优先使用 actual_deduct_amount_minor */
actual_deduct_amount: number;
actual_deduct_amount_minor: number;
actual_deduct_amount_formatted: string;
status: string;
fail_reason_code: string | null;
fail_reason_text: string | null;
/** @deprecated 历史字段,单位为 minor优先使用 win_amount_minor */
win_amount: number;
win_amount_minor: number;
win_amount_formatted: string;
/** @deprecated 历史字段,单位为 minor优先使用 jackpot_win_amount_minor */
jackpot_win_amount: number;
jackpot_win_amount_minor: number;
jackpot_win_amount_formatted: string;
placed_at: string | null;
updated_at: string | null;