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:
@@ -10,11 +10,16 @@ export type SettlementPeriodSummary = {
|
|||||||
awaiting_payment: number;
|
awaiting_payment: number;
|
||||||
settled: number;
|
settled: number;
|
||||||
total_unpaid: number;
|
total_unpaid: number;
|
||||||
|
total_net?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SettlementPeriodPipeline = {
|
export type SettlementPeriodPipeline = {
|
||||||
credit_ledger_count: number;
|
credit_ledger_count: number;
|
||||||
share_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 = {
|
export type SettlementPeriodRow = {
|
||||||
@@ -55,6 +60,12 @@ export type SettlementBillRow = {
|
|||||||
status: string;
|
status: string;
|
||||||
owner_label?: string;
|
owner_label?: string;
|
||||||
counterparty_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_funding_mode?: string | null;
|
||||||
owner_auth_source?: string | null;
|
owner_auth_source?: string | null;
|
||||||
period_start?: string;
|
period_start?: string;
|
||||||
@@ -102,12 +113,71 @@ export type SettlementBillListScope =
|
|||||||
| "settled"
|
| "settled"
|
||||||
| "adjustment";
|
| "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?: {
|
export async function getSettlementBills(params?: {
|
||||||
settlement_period_id?: number;
|
settlement_period_id?: number;
|
||||||
admin_site_id?: number;
|
admin_site_id?: number;
|
||||||
bill_type?: string;
|
bill_type?: string;
|
||||||
scope?: SettlementBillListScope;
|
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 });
|
return adminRequest.get(`${A}/settlement-bills`, { params });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,70 +233,13 @@ export async function getSettlementAdjustments(params?: {
|
|||||||
return adminRequest.get(`${A}/settlement-adjustments`, { 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 = {
|
export type RebateAllocationRow = {
|
||||||
id: number;
|
id: number;
|
||||||
rebate_record_id: number;
|
rebate_record_id: number;
|
||||||
|
settlement_bill_id?: number;
|
||||||
participant_type: string;
|
participant_type: string;
|
||||||
participant_id: number;
|
participant_id: number;
|
||||||
|
participant_label?: string;
|
||||||
actual_share_rate: number;
|
actual_share_rate: number;
|
||||||
allocated_amount: number;
|
allocated_amount: number;
|
||||||
allocation_rule: string;
|
allocation_rule: string;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
export default function AdminConfigRebateRedirectPage() {
|
export default function AdminConfigRebateRedirectPage() {
|
||||||
redirect("/admin/rules/odds#rebate");
|
redirect("/admin/rules/odds");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,9 @@ export function AdminPerPagePicker({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger id={selectId} size="sm" className="w-[6.75rem]">
|
<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>
|
</SelectTrigger>
|
||||||
<SelectContent align="start" sideOffset={6}>
|
<SelectContent align="start" sideOffset={6}>
|
||||||
{ADMIN_LIST_PER_PAGE_OPTIONS.map((n) => (
|
{ADMIN_LIST_PER_PAGE_OPTIONS.map((n) => (
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export function AdminNoResourceState({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full flex-col items-center justify-center text-center",
|
"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,
|
className,
|
||||||
)}
|
)}
|
||||||
role="status"
|
role="status"
|
||||||
@@ -41,14 +41,14 @@ export function AdminNoResourceState({
|
|||||||
width={compact ? 120 : 160}
|
width={compact ? 120 : 160}
|
||||||
height={compact ? 120 : 160}
|
height={compact ? 120 : 160}
|
||||||
className={cn(
|
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]",
|
compact ? "max-h-24 max-w-[120px]" : "max-h-40 max-w-[160px]",
|
||||||
imageClassName,
|
imageClassName,
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-muted-foreground",
|
"text-muted-foreground self-center",
|
||||||
compact ? "text-[11px] leading-snug" : "text-sm",
|
compact ? "text-[11px] leading-snug" : "text-sm",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
SidebarMenuSub,
|
SidebarMenuSub,
|
||||||
SidebarMenuSubButton,
|
SidebarMenuSubButton,
|
||||||
SidebarMenuSubItem,
|
SidebarMenuSubItem,
|
||||||
|
useSidebar,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import {
|
import {
|
||||||
ADMIN_NAV_GROUP_ICON,
|
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";
|
"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 NAV_ACTIVE = "data-active:bg-red-600 data-active:text-white data-active:font-medium data-active:shadow-sm";
|
||||||
const SUB_NAV =
|
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(
|
function isActive(
|
||||||
pathname: string,
|
pathname: string,
|
||||||
@@ -101,6 +102,7 @@ function NavSubLeaf({
|
|||||||
pathname: string;
|
pathname: string;
|
||||||
t: TFunction;
|
t: TFunction;
|
||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
|
const Icon = resolveAdminNavIcon(item.segment);
|
||||||
const active = isActive(pathname, item);
|
const active = isActive(pathname, item);
|
||||||
const label = adminNavLabel(item.segment, t, item.label);
|
const label = adminNavLabel(item.segment, t, item.label);
|
||||||
|
|
||||||
@@ -112,6 +114,7 @@ function NavSubLeaf({
|
|||||||
render={<Link href={item.href} />}
|
render={<Link href={item.href} />}
|
||||||
className={cn(SUB_NAV, NAV_ACTIVE)}
|
className={cn(SUB_NAV, NAV_ACTIVE)}
|
||||||
>
|
>
|
||||||
|
<Icon aria-hidden />
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
</SidebarMenuSubButton>
|
</SidebarMenuSubButton>
|
||||||
</SidebarMenuSubItem>
|
</SidebarMenuSubItem>
|
||||||
@@ -175,7 +178,9 @@ export function AdminSidebarNav({
|
|||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
const { t } = useTranslation("common");
|
const { t } = useTranslation("common");
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const { state } = useSidebar();
|
||||||
const navGroups = useMemo(() => groupAdminNavItems(items), [items]);
|
const navGroups = useMemo(() => groupAdminNavItems(items), [items]);
|
||||||
|
const flatItems = useMemo(() => navGroups.flatMap((g) => g.items), [navGroups]);
|
||||||
|
|
||||||
const [openGroups, setOpenGroups] = useState<Record<AdminNavGroup, boolean>>(() =>
|
const [openGroups, setOpenGroups] = useState<Record<AdminNavGroup, boolean>>(() =>
|
||||||
defaultOpenGroups(navGroups, pathname),
|
defaultOpenGroups(navGroups, pathname),
|
||||||
@@ -195,6 +200,16 @@ export function AdminSidebarNav({
|
|||||||
});
|
});
|
||||||
}, [pathname, navGroups]);
|
}, [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 overview = navGroups.find((g) => g.group === "overview");
|
||||||
const collapsible = navGroups.filter((g) => g.group !== "overview");
|
const collapsible = navGroups.filter((g) => g.group !== "overview");
|
||||||
|
|
||||||
|
|||||||
128
src/components/admin/admin-subnav.tsx
Normal file
128
src/components/admin/admin-subnav.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ function Tabs({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tabsListVariants = cva(
|
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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
@@ -59,9 +59,10 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
|||||||
data-slot="tabs-trigger"
|
data-slot="tabs-trigger"
|
||||||
className={cn(
|
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",
|
"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",
|
"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",
|
"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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"rulesPlaysTitle": "Play rules",
|
"rulesPlaysTitle": "Play rules",
|
||||||
"rulesOddsTitle": "Odds & rebate",
|
"rulesOddsTitle": "Odds & rebate",
|
||||||
"rulesOddsDescription": "Odds matrix and rebate rates on one page, sharing the same odds version line.",
|
"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"
|
"riskCapTitle": "Risk cap rules"
|
||||||
},
|
},
|
||||||
"hub": {
|
"hub": {
|
||||||
@@ -411,6 +412,16 @@
|
|||||||
"oddsConfig": "Odds"
|
"oddsConfig": "Odds"
|
||||||
},
|
},
|
||||||
"currentSelection": "Selection: {{category}} / {{play}}",
|
"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": {
|
"playGroups": {
|
||||||
"bigSmall": "Big / small",
|
"bigSmall": "Big / small",
|
||||||
"combo4": "4D position",
|
"combo4": "4D position",
|
||||||
@@ -420,10 +431,13 @@
|
|||||||
},
|
},
|
||||||
"summary": {
|
"summary": {
|
||||||
"title": "Summary",
|
"title": "Summary",
|
||||||
"version": "Version",
|
"contextTitle": "Version & tips",
|
||||||
|
"version": "Editing version",
|
||||||
|
"activeVersion": "Active version",
|
||||||
"statusLabel": "Status",
|
"statusLabel": "Status",
|
||||||
"readOnlyTag": "Read-only",
|
"readOnlyTag": "Read-only",
|
||||||
"readOnlyHint": "This version is read-only. Create a draft to make changes.",
|
"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."
|
"activeHint": "This version is active; new tickets use these settings."
|
||||||
},
|
},
|
||||||
"tabs": {
|
"tabs": {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
"playerAllPlayersHint": "If no player is selected, the reconcile job will cover all players in the chosen date range.",
|
"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}}.",
|
"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}}.",
|
"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",
|
"jobsTitle": "Reconcile jobs",
|
||||||
"jobsDesc": "Use the action on the right to open paginated item details.",
|
"jobsDesc": "Use the action on the right to open paginated item details.",
|
||||||
"refresh": "Refresh",
|
"refresh": "Refresh",
|
||||||
|
|||||||
@@ -1,5 +1,50 @@
|
|||||||
{
|
{
|
||||||
"title": "Settlement center",
|
"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": {
|
"header": {
|
||||||
"subtitle": "Credit-line settlement",
|
"subtitle": "Credit-line settlement",
|
||||||
"statusRunning": "Period open",
|
"statusRunning": "Period open",
|
||||||
@@ -9,18 +54,16 @@
|
|||||||
"subnav": {
|
"subnav": {
|
||||||
"label": "Settlement center navigation"
|
"label": "Settlement center navigation"
|
||||||
},
|
},
|
||||||
"nav": {
|
"workbench": {
|
||||||
"aria": "Settlement center navigation",
|
"viewPeriod": "Period",
|
||||||
"group": {
|
"closePreset": "Close · {{label}}",
|
||||||
"hub": "Workbench",
|
"closeNoData": "Close failed: no share ledger in period. Run credit game settlement first.",
|
||||||
"finance": "Finance",
|
"openPeriodPipeline": "Open {{range}} · {{share}} share entries"
|
||||||
"ledger": "Ledger",
|
|
||||||
"bills": "Bills"
|
|
||||||
},
|
},
|
||||||
"overview": "Overview",
|
"nav": {
|
||||||
"periods": "Periods",
|
"periods": "Periods",
|
||||||
"ledger": "Account ledger",
|
|
||||||
"bills": "Bills",
|
"bills": "Bills",
|
||||||
|
"ledger": "Account ledger",
|
||||||
"creditLedger": "Credit ledger",
|
"creditLedger": "Credit ledger",
|
||||||
"playerBills": "Player bills",
|
"playerBills": "Player bills",
|
||||||
"agentBills": "Agent bills",
|
"agentBills": "Agent bills",
|
||||||
@@ -33,6 +76,7 @@
|
|||||||
},
|
},
|
||||||
"filters": {
|
"filters": {
|
||||||
"period": "Period",
|
"period": "Period",
|
||||||
|
"statusAll": "All",
|
||||||
"allPeriods": "All periods",
|
"allPeriods": "All periods",
|
||||||
"statusOpen": "Open",
|
"statusOpen": "Open",
|
||||||
"statusClosed": "Closed",
|
"statusClosed": "Closed",
|
||||||
@@ -54,7 +98,9 @@
|
|||||||
"badDebtIntro": "Bad debt write-off entries linked to original bills."
|
"badDebtIntro": "Bad debt write-off entries linked to original bills."
|
||||||
},
|
},
|
||||||
"creditLedger": {
|
"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": {
|
"columns": {
|
||||||
"txn": "Txn ID",
|
"txn": "Txn ID",
|
||||||
"player": "Player",
|
"player": "Player",
|
||||||
@@ -83,9 +129,16 @@
|
|||||||
"reason": {
|
"reason": {
|
||||||
"payment_record": "Bill payment",
|
"payment_record": "Bill payment",
|
||||||
"bet_hold": "Bet hold",
|
"bet_hold": "Bet hold",
|
||||||
|
"game_settlement": "Draw settlement",
|
||||||
|
"game_settlement_win": "Draw settlement credit",
|
||||||
"bet_hold_release": "Hold release",
|
"bet_hold_release": "Hold release",
|
||||||
"game_settlement_loss": "Draw settlement",
|
"game_settlement_loss": "Draw settlement debit",
|
||||||
"settlement_confirm": "Period confirm"
|
"settlement_payout": "Settlement payout",
|
||||||
|
"settlement_confirm": "Period confirm",
|
||||||
|
"adjustment": "Adjustment",
|
||||||
|
"reversal": "Reversal",
|
||||||
|
"bad_debt": "Bad debt",
|
||||||
|
"share_ledger": "Share ledger"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"columns": {
|
"columns": {
|
||||||
@@ -132,6 +185,51 @@
|
|||||||
"viewBill": "View bill",
|
"viewBill": "View bill",
|
||||||
"billDetail": "Bill detail"
|
"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": {
|
"ledgerPanel": {
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
"searchBtn": "Search",
|
"searchBtn": "Search",
|
||||||
@@ -144,18 +242,34 @@
|
|||||||
"optional": "Optional",
|
"optional": "Optional",
|
||||||
"billStatus": "Bill status",
|
"billStatus": "Bill status",
|
||||||
"dateRange": "Date range",
|
"dateRange": "Date range",
|
||||||
"rowPosted": "Posted",
|
"rowPosted": "Posted"
|
||||||
"category": {
|
|
||||||
"all": "All",
|
|
||||||
"credit": "Credit holds",
|
|
||||||
"payment": "Payments",
|
|
||||||
"adjustment": "Adjust / reverse",
|
|
||||||
"badDebt": "Bad debt",
|
|
||||||
"actionable": "Needs action"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"billsPanel": {
|
"billsPanel": {
|
||||||
"intro": "Share bills after period close. Filter by type or status; open detail to confirm or record payment.",
|
"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": {
|
"category": {
|
||||||
"all": "All",
|
"all": "All",
|
||||||
"player": "Player bills",
|
"player": "Player bills",
|
||||||
@@ -179,12 +293,12 @@
|
|||||||
"badDebt": { "title": "Bad debt" }
|
"badDebt": { "title": "Bad debt" }
|
||||||
},
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"noSite": "Select an integration site.",
|
"noSite": "Select a site.",
|
||||||
"noPeriods": "Open and close a period under Periods first.",
|
"noPeriods": "Close the current period first.",
|
||||||
"noClosed": "Close a period to generate bills.",
|
"noClosed": "Close a period to generate bills.",
|
||||||
"noBadDebt": "No bad debt write-offs yet.",
|
"noBadDebt": "No bad debt records.",
|
||||||
"noCreditLedger": "No credit ledger rows in this period. Check credit players placed bets and the period date range.",
|
"noCreditLedger": "No ledger entries in this period.",
|
||||||
"billsNeedClose": "Share bills appear after period close. If credit ledger has rows but bills are empty, settle draws then close the period."
|
"billsNeedClose": "Bills are created after period close."
|
||||||
},
|
},
|
||||||
"periods": {
|
"periods": {
|
||||||
"loadFailed": "Failed to load periods"
|
"loadFailed": "Failed to load periods"
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
"subnavLabel": "Wallet sub pages",
|
"subnavLabel": "Wallet sub pages",
|
||||||
"subnavTransactions": "Main-site wallet txns",
|
"subnavTransactions": "Main-site wallet txns",
|
||||||
"subnavTransferOrders": "Main-site transfers",
|
"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",
|
"scopeHint": "This area is for main-site wallet mode (wallet txns and transfers). For credit-line period settlement, see",
|
||||||
"scopeHintSettlementLink": "Settlement center → Credit ledger",
|
"scopeHintSettlementLink": "Settlement center",
|
||||||
"scopeHintSettlement": "Settlement center → Credit ledger",
|
"scopeHintSettlement": "Settlement center",
|
||||||
"ledgerChannel": "Ledger",
|
"ledgerChannel": "Ledger",
|
||||||
"ledgerCredit": "Credit ledger",
|
"ledgerCredit": "Credit ledger",
|
||||||
"ledgerWallet": "Wallet txn",
|
"ledgerWallet": "Wallet txn",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"rulesPlaysTitle": "खेल नियम",
|
"rulesPlaysTitle": "खेल नियम",
|
||||||
"rulesOddsTitle": "बाधा र रिबेट",
|
"rulesOddsTitle": "बाधा र रिबेट",
|
||||||
"rulesOddsDescription": "बाधा म्याट्रिक्स र रिबेट दर एउटै पृष्ठमा, एउटै बाधा संस्करण लाइनमा।",
|
"rulesOddsDescription": "बाधा म्याट्रिक्स र रिबेट दर एउटै पृष्ठमा, एउटै बाधा संस्करण लाइनमा।",
|
||||||
|
"rulesOddsDescriptionShort": "बायाँबाट खेल छान्नुहोस्, दायाँबाट बाधा र रिबेट सम्पादन गर्नुहोस्, त्यसपछि सेभ र प्रकाशन गर्नुहोस्।",
|
||||||
"riskCapTitle": "जोखिम क्याप संस्करण"
|
"riskCapTitle": "जोखिम क्याप संस्करण"
|
||||||
},
|
},
|
||||||
"hub": {
|
"hub": {
|
||||||
@@ -394,6 +395,16 @@
|
|||||||
"oddsConfig": "बाधा सेटिङ"
|
"oddsConfig": "बाधा सेटिङ"
|
||||||
},
|
},
|
||||||
"currentSelection": "हालको छनोट: {{category}} / {{play}}",
|
"currentSelection": "हालको छनोट: {{category}} / {{play}}",
|
||||||
|
"playSelectPlaceholder": "खेल प्रकार छान्नुहोस्",
|
||||||
|
"readOnlyBanner": "यो संस्करण पढ्न मात्र हो। बाधा र रिबेट सम्पादन गर्न ड्राफ्ट बनाउनुहोस्।",
|
||||||
|
"table": {
|
||||||
|
"prizeScope": "पुरस्कार दायरा",
|
||||||
|
"multiplier": "बाधा गुणक"
|
||||||
|
},
|
||||||
|
"draftBar": {
|
||||||
|
"unsaved": "नसेभ परिवर्तनहरू",
|
||||||
|
"saved": "परिवर्तन स्थानीय ड्राफ्टमा राखिएको छ"
|
||||||
|
},
|
||||||
"playGroups": {
|
"playGroups": {
|
||||||
"bigSmall": "ठूलो / सानो",
|
"bigSmall": "ठूलो / सानो",
|
||||||
"combo4": "4D स्थिति",
|
"combo4": "4D स्थिति",
|
||||||
@@ -403,10 +414,13 @@
|
|||||||
},
|
},
|
||||||
"summary": {
|
"summary": {
|
||||||
"title": "सारांश",
|
"title": "सारांश",
|
||||||
"version": "संस्करण",
|
"contextTitle": "संस्करण र सुझाव",
|
||||||
|
"version": "सम्पादन संस्करण",
|
||||||
|
"activeVersion": "सक्रिय संस्करण",
|
||||||
"statusLabel": "स्थिति",
|
"statusLabel": "स्थिति",
|
||||||
"readOnlyTag": "पढ्न मात्र",
|
"readOnlyTag": "पढ्न मात्र",
|
||||||
"readOnlyHint": "यो संस्करण पढ्न मात्र हो। परिवर्तन गर्न ड्राफ्ट बनाउनुहोस्।",
|
"readOnlyHint": "यो संस्करण पढ्न मात्र हो। परिवर्तन गर्न ड्राफ्ट बनाउनुहोस्।",
|
||||||
|
"draftHint": "प्रकाशन अघि ड्राफ्ट सेभ गर्नुहोस्; प्रकाशनले नयाँ टिकटमा मात्र असर गर्छ।",
|
||||||
"activeHint": "यो संस्करण सक्रिय छ; नयाँ टिकट यही सेटिङ प्रयोग गर्छ।"
|
"activeHint": "यो संस्करण सक्रिय छ; नयाँ टिकट यही सेटिङ प्रयोग गर्छ।"
|
||||||
},
|
},
|
||||||
"tabs": {
|
"tabs": {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"playerAllPlayersHint": "खेलाडी नछानेमा, छनोट गरिएको मिति दायराभित्र सबै खेलाडीका लागि मिलान चलाइनेछ।",
|
"playerAllPlayersHint": "खेलाडी नछानेमा, छनोट गरिएको मिति दायराभित्र सबै खेलाडीका लागि मिलान चलाइनेछ।",
|
||||||
"createSummaryAll": "{{from}} देखि {{to}} सम्म सबै खेलाडीका लागि म्यानुअल मिलान चलाइनेछ।",
|
"createSummaryAll": "{{from}} देखि {{to}} सम्म सबै खेलाडीका लागि म्यानुअल मिलान चलाइनेछ।",
|
||||||
"createSummaryPlayer": "खेलाडी {{player}} का लागि {{from}} देखि {{to}} सम्म म्यानुअल मिलान चलाइनेछ।",
|
"createSummaryPlayer": "खेलाडी {{player}} का लागि {{from}} देखि {{to}} सम्म म्यानुअल मिलान चलाइनेछ।",
|
||||||
|
"createSummaryPending": "कार्य सिर्जना गर्नु अघि पूरा मिलान मिति दायरा छान्नुहोस्।",
|
||||||
"jobsTitle": "मिलान कार्यहरू",
|
"jobsTitle": "मिलान कार्यहरू",
|
||||||
"jobsDesc": "दायाँपट्टिको कार्यबाट विवरण खोल्नुहोस्।",
|
"jobsDesc": "दायाँपट्टिको कार्यबाट विवरण खोल्नुहोस्।",
|
||||||
"refresh": "रिफ्रेस",
|
"refresh": "रिफ्रेस",
|
||||||
|
|||||||
@@ -164,7 +164,7 @@
|
|||||||
"periodPlaceholder": "选择账期",
|
"periodPlaceholder": "选择账期",
|
||||||
"allPeriods": "全部账期",
|
"allPeriods": "全部账期",
|
||||||
"filteredByPeriodRange": "账期 {{range}} 的账单",
|
"filteredByPeriodRange": "账期 {{range}} 的账单",
|
||||||
"emptyNoPeriodsManage": "尚无账期与账单。请在下方「账期管理」点「本周」开期,到期后关账,账单会自动出现在这里。",
|
"emptyNoPeriodsManage": "尚无账单。请在结算中心完成「关账出账」后,账单会出现在此处。",
|
||||||
"emptyNoPeriodsAgent": "尚无账单。账期由上级或平台关账后自动生成,无需您手动筛选时间。",
|
"emptyNoPeriodsAgent": "尚无账单。账期由上级或平台关账后自动生成,无需您手动筛选时间。",
|
||||||
"emptyNoClosed": "当前没有已关账的账期,账单尚未生成。请等待负责人关账后再查看。",
|
"emptyNoClosed": "当前没有已关账的账期,账单尚未生成。请等待负责人关账后再查看。",
|
||||||
"typePlayer": "玩家账单",
|
"typePlayer": "玩家账单",
|
||||||
@@ -273,7 +273,7 @@
|
|||||||
"range": "账期",
|
"range": "账期",
|
||||||
"statusOpen": "进行中",
|
"statusOpen": "进行中",
|
||||||
"statusClosed": "已关账",
|
"statusClosed": "已关账",
|
||||||
"empty": "尚无账期。点「本周」等快捷账期后开期,到期后在此关账。",
|
"empty": "尚无账期,请选择快捷账期开期。",
|
||||||
"start": "开始",
|
"start": "开始",
|
||||||
"end": "结束",
|
"end": "结束",
|
||||||
"status": "状态",
|
"status": "状态",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"rulesPlaysTitle": "投注规则",
|
"rulesPlaysTitle": "投注规则",
|
||||||
"rulesOddsTitle": "赔率与回水",
|
"rulesOddsTitle": "赔率与回水",
|
||||||
"rulesOddsDescription": "赔率矩阵与回水比例在同一页维护,共用赔率版本线。",
|
"rulesOddsDescription": "赔率矩阵与回水比例在同一页维护,共用赔率版本线。",
|
||||||
|
"rulesOddsDescriptionShort": "左侧选玩法,右侧改赔率与回水;修改后记得保存草稿并发布。",
|
||||||
"riskCapTitle": "限额版本"
|
"riskCapTitle": "限额版本"
|
||||||
},
|
},
|
||||||
"hub": {
|
"hub": {
|
||||||
@@ -420,6 +421,16 @@
|
|||||||
"oddsConfig": "赔率配置"
|
"oddsConfig": "赔率配置"
|
||||||
},
|
},
|
||||||
"currentSelection": "当前选择:{{category}} / {{play}}",
|
"currentSelection": "当前选择:{{category}} / {{play}}",
|
||||||
|
"playSelectPlaceholder": "选择玩法",
|
||||||
|
"readOnlyBanner": "当前版本只读,需先创建草稿才能修改赔率与回水。",
|
||||||
|
"table": {
|
||||||
|
"prizeScope": "奖级范围",
|
||||||
|
"multiplier": "赔率倍数"
|
||||||
|
},
|
||||||
|
"draftBar": {
|
||||||
|
"unsaved": "有未保存的修改",
|
||||||
|
"saved": "修改已同步到本地草稿"
|
||||||
|
},
|
||||||
"playGroups": {
|
"playGroups": {
|
||||||
"bigSmall": "大小类",
|
"bigSmall": "大小类",
|
||||||
"combo4": "组合类",
|
"combo4": "组合类",
|
||||||
@@ -429,10 +440,13 @@
|
|||||||
},
|
},
|
||||||
"summary": {
|
"summary": {
|
||||||
"title": "配置摘要",
|
"title": "配置摘要",
|
||||||
"version": "版本",
|
"contextTitle": "版本与提示",
|
||||||
|
"version": "当前编辑版本",
|
||||||
|
"activeVersion": "线上生效版本",
|
||||||
"statusLabel": "状态",
|
"statusLabel": "状态",
|
||||||
"readOnlyTag": "只读",
|
"readOnlyTag": "只读",
|
||||||
"readOnlyHint": "当前为只读版本,如需修改请先创建草稿。",
|
"readOnlyHint": "当前为只读版本,如需修改请先创建草稿。",
|
||||||
|
"draftHint": "草稿中的修改需保存后才会写入版本;发布后对后续新注单生效。",
|
||||||
"activeHint": "当前版本已生效,新注单将按此配置计算。"
|
"activeHint": "当前版本已生效,新注单将按此配置计算。"
|
||||||
},
|
},
|
||||||
"tabs": {
|
"tabs": {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
"playerAllPlayersHint": "不选择玩家时,会按日期范围对全量玩家做一次人工对账。",
|
"playerAllPlayersHint": "不选择玩家时,会按日期范围对全量玩家做一次人工对账。",
|
||||||
"createSummaryAll": "将对 {{from}} 至 {{to}} 的全量玩家发起人工对账。",
|
"createSummaryAll": "将对 {{from}} 至 {{to}} 的全量玩家发起人工对账。",
|
||||||
"createSummaryPlayer": "将对玩家 {{player}} 在 {{from}} 至 {{to}} 的数据发起人工对账。",
|
"createSummaryPlayer": "将对玩家 {{player}} 在 {{from}} 至 {{to}} 的数据发起人工对账。",
|
||||||
|
"createSummaryPending": "请选择完整的对账日期范围后,再创建任务。",
|
||||||
"jobsTitle": "对账任务",
|
"jobsTitle": "对账任务",
|
||||||
"jobsDesc": "在右侧操作中查看差异明细与分页。",
|
"jobsDesc": "在右侧操作中查看差异明细与分页。",
|
||||||
"refresh": "刷新",
|
"refresh": "刷新",
|
||||||
|
|||||||
@@ -1,26 +1,55 @@
|
|||||||
{
|
{
|
||||||
"title": "结算中心",
|
"title": "结算中心",
|
||||||
"header": {
|
"subtitle": "账期关账、账单确认与收付登记",
|
||||||
"subtitle": "信用占成账务",
|
"subtitleList": "账期列表:开账、关账;列表已含账期汇总,行内「查看详情」进入账单与下注流水。",
|
||||||
"statusRunning": "账期进行中",
|
"period": {
|
||||||
"statusIdle": "等待开期",
|
"title": "账期",
|
||||||
"statusCompleted": "账期已结清"
|
"statusCompleted": "已结清",
|
||||||
|
"pipelineShare": "流水 {{count}} 笔",
|
||||||
|
"billTodo": "待确认 {{p}} · 待收付 {{a}}",
|
||||||
|
"openTitle": "开账",
|
||||||
|
"openBtn": "开账",
|
||||||
|
"closeNeedLedger": "本期暂无流水,请先完成开奖结算。",
|
||||||
|
"closeDialogTitle": "确认关账",
|
||||||
|
"closeDialogDesc": "将汇总 {{range}} 内的流水并生成账单。",
|
||||||
|
"closeDialogShare": "流水 {{count}} 笔",
|
||||||
|
"closeDialogEmpty": "本期暂无占成流水,关账后不会生成账单。",
|
||||||
|
"closeDialogUnsettled": "仍有 {{count}} 笔注单未结算",
|
||||||
|
"closeDialogIrreversible": "关账后不可撤销,差错请通过调账或冲正处理。",
|
||||||
|
"closeDialogConfirm": "确认关账"
|
||||||
},
|
},
|
||||||
"subnav": {
|
"periodDetail": {
|
||||||
"label": "结算中心导航"
|
"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": {
|
"nav": {
|
||||||
"aria": "结算中心导航",
|
"periods": "账期",
|
||||||
"group": {
|
|
||||||
"hub": "工作台",
|
|
||||||
"finance": "账务",
|
|
||||||
"ledger": "账务流水",
|
|
||||||
"bills": "账单管理"
|
|
||||||
},
|
|
||||||
"overview": "概览",
|
|
||||||
"periods": "账期管理",
|
|
||||||
"ledger": "账务流水",
|
|
||||||
"bills": "账单",
|
"bills": "账单",
|
||||||
|
"ledger": "账务流水",
|
||||||
"creditLedger": "信用流水",
|
"creditLedger": "信用流水",
|
||||||
"playerBills": "玩家账单",
|
"playerBills": "玩家账单",
|
||||||
"agentBills": "代理账单",
|
"agentBills": "代理账单",
|
||||||
@@ -33,6 +62,7 @@
|
|||||||
},
|
},
|
||||||
"filters": {
|
"filters": {
|
||||||
"period": "账期范围",
|
"period": "账期范围",
|
||||||
|
"statusAll": "全部",
|
||||||
"allPeriods": "全部账期",
|
"allPeriods": "全部账期",
|
||||||
"statusOpen": "进行中",
|
"statusOpen": "进行中",
|
||||||
"statusClosed": "已关账",
|
"statusClosed": "已关账",
|
||||||
@@ -54,7 +84,9 @@
|
|||||||
"badDebtIntro": "坏账核销产生的调账流水,关联原账单。"
|
"badDebtIntro": "坏账核销产生的调账流水,关联原账单。"
|
||||||
},
|
},
|
||||||
"creditLedger": {
|
"creditLedger": {
|
||||||
"intro": "账期内信用占用、账单收付、调账与坏账等流水;行内「⋯」可确认账单、登记收付、调账、冲正或坏账(需关联账单)。",
|
"periodIntro": "待开奖显示「下注冻结」(非扣款);已开奖每注单仅一条「开奖结算」输赢,不会与冻结重复出现。",
|
||||||
|
"emptyPeriod": "本账期暂无下注流水。请确认信用盘玩家已在账期内下注并完成开奖结算。",
|
||||||
|
"intro": "查询账期内的额度变动、收付与调账记录。",
|
||||||
"columns": {
|
"columns": {
|
||||||
"txn": "流水号",
|
"txn": "流水号",
|
||||||
"player": "玩家",
|
"player": "玩家",
|
||||||
@@ -82,15 +114,30 @@
|
|||||||
},
|
},
|
||||||
"reason": {
|
"reason": {
|
||||||
"payment_record": "账单收付",
|
"payment_record": "账单收付",
|
||||||
"bet_hold": "下注占用",
|
"bet_hold": "下注冻结",
|
||||||
|
"freezeAmount": "冻结 {{amount}}",
|
||||||
|
"game_settlement": "开奖结算",
|
||||||
|
"game_settlement_win": "开奖结算入账",
|
||||||
"bet_hold_release": "占用释放",
|
"bet_hold_release": "占用释放",
|
||||||
"game_settlement_loss": "开奖结算扣款",
|
"game_settlement_loss": "开奖结算扣款",
|
||||||
"settlement_confirm": "账期结算确认"
|
"settlement_payout": "结算收付入账",
|
||||||
|
"settlement_confirm": "账期结算确认",
|
||||||
|
"adjustment": "补差",
|
||||||
|
"reversal": "冲正",
|
||||||
|
"bad_debt": "坏账核销",
|
||||||
|
"share_ledger": "占成流水"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"columns": {
|
"columns": {
|
||||||
|
"billId": "账单 ID",
|
||||||
"period": "账期",
|
"period": "账期",
|
||||||
"type": "类型",
|
"type": "类型",
|
||||||
|
"playerAccount": "玩家账号",
|
||||||
|
"playerId": "玩家 ID",
|
||||||
|
"directAgent": "直属代理",
|
||||||
|
"superiorAgent": "上级代理",
|
||||||
|
"play": "玩法",
|
||||||
|
"drawNo": "期号",
|
||||||
"owner": "本方",
|
"owner": "本方",
|
||||||
"counterparty": "对方",
|
"counterparty": "对方",
|
||||||
"gross": "输赢",
|
"gross": "输赢",
|
||||||
@@ -132,6 +179,51 @@
|
|||||||
"viewBill": "查看账单",
|
"viewBill": "查看账单",
|
||||||
"billDetail": "账单详情"
|
"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": {
|
"ledgerPanel": {
|
||||||
"search": "搜索",
|
"search": "搜索",
|
||||||
"searchBtn": "搜索",
|
"searchBtn": "搜索",
|
||||||
@@ -144,30 +236,67 @@
|
|||||||
"optional": "可选",
|
"optional": "可选",
|
||||||
"billStatus": "账单状态",
|
"billStatus": "账单状态",
|
||||||
"dateRange": "时间范围",
|
"dateRange": "时间范围",
|
||||||
"rowPosted": "已记账",
|
"rowPosted": "已记账"
|
||||||
"category": {
|
|
||||||
"all": "全部",
|
|
||||||
"credit": "信用占用",
|
|
||||||
"payment": "收付",
|
|
||||||
"adjustment": "调账 / 冲正",
|
|
||||||
"badDebt": "坏账",
|
|
||||||
"actionable": "待操作"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"billsPanel": {
|
"billsPanel": {
|
||||||
"intro": "关账后生成的占成账单;可按类型与状态筛选,打开详情进行确认与收付。",
|
"billId": "账单 ID",
|
||||||
|
"ownerKeyword": "本方 / 对方",
|
||||||
|
"ownerKeywordPh": "玩家账号、代理名称",
|
||||||
|
"status": "账单状态",
|
||||||
|
"billType": "账单类型",
|
||||||
|
"filterAll": "全部状态",
|
||||||
|
"filterAllTypes": "全部类型",
|
||||||
|
"filterAdjustment": "调账 / 冲正",
|
||||||
|
"optional": "可选",
|
||||||
|
"searchBtn": "搜索",
|
||||||
|
"reset": "重置",
|
||||||
|
"refresh": "刷新",
|
||||||
|
"clientFilterHint": "本方筛选:显示 {{shown}} / {{total}} 条",
|
||||||
"category": {
|
"category": {
|
||||||
"all": "全部",
|
|
||||||
"player": "玩家账单",
|
"player": "玩家账单",
|
||||||
"agent": "代理账单",
|
"agent": "代理账单",
|
||||||
"pendingConfirm": "待确认",
|
"pendingConfirm": "待确认",
|
||||||
"awaitingPayment": "待收付"
|
"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": {
|
"panels": {
|
||||||
|
"workbench": { "title": "工作台" },
|
||||||
"overview": { "title": "结算概览" },
|
"overview": { "title": "结算概览" },
|
||||||
"ledger": { "title": "账务流水" },
|
"ledger": { "title": "账务流水" },
|
||||||
"bills": { "title": "账单" },
|
"bills": { "title": "全部账单" },
|
||||||
"creditLedger": { "title": "信用流水" },
|
"creditLedger": { "title": "信用流水" },
|
||||||
"playerBills": { "title": "玩家账单" },
|
"playerBills": { "title": "玩家账单" },
|
||||||
"agentBills": { "title": "代理账单" },
|
"agentBills": { "title": "代理账单" },
|
||||||
@@ -178,13 +307,17 @@
|
|||||||
"reports": { "title": "账期报表" },
|
"reports": { "title": "账期报表" },
|
||||||
"badDebt": { "title": "坏账核销" }
|
"badDebt": { "title": "坏账核销" }
|
||||||
},
|
},
|
||||||
|
"billsPanel": {
|
||||||
|
"emptyFiltered": "当前筛选下暂无账单,请改为「全部状态」或重置筛选。",
|
||||||
|
"emptyClosed": "本期已关账但暂无账单。常见原因:账期内无信用盘玩家的已结算注单,或占成流水不在本账期时间范围内。"
|
||||||
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"noSite": "请选择接入站点。",
|
"noSite": "请选择站点。",
|
||||||
"noPeriods": "请先在「账期管理」开期并关账。",
|
"noPeriods": "请先关账当前账期。",
|
||||||
"noClosed": "请先关账生成账单。",
|
"noClosed": "请先关账生成账单。",
|
||||||
"noBadDebt": "暂无坏账核销记录。",
|
"noBadDebt": "暂无坏账记录。",
|
||||||
"noCreditLedger": "所选账期内暂无信用流水。请确认信用盘玩家已下注且账期时间范围正确。",
|
"noCreditLedger": "该账期内暂无流水。",
|
||||||
"billsNeedClose": "占成账单须先关账才会出现;若上方「信用流水」有数据而账单为空,请完成开奖结算后执行关账。"
|
"billsNeedClose": "账单在关账后生成。请返回账期列表,对本期执行「关账」后再查看。"
|
||||||
},
|
},
|
||||||
"periods": {
|
"periods": {
|
||||||
"loadFailed": "账期列表加载失败"
|
"loadFailed": "账期列表加载失败"
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
"subnavLabel": "钱包子页",
|
"subnavLabel": "钱包子页",
|
||||||
"subnavTransactions": "主站钱包流水",
|
"subnavTransactions": "主站钱包流水",
|
||||||
"subnavTransferOrders": "主站转账单",
|
"subnavTransferOrders": "主站转账单",
|
||||||
"scopeHint": "本模块为主站钱包模式:钱包流水与主站转账单。信用盘玩家的下注占用、结算记账请查看",
|
"scopeHint": "本模块为主站钱包模式:钱包流水与主站转账单。信用盘玩家的账期结账请查看",
|
||||||
"scopeHintSettlementLink": "结算中心 → 信用流水",
|
"scopeHintSettlementLink": "结算中心",
|
||||||
"scopeHintSettlement": "结算中心 → 信用流水",
|
"scopeHintSettlement": "结算中心",
|
||||||
"ledgerChannel": "账本",
|
"ledgerChannel": "账本",
|
||||||
"ledgerCredit": "信用流水",
|
"ledgerCredit": "信用流水",
|
||||||
"ledgerWallet": "钱包流水",
|
"ledgerWallet": "钱包流水",
|
||||||
|
|||||||
51
src/lib/admin-select-display.ts
Normal file
51
src/lib/admin-select-display.ts
Normal 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;
|
||||||
|
}
|
||||||
13
src/lib/admin-site-display.ts
Normal file
13
src/lib/admin-site-display.ts
Normal 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 ?? "—";
|
||||||
|
}
|
||||||
@@ -58,6 +58,11 @@ const STATUS_TONE_MAP: Record<string, AdminStatusTone> = {
|
|||||||
completed: "success",
|
completed: "success",
|
||||||
failed: "danger",
|
failed: "danger",
|
||||||
|
|
||||||
|
// 代理账期账单
|
||||||
|
confirmed: "warning",
|
||||||
|
partial_paid: "warning",
|
||||||
|
overdue: "danger",
|
||||||
|
|
||||||
// 注单
|
// 注单
|
||||||
pending_confirm: "info",
|
pending_confirm: "info",
|
||||||
partial_pending_confirm: "warning",
|
partial_pending_confirm: "warning",
|
||||||
|
|||||||
@@ -2,20 +2,25 @@ import type { LucideIcon } from "lucide-react";
|
|||||||
import {
|
import {
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
CircleDollarSign,
|
CircleDollarSign,
|
||||||
|
ClipboardList,
|
||||||
|
Coins,
|
||||||
FileSpreadsheet,
|
FileSpreadsheet,
|
||||||
Globe,
|
Globe,
|
||||||
|
KeyRound,
|
||||||
Landmark,
|
Landmark,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
LogIn,
|
LogIn,
|
||||||
Network,
|
Network,
|
||||||
|
Percent,
|
||||||
Scale,
|
Scale,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
Receipt,
|
Receipt,
|
||||||
Settings,
|
Settings,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
ShieldCheck,
|
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
Ticket,
|
Ticket,
|
||||||
|
Trophy,
|
||||||
|
UserCog,
|
||||||
Users,
|
Users,
|
||||||
Wallet,
|
Wallet,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -29,9 +34,9 @@ export const adminNavIconBySegment: Record<AdminNavItem["segment"], LucideIcon>
|
|||||||
agents: Network,
|
agents: Network,
|
||||||
players: Users,
|
players: Users,
|
||||||
draws: CalendarClock,
|
draws: CalendarClock,
|
||||||
rules_plays: SlidersHorizontal,
|
rules_plays: ClipboardList,
|
||||||
rules_odds: SlidersHorizontal,
|
rules_odds: Percent,
|
||||||
jackpot: CircleDollarSign,
|
jackpot: Trophy,
|
||||||
risk_cap: ShieldAlert,
|
risk_cap: ShieldAlert,
|
||||||
tickets: Ticket,
|
tickets: Ticket,
|
||||||
wallet: Wallet,
|
wallet: Wallet,
|
||||||
@@ -41,9 +46,9 @@ export const adminNavIconBySegment: Record<AdminNavItem["segment"], LucideIcon>
|
|||||||
settlement_center: Receipt,
|
settlement_center: Receipt,
|
||||||
reconcile: Scale,
|
reconcile: Scale,
|
||||||
audit: ScrollText,
|
audit: ScrollText,
|
||||||
admin_users: ShieldCheck,
|
admin_users: UserCog,
|
||||||
admin_roles: ShieldCheck,
|
admin_roles: KeyRound,
|
||||||
currencies: CircleDollarSign,
|
currencies: Coins,
|
||||||
integration: Globe,
|
integration: Globe,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export type AdminNavItem = {
|
|||||||
segment: AdminNavSegment;
|
segment: AdminNavSegment;
|
||||||
nav_group?: AdminNavGroup;
|
nav_group?: AdminNavGroup;
|
||||||
platform_only?: boolean;
|
platform_only?: boolean;
|
||||||
|
agent_hidden?: boolean;
|
||||||
activeMatchPrefix?: string;
|
activeMatchPrefix?: string;
|
||||||
requiredAny?: readonly string[];
|
requiredAny?: readonly string[];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { ComponentType } from "react";
|
|||||||
import { ChevronRight, Network, Pencil, Plus, Trash2, Users } from "lucide-react";
|
import { ChevronRight, Network, Pencil, Plus, Trash2, Users } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { AdminSubnav, AdminSubnavButton } from "@/components/admin/admin-subnav";
|
||||||
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
||||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||||
@@ -218,19 +219,23 @@ export function AgentLineDetailPanel({
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</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
|
{tabs
|
||||||
.filter((tab) => tab.visible)
|
.filter((tab) => tab.visible)
|
||||||
.map((tab) => (
|
.map((tab) => (
|
||||||
<TabButton
|
<AdminSubnavButton
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
active={detailTab === tab.key}
|
active={detailTab === tab.key}
|
||||||
onClick={() => onDetailTabChange(tab.key)}
|
onClick={() => onDetailTabChange(tab.key)}
|
||||||
label={tab.label}
|
|
||||||
count={tab.count}
|
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">
|
<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" ? (
|
{detailTab === "overview" ? (
|
||||||
@@ -569,7 +574,7 @@ function DownlineTable({
|
|||||||
{child.email ?? "—"}
|
{child.email ?? "—"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right tabular-nums text-xs">
|
<TableCell className="text-right tabular-nums text-xs">
|
||||||
{summary ? `${ratioToPercentUi(summary.total_share_rate)}%` : "—"}
|
{summary ? `${summary.total_share_rate ?? 0}%` : "—"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right tabular-nums text-xs">
|
<TableCell className="text-right tabular-nums text-xs">
|
||||||
{summary ? formatCredit(summary.credit_limit) : "—"}
|
{summary ? formatCredit(summary.credit_limit) : "—"}
|
||||||
@@ -660,40 +665,3 @@ function MetricCard({
|
|||||||
</div>
|
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||||
import { percentUiToRatio } from "@/lib/admin-rate-percent";
|
import { percentUiToRatio } from "@/lib/admin-rate-percent";
|
||||||
|
import { adminSiteCodeLabel } from "@/lib/admin-select-display";
|
||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
import type { AdminIntegrationSiteRow } from "@/types/api/admin-integration-site";
|
import type { AdminIntegrationSiteRow } from "@/types/api/admin-integration-site";
|
||||||
|
|
||||||
@@ -128,8 +129,11 @@ export function AgentLineProvisionWizard(): React.ReactElement {
|
|||||||
disabled={sitesLoading || unboundSites.length === 0}
|
disabled={sitesLoading || unboundSites.length === 0}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue
|
<SelectValue>
|
||||||
placeholder={
|
{(v) =>
|
||||||
|
adminSiteCodeLabel(
|
||||||
|
v,
|
||||||
|
unboundSites,
|
||||||
sitesLoading
|
sitesLoading
|
||||||
? t("common:loading", { defaultValue: "加载中…" })
|
? t("common:loading", { defaultValue: "加载中…" })
|
||||||
: unboundSites.length === 0
|
: unboundSites.length === 0
|
||||||
@@ -138,9 +142,10 @@ export function AgentLineProvisionWizard(): React.ReactElement {
|
|||||||
})
|
})
|
||||||
: t("agents:lineProvision.siteCodePlaceholder", {
|
: t("agents:lineProvision.siteCodePlaceholder", {
|
||||||
defaultValue: "选择站点",
|
defaultValue: "选择站点",
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
/>
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{unboundSites.map((site) => (
|
{unboundSites.map((site) => (
|
||||||
@@ -248,7 +253,15 @@ export function AgentLineProvisionWizard(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<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>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="daily">
|
<SelectItem value="daily">
|
||||||
|
|||||||
@@ -122,7 +122,9 @@ export function AgentProfileFields({
|
|||||||
>
|
>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={`${idPrefix}-share-rate`}>
|
<Label htmlFor={`${idPrefix}-share-rate`}>
|
||||||
{t("profile.totalShareRate", { defaultValue: "占成比例 (%)" })}
|
{parentCaps
|
||||||
|
? t("profile.relativeShareRate", { defaultValue: "占成比例(占上级 %)" })
|
||||||
|
: t("profile.totalShareRate", { defaultValue: "占成比例 (%)" })}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id={`${idPrefix}-share-rate`}
|
id={`${idPrefix}-share-rate`}
|
||||||
@@ -133,6 +135,14 @@ export function AgentProfileFields({
|
|||||||
value={shareRate}
|
value={shareRate}
|
||||||
onChange={(e) => onShareRateChange(e.target.value)}
|
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>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={`${idPrefix}-credit-limit`}>
|
<Label htmlFor={`${idPrefix}-credit-limit`}>
|
||||||
@@ -200,7 +210,13 @@ export function AgentProfileFields({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger id={`${idPrefix}-settlement-cycle`}>
|
<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>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="daily">{t("profile.cycleDaily", { defaultValue: "日结" })}</SelectItem>
|
<SelectItem value="daily">{t("profile.cycleDaily", { defaultValue: "日结" })}</SelectItem>
|
||||||
|
|||||||
@@ -160,7 +160,13 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const applyProfileRowToForm = (row: AgentProfileRow) => {
|
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));
|
setProfileShareRate(percentValueToUi(row.total_share_rate ?? 0));
|
||||||
|
}
|
||||||
setProfileCreditLimit(String(row.credit_limit ?? 0));
|
setProfileCreditLimit(String(row.credit_limit ?? 0));
|
||||||
setProfileRebateLimit(ratioToPercentUi(row.rebate_limit ?? 0));
|
setProfileRebateLimit(ratioToPercentUi(row.rebate_limit ?? 0));
|
||||||
setProfileDefaultRebate(ratioToPercentUi(row.default_player_rebate ?? 0));
|
setProfileDefaultRebate(ratioToPercentUi(row.default_player_rebate ?? 0));
|
||||||
@@ -173,8 +179,9 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
setProfileRiskTags((row.risk_tags ?? []).join(", "));
|
setProfileRiskTags((row.risk_tags ?? []).join(", "));
|
||||||
};
|
};
|
||||||
|
|
||||||
const profilePayload = () => ({
|
const profilePayload = () => {
|
||||||
total_share_rate: Number.parseFloat(profileShareRate) || 0,
|
const shareRate = Number.parseFloat(profileShareRate) || 0;
|
||||||
|
const base = {
|
||||||
credit_limit: Number.parseInt(profileCreditLimit, 10) || 0,
|
credit_limit: Number.parseInt(profileCreditLimit, 10) || 0,
|
||||||
rebate_limit: percentUiToRatio(profileRebateLimit),
|
rebate_limit: percentUiToRatio(profileRebateLimit),
|
||||||
default_player_rebate: percentUiToRatio(profileDefaultRebate),
|
default_player_rebate: percentUiToRatio(profileDefaultRebate),
|
||||||
@@ -183,7 +190,12 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
can_create_child_agent: profileCanCreateChild,
|
can_create_child_agent: profileCanCreateChild,
|
||||||
can_create_player: profileCanCreatePlayer,
|
can_create_player: profileCanCreatePlayer,
|
||||||
risk_tags: parseRiskTagsInput(profileRiskTags),
|
risk_tags: parseRiskTagsInput(profileRiskTags),
|
||||||
});
|
};
|
||||||
|
if (profileParentCaps) {
|
||||||
|
return { ...base, relative_share_rate: shareRate };
|
||||||
|
}
|
||||||
|
return { ...base, total_share_rate: shareRate };
|
||||||
|
};
|
||||||
|
|
||||||
const validateProfileFields = (): string | null => {
|
const validateProfileFields = (): string | null => {
|
||||||
const shareRate = Number.parseFloat(profileShareRate);
|
const shareRate = Number.parseFloat(profileShareRate);
|
||||||
@@ -443,15 +455,30 @@ export function AgentsConsole(): React.ReactElement {
|
|||||||
setNodeStatus(1);
|
setNodeStatus(1);
|
||||||
setNodeUsername("");
|
setNodeUsername("");
|
||||||
setNodePassword("");
|
setNodePassword("");
|
||||||
resetProfileForm("create");
|
|
||||||
setProfileLoading(false);
|
setProfileLoading(false);
|
||||||
setProfileLoaded(true);
|
setProfileLoaded(true);
|
||||||
setEditingNodeNeedsPrimaryAccount(false);
|
setEditingNodeNeedsPrimaryAccount(false);
|
||||||
setNodeDialogOpen(true);
|
setNodeDialogOpen(true);
|
||||||
if (canManageProfile) {
|
if (canManageProfile) {
|
||||||
void getAgentNodeProfile(node.id)
|
void getAgentNodeProfile(node.id)
|
||||||
.then((p) => setProfileParentCaps(p.parent_caps ?? null))
|
.then((p) => {
|
||||||
.catch(() => setProfileParentCaps(null));
|
// 使用父代理自身的 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");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import {
|
||||||
|
AdminSubnav,
|
||||||
|
AdminSubnavBar,
|
||||||
|
AdminSubnavLink,
|
||||||
|
} from "@/components/admin/admin-subnav";
|
||||||
import { useAdminSiteCodeOptions } from "@/hooks/use-admin-site-code-options";
|
import { useAdminSiteCodeOptions } from "@/hooks/use-admin-site-code-options";
|
||||||
import { isAgentLineSubnavTabVisible } from "@/modules/agents/agent-line-subnav-visibility";
|
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 { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||||
import { PRD_INTEGRATION_ACCESS_ANY } from "@/lib/admin-prd";
|
import { PRD_INTEGRATION_ACCESS_ANY } from "@/lib/admin-prd";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
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: {
|
const primaryTabs: {
|
||||||
href: string;
|
href: string;
|
||||||
@@ -86,50 +89,8 @@ export function AgentsSubnav(): React.ReactElement {
|
|||||||
return <></>;
|
return <></>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const siteSelector =
|
||||||
<div className="flex w-full flex-wrap items-center justify-between gap-3 rounded-lg bg-muted/50 p-1">
|
canSwitchSite && siteOptions.length > 0 && selectSiteId !== null ? (
|
||||||
<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 ? (
|
|
||||||
<Select
|
<Select
|
||||||
value={String(selectSiteId)}
|
value={String(selectSiteId)}
|
||||||
onValueChange={(value) => setAdminSiteId(Number(value))}
|
onValueChange={(value) => setAdminSiteId(Number(value))}
|
||||||
@@ -147,7 +108,30 @@ export function AgentsSubnav(): React.ReactElement {
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</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}
|
) : null}
|
||||||
</div>
|
</AdminSubnav>
|
||||||
|
</AdminSubnavBar>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,14 +25,6 @@ export const CONFIG_NAV_GROUPS: readonly ConfigNavGroup[] = [
|
|||||||
href: "/admin/config/plays",
|
href: "/admin/config/plays",
|
||||||
key: "plays",
|
key: "plays",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
href: "/admin/config/odds",
|
|
||||||
key: "odds",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: "/admin/config/rebate",
|
|
||||||
key: "rebate",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
href: "/admin/config/jackpot",
|
href: "/admin/config/jackpot",
|
||||||
key: "jackpot",
|
key: "jackpot",
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useTranslation } from "react-i18next";
|
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";
|
import { CONFIG_NAV_GROUPS } from "@/modules/config/config-nav-model";
|
||||||
|
|
||||||
export function ConfigSubNav() {
|
export function ConfigSubNav() {
|
||||||
@@ -13,27 +12,15 @@ export function ConfigSubNav() {
|
|||||||
const links = CONFIG_NAV_GROUPS.flatMap((group) => group.items);
|
const links = CONFIG_NAV_GROUPS.flatMap((group) => group.items);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<AdminSubnav aria-label={t("nav.aria")}>
|
||||||
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")}
|
|
||||||
>
|
|
||||||
{links.map(({ href, key }) => {
|
{links.map(({ href, key }) => {
|
||||||
const active = pathname === href || pathname.startsWith(`${href}/`);
|
const active = pathname === href || pathname.startsWith(`${href}/`);
|
||||||
return (
|
return (
|
||||||
<Link
|
<AdminSubnavLink key={href} href={href} active={active}>
|
||||||
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",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{t(`nav.items.${key}`)}
|
{t(`nav.items.${key}`)}
|
||||||
</Link>
|
</AdminSubnavLink>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</AdminSubnav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ type ConfigVersionActionsProps = {
|
|||||||
loadingDetail?: boolean;
|
loadingDetail?: boolean;
|
||||||
saving?: boolean;
|
saving?: boolean;
|
||||||
publishLabel?: string;
|
publishLabel?: string;
|
||||||
|
/** 合并编辑页由底部操作栏承接保存/发布时隐藏 */
|
||||||
|
suppressDraftActions?: boolean;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
onNewDraft: () => void;
|
onNewDraft: () => void;
|
||||||
onSaveDraft: () => void;
|
onSaveDraft: () => void;
|
||||||
@@ -28,6 +30,7 @@ export function ConfigVersionActions({
|
|||||||
loadingDetail = false,
|
loadingDetail = false,
|
||||||
saving = false,
|
saving = false,
|
||||||
publishLabel,
|
publishLabel,
|
||||||
|
suppressDraftActions = false,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onNewDraft,
|
onNewDraft,
|
||||||
onSaveDraft,
|
onSaveDraft,
|
||||||
@@ -59,7 +62,7 @@ export function ConfigVersionActions({
|
|||||||
<Plus className="size-3.5" aria-hidden />
|
<Plus className="size-3.5" aria-hidden />
|
||||||
{t("versionActions.newDraft")}
|
{t("versionActions.newDraft")}
|
||||||
</Button>
|
</Button>
|
||||||
{isDraft ? (
|
{isDraft && !suppressDraftActions ? (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
34
src/modules/config/doc/odds-config-dirty.ts
Normal file
34
src/modules/config/doc/odds-config-dirty.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -55,11 +55,18 @@ import type {
|
|||||||
OddsVersionDetail,
|
OddsVersionDetail,
|
||||||
} from "@/types/api/admin-config";
|
} 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 {
|
import {
|
||||||
OddsConfigSummaryPanel,
|
Table,
|
||||||
playRebatePercentFromScopes,
|
TableBody,
|
||||||
} from "@/modules/config/doc/odds-config-summary-panel";
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
import {
|
import {
|
||||||
buildOddsPlayFilterGroups,
|
buildOddsPlayFilterGroups,
|
||||||
filterOddsPlayTypesByCategory,
|
filterOddsPlayTypesByCategory,
|
||||||
@@ -507,6 +514,13 @@ export function OddsConfigDocScreen({
|
|||||||
? resolveAdminPlayTypeDisplayName(resolvedPlayCode, i18n.language, sortedTypes.find((t) => t.play_code === resolvedPlayCode))
|
? 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 = (
|
const filtersInner = (
|
||||||
<>
|
<>
|
||||||
<ConfigChipGroup label={t("odds.category", { ns: "config" })}>
|
<ConfigChipGroup label={t("odds.category", { ns: "config" })}>
|
||||||
@@ -602,6 +616,7 @@ export function OddsConfigDocScreen({
|
|||||||
loadingList={resolvedLoadingList}
|
loadingList={resolvedLoadingList}
|
||||||
loadingDetail={resolvedLoadingDetail}
|
loadingDetail={resolvedLoadingDetail}
|
||||||
saving={saving}
|
saving={saving}
|
||||||
|
suppressDraftActions={mergedLayout && isDraft}
|
||||||
onRefresh={() => void refreshList()}
|
onRefresh={() => void refreshList()}
|
||||||
onNewDraft={() => void handleNewDraft()}
|
onNewDraft={() => void handleNewDraft()}
|
||||||
onSaveDraft={() => void handleSave()}
|
onSaveDraft={() => void handleSave()}
|
||||||
@@ -635,24 +650,89 @@ export function OddsConfigDocScreen({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const mainBlock = (
|
const rebateField = (
|
||||||
<>
|
<div className="rounded-lg border border-border/60 bg-muted/20 p-4">
|
||||||
{resolvedError ? <p className="text-sm text-destructive">{resolvedError}</p> : null}
|
<div className="grid max-w-xs gap-1.5">
|
||||||
|
<Label htmlFor="odds-rebate-rate">{t("odds.rebateRate", { ns: "config" })}</Label>
|
||||||
{resolvedLoadingDetail || resolvedLoadingTypes ? (
|
{canEditDraft ? (
|
||||||
<AdminLoadingState
|
<Input
|
||||||
className={cn(mergedLayout ? "py-6" : "py-8")}
|
id="odds-rebate-rate"
|
||||||
minHeight="6rem"
|
type="text"
|
||||||
label={t("odds.loadingDetails", { ns: "config" })}
|
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)}>
|
<ConfigReadonlyValue mono className="h-9 w-full max-w-xs justify-center">
|
||||||
<div className="grid grid-cols-2 gap-x-4 gap-y-4 sm:grid-cols-3">
|
{rebatePercentUi}
|
||||||
{PRIZE_SCOPE_ORDER.map((scope) => {
|
</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 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;
|
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">
|
<div key={scope} className="grid min-w-0 gap-1.5">
|
||||||
<Label className="truncate text-xs font-medium text-muted-foreground">
|
<Label className="truncate text-xs font-medium text-muted-foreground">
|
||||||
{prizeScopeLabel(scope, t)}
|
{prizeScopeLabel(scope, t)}
|
||||||
@@ -682,8 +762,7 @@ export function OddsConfigDocScreen({
|
|||||||
<p className="text-xs text-destructive">{t("odds.missingScopeRow", { ns: "config", scope })}</p>
|
<p className="text-xs text-destructive">{t("odds.missingScopeRow", { ns: "config", scope })}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
<div className="grid min-w-0 gap-1.5">
|
<div className="grid min-w-0 gap-1.5">
|
||||||
<Label className="truncate text-xs font-medium text-muted-foreground">
|
<Label className="truncate text-xs font-medium text-muted-foreground">
|
||||||
{t("odds.rebateRate", { ns: "config" })}
|
{t("odds.rebateRate", { ns: "config" })}
|
||||||
@@ -705,9 +784,33 @@ export function OddsConfigDocScreen({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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 ? (
|
{!embedded ? (
|
||||||
<p className="mt-3 text-xs text-muted-foreground">{t("odds.rebateRateHint", { ns: "config" })}</p>
|
<p className="mt-3 text-xs text-muted-foreground">{t("odds.rebateRateHint", { ns: "config" })}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
@@ -785,34 +888,51 @@ export function OddsConfigDocScreen({
|
|||||||
|
|
||||||
if (embedded && mergedLayout) {
|
if (embedded && mergedLayout) {
|
||||||
return (
|
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="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">
|
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_min(100%,260px)] xl:items-start">
|
||||||
<ConfigWorkflowSection step={1} title={t("odds.sections.playScope", { ns: "config" })}>
|
<div className="overflow-hidden rounded-xl border border-border/60 bg-card">
|
||||||
{filtersBlock}
|
<div className="grid gap-0 lg:grid-cols-[minmax(0,13rem)_minmax(0,1fr)]">
|
||||||
</ConfigWorkflowSection>
|
<aside className="border-b border-border/50 px-4 py-4 lg:border-r lg:border-b-0">
|
||||||
<ConfigWorkflowSection
|
<OddsConfigPlayNav
|
||||||
step={2}
|
catTab={catTab}
|
||||||
title={t("odds.sections.oddsConfig", { ns: "config" })}
|
onCatTabChange={setCatTab}
|
||||||
description={t("odds.currentSelection", {
|
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",
|
ns: "config",
|
||||||
category: activeCatLabel,
|
category: activeCatLabel,
|
||||||
play: activePlayLabel,
|
play: activePlayLabel,
|
||||||
})}
|
})}
|
||||||
>
|
</p>
|
||||||
{mainBlock}
|
|
||||||
</ConfigWorkflowSection>
|
|
||||||
{rebateSection}
|
|
||||||
</div>
|
</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
|
<OddsConfigSummaryPanel
|
||||||
catTabLabel={activeCatLabel}
|
compact
|
||||||
playLabel={activePlayLabel}
|
|
||||||
detail={resolvedDetail}
|
detail={resolvedDetail}
|
||||||
draftRows={resolvedDraftRows}
|
activeHead={activeHead ?? null}
|
||||||
types={sortedTypes}
|
|
||||||
scopeRows={scopeRows}
|
|
||||||
playRebatePercent={playRebatePercentFromScopes(scopeRows, PRIZE_SCOPE_ORDER)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{dialogs}
|
{dialogs}
|
||||||
|
|||||||
51
src/modules/config/doc/odds-config-draft-bar.tsx
Normal file
51
src/modules/config/doc/odds-config-draft-bar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
158
src/modules/config/doc/odds-config-play-nav.tsx
Normal file
158
src/modules/config/doc/odds-config-play-nav.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,28 +5,21 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { ConfigStatusBadge } from "@/modules/config/config-status-badge";
|
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 { prizeScopeLabel, type PrizeScopeCode } from "@/modules/config/doc/prize-scopes";
|
||||||
|
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { AdminPlayTypeRow, OddsItemRow, OddsVersionDetail } from "@/types/api/admin-config";
|
import type { ConfigVersionSummary, OddsItemRow, OddsVersionDetail } from "@/types/api/admin-config";
|
||||||
|
|
||||||
function oddsMultiplierLabel(oddsValue: number): string {
|
|
||||||
return (oddsValue / 10000).toFixed(4);
|
|
||||||
}
|
|
||||||
|
|
||||||
type SummaryRow = {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type OddsConfigSummaryPanelProps = {
|
type OddsConfigSummaryPanelProps = {
|
||||||
catTabLabel: string;
|
catTabLabel?: string;
|
||||||
playLabel: string;
|
playLabel?: string;
|
||||||
detail: OddsVersionDetail | null;
|
detail: OddsVersionDetail | null;
|
||||||
draftRows: OddsItemRow[];
|
scopeRows?: Partial<Record<PrizeScopeCode, OddsItemRow>>;
|
||||||
types: AdminPlayTypeRow[];
|
playRebatePercent?: string;
|
||||||
scopeRows: Partial<Record<PrizeScopeCode, OddsItemRow>>;
|
activeHead?: ConfigVersionSummary | null;
|
||||||
playRebatePercent: string;
|
/** 合并页:仅展示版本与操作提示,不重复主编辑区数值 */
|
||||||
|
compact?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -34,63 +27,32 @@ export function OddsConfigSummaryPanel({
|
|||||||
catTabLabel,
|
catTabLabel,
|
||||||
playLabel,
|
playLabel,
|
||||||
detail,
|
detail,
|
||||||
draftRows,
|
|
||||||
types,
|
|
||||||
scopeRows,
|
scopeRows,
|
||||||
playRebatePercent,
|
playRebatePercent,
|
||||||
|
activeHead = null,
|
||||||
|
compact = false,
|
||||||
className,
|
className,
|
||||||
}: OddsConfigSummaryPanelProps) {
|
}: OddsConfigSummaryPanelProps) {
|
||||||
const { t } = useTranslation("config");
|
const { t } = useTranslation("config");
|
||||||
|
const formatDt = useAdminDateTimeFormatter();
|
||||||
|
|
||||||
const isDraft = detail?.status === "draft";
|
const isDraft = detail?.status === "draft";
|
||||||
const isActive = detail?.status === "active";
|
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}` : "—";
|
const versionLabel = detail ? `v${detail.version_no}` : "—";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="overflow-hidden rounded-xl border border-border/60 bg-card">
|
<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">
|
<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 />
|
<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>
|
||||||
|
|
||||||
<div className="space-y-4 px-4 py-4">
|
<div className="space-y-4 px-4 py-4">
|
||||||
@@ -100,29 +62,50 @@ export function OddsConfigSummaryPanel({
|
|||||||
{detail ? <ConfigStatusBadge status={detail.status} /> : null}
|
{detail ? <ConfigStatusBadge status={detail.status} /> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
{activeHead ? (
|
||||||
<span className="text-sm text-muted-foreground">{t("odds.summary.statusLabel")}</span>
|
<div className="space-y-1 text-sm">
|
||||||
{detail && !isDraft ? (
|
<p className="text-muted-foreground">{t("odds.summary.activeVersion")}</p>
|
||||||
<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">
|
<p className="font-mono font-medium">
|
||||||
{t("odds.summary.readOnlyTag")}
|
v{activeHead.version_no}
|
||||||
</span>
|
{activeHead.effective_at ? ` · ${formatDt(activeHead.effective_at)}` : ""}
|
||||||
) : isDraft ? (
|
</p>
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<dl className="space-y-2.5">
|
{!compact && catTabLabel && playLabel ? (
|
||||||
{rows.map((row) => (
|
<dl className="space-y-2 text-sm">
|
||||||
<div key={row.label} className="grid grid-cols-[minmax(0,1fr)_auto] gap-3 text-sm">
|
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-3">
|
||||||
<dt className="text-muted-foreground">{row.label}</dt>
|
<dt className="text-muted-foreground">{t("odds.category")}</dt>
|
||||||
<dd className="font-mono text-right tabular-nums text-foreground">{row.value}</dd>
|
<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>
|
</div>
|
||||||
))}
|
|
||||||
</dl>
|
</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 ? (
|
{detail && !isDraft ? (
|
||||||
<Alert className="border-sky-500/30 bg-sky-500/5 text-foreground">
|
<Alert className="border-sky-500/30 bg-sky-500/5 text-foreground">
|
||||||
@@ -131,6 +114,12 @@ export function OddsConfigSummaryPanel({
|
|||||||
{t("odds.summary.readOnlyHint")}
|
{t("odds.summary.readOnlyHint")}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</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 ? (
|
) : isActive ? (
|
||||||
<Alert className="border-emerald-500/30 bg-emerald-500/5 text-foreground">
|
<Alert className="border-emerald-500/30 bg-emerald-500/5 text-foreground">
|
||||||
<AlertDescription className="text-xs leading-relaxed">
|
<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(
|
export function playRebatePercentFromScopes(
|
||||||
scopeRows: Partial<Record<PrizeScopeCode, OddsItemRow>>,
|
scopeRows: Partial<Record<PrizeScopeCode, OddsItemRow>>,
|
||||||
|
|||||||
@@ -134,7 +134,15 @@ export function RiskCapRuntimePanel() {
|
|||||||
disabled={drawsLoading || draws.length === 0}
|
disabled={drawsLoading || draws.length === 0}
|
||||||
>
|
>
|
||||||
<SelectTrigger id="risk-cap-draw" className="font-mono">
|
<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>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{draws.map((d) => (
|
{draws.map((d) => (
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { useConfirmAction } from "@/hooks/use-confirm-action";
|
|||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
import type { AdminDrawShowData } from "@/types/api/admin-draws";
|
import type { AdminDrawShowData } from "@/types/api/admin-draws";
|
||||||
import { canManageDrawResults } from "@/lib/draw-access";
|
import { canManageDrawResults } from "@/lib/draw-access";
|
||||||
|
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||||
import { useAdminProfile } from "@/stores/admin-session";
|
import { useAdminProfile } from "@/stores/admin-session";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { 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 { canManageDrawResults, canViewDrawFinance, canViewDrawResults } from "@/lib/draw-access";
|
||||||
import { useAdminProfile } from "@/stores/admin-session";
|
import { useAdminProfile } from "@/stores/admin-session";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
const segments = [
|
const segments = [
|
||||||
{ suffix: "", key: "status", label: "subnav.status", requiresManage: false },
|
{ suffix: "", key: "status", label: "subnav.status", requiresManage: false },
|
||||||
@@ -94,7 +92,7 @@ export function DrawSubnav({ drawId }: { drawId: string }): React.ReactElement {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
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 }) => {
|
{visibleSegments.map(({ suffix, key, label }) => {
|
||||||
const href = `${base}${suffix}`;
|
const href = `${base}${suffix}`;
|
||||||
const active =
|
const active =
|
||||||
@@ -107,17 +105,11 @@ export function DrawSubnav({ drawId }: { drawId: string }): React.ReactElement {
|
|||||||
: pathname === href || pathname.startsWith(`${href}/`);
|
: pathname === href || pathname.startsWith(`${href}/`);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<AdminSubnavLink key={key} href={href} active={active}>
|
||||||
key={key}
|
|
||||||
href={href}
|
|
||||||
className={cn(
|
|
||||||
buttonVariants({ variant: active ? "default" : "outline", size: "sm" }),
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{t(label)}
|
{t(label)}
|
||||||
</Link>
|
</AdminSubnavLink>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</AdminSubnav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { AdminListPaginationFooter } from "@/components/admin/admin-list-paginat
|
|||||||
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||||
import { AdminPlayerIdentityCells, AdminPlayerIdentityHeads } from "@/components/admin/admin-player-identity-columns";
|
import { AdminPlayerIdentityCells, AdminPlayerIdentityHeads } from "@/components/admin/admin-player-identity-columns";
|
||||||
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
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 { ModuleScaffold } from "@/components/admin/module-scaffold";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
@@ -327,24 +328,20 @@ export function JackpotRecordsConsole({ embedded = false }: JackpotRecordsConsol
|
|||||||
{filterBlock}
|
{filterBlock}
|
||||||
{err ? <p className="text-destructive text-sm">{err}</p> : null}
|
{err ? <p className="text-destructive text-sm">{err}</p> : null}
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2 border-b border-border/70 pb-3">
|
<AdminSubnav aria-label={t("recordTabs", { defaultValue: "奖池记录" })}>
|
||||||
<Button
|
<AdminSubnavButton
|
||||||
type="button"
|
active={recordTab === "payout"}
|
||||||
size="sm"
|
|
||||||
variant={recordTab === "payout" ? "default" : "outline"}
|
|
||||||
onClick={() => setRecordTab("payout")}
|
onClick={() => setRecordTab("payout")}
|
||||||
>
|
>
|
||||||
{t("payoutRecords")}
|
{t("payoutRecords")}
|
||||||
</Button>
|
</AdminSubnavButton>
|
||||||
<Button
|
<AdminSubnavButton
|
||||||
type="button"
|
active={recordTab === "contribution"}
|
||||||
size="sm"
|
|
||||||
variant={recordTab === "contribution" ? "default" : "outline"}
|
|
||||||
onClick={() => setRecordTab("contribution")}
|
onClick={() => setRecordTab("contribution")}
|
||||||
>
|
>
|
||||||
{t("contributionRecords")}
|
{t("contributionRecords")}
|
||||||
</Button>
|
</AdminSubnavButton>
|
||||||
</div>
|
</AdminSubnav>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{recordTab === "payout" ? payoutTable : contributionTable}
|
{recordTab === "payout" ? payoutTable : contributionTable}
|
||||||
|
|||||||
@@ -270,20 +270,17 @@ export function PlayerDetailConsole({ playerId }: { playerId: number }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs defaultValue="overview" className="gap-4">
|
<Tabs defaultValue="overview" className="gap-4">
|
||||||
<TabsList variant="line" className="w-full justify-start border-b rounded-none bg-transparent p-0">
|
<TabsList
|
||||||
<TabsTrigger value="overview" className="rounded-none px-3">
|
variant="line"
|
||||||
{t("tabOverview")}
|
className="h-auto w-full justify-start gap-1 rounded-none border-b border-border/60 bg-transparent p-0 px-1"
|
||||||
</TabsTrigger>
|
>
|
||||||
<TabsTrigger value="tickets" className="rounded-none px-3">
|
<TabsTrigger value="overview">{t("tabOverview")}</TabsTrigger>
|
||||||
{t("tabTickets")}
|
<TabsTrigger value="tickets">{t("tabTickets")}</TabsTrigger>
|
||||||
</TabsTrigger>
|
<TabsTrigger value="wallet">
|
||||||
<TabsTrigger value="wallet" className="rounded-none px-3">
|
|
||||||
{isCreditPlayer ? t("tabCreditLedger") : t("tabWalletTxns")}
|
{isCreditPlayer ? t("tabCreditLedger") : t("tabWalletTxns")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
{showTransferTab ? (
|
{showTransferTab ? (
|
||||||
<TabsTrigger value="transfers" className="rounded-none px-3">
|
<TabsTrigger value="transfers">{t("tabTransferOrders")}</TabsTrigger>
|
||||||
{t("tabTransferOrders")}
|
|
||||||
</TabsTrigger>
|
|
||||||
) : null}
|
) : null}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ import { useAdminCurrencyCatalog } from "@/hooks/use-admin-currency-catalog";
|
|||||||
import { adminPlayerDetailPath } from "@/lib/admin-player-paths";
|
import { adminPlayerDetailPath } from "@/lib/admin-player-paths";
|
||||||
import { PlayerFundingModeBadge } from "@/components/admin/player-funding-badges";
|
import { PlayerFundingModeBadge } from "@/components/admin/player-funding-badges";
|
||||||
import { playerBalanceCells } from "@/lib/admin-player-display";
|
import { playerBalanceCells } from "@/lib/admin-player-display";
|
||||||
|
import { ADMIN_SELECT_FILTER_ALL, adminSiteSelectLabel } from "@/lib/admin-select-display";
|
||||||
import { formatAdminMinorUnits } from "@/lib/money";
|
import { formatAdminMinorUnits } from "@/lib/money";
|
||||||
import { LotteryApiBizError } from "@/types/api/errors";
|
import { LotteryApiBizError } from "@/types/api/errors";
|
||||||
import type { AdminPlayerRow } from "@/types/api/admin-player";
|
import type { AdminPlayerRow } from "@/types/api/admin-player";
|
||||||
@@ -419,17 +420,27 @@ export function PlayersConsole(): React.ReactElement {
|
|||||||
{t("filterSite")}
|
{t("filterSite")}
|
||||||
</Label>
|
</Label>
|
||||||
<Select
|
<Select
|
||||||
value={siteFilter || "__all__"}
|
value={siteFilter || ADMIN_SELECT_FILTER_ALL}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
setSiteFilter(value === "__all__" ? "" : value);
|
setSiteFilter(
|
||||||
|
value == null || value === ADMIN_SELECT_FILTER_ALL ? "" : value,
|
||||||
|
);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger id="player-site-filter" className="w-full sm:w-[12rem]">
|
<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>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="__all__">{t("filterAllSites")}</SelectItem>
|
<SelectItem value={ADMIN_SELECT_FILTER_ALL}>{t("filterAllSites")}</SelectItem>
|
||||||
{(isSuperAdmin ? siteOptions : profile?.accessible_sites ?? []).map((site) => (
|
{(isSuperAdmin ? siteOptions : profile?.accessible_sites ?? []).map((site) => (
|
||||||
<SelectItem key={site.code} value={site.code}>
|
<SelectItem key={site.code} value={site.code}>
|
||||||
{site.name ? `${site.name} (${site.code})` : site.code}
|
{site.name ? `${site.name} (${site.code})` : site.code}
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ function itemStatusLabel(status: string, t: (key: string) => string): string {
|
|||||||
function reconcileTypeLabel(type: string, t: (key: string) => string): string {
|
function reconcileTypeLabel(type: string, t: (key: string) => string): string {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "wallet_transfer":
|
case "wallet_transfer":
|
||||||
return t("reconcileTypeWalletTransfer");
|
return t("reconcileTypeFixed");
|
||||||
default:
|
default:
|
||||||
return type;
|
return type;
|
||||||
}
|
}
|
||||||
@@ -237,6 +237,7 @@ export function ReconcileConsole(): React.ReactElement {
|
|||||||
const selectedJobItemCount = getJobSummaryValue(selectedJob?.summary_json, "item_count");
|
const selectedJobItemCount = getJobSummaryValue(selectedJob?.summary_json, "item_count");
|
||||||
const selectedJobMismatchCount = getJobSummaryValue(selectedJob?.summary_json, "mismatch_count");
|
const selectedJobMismatchCount = getJobSummaryValue(selectedJob?.summary_json, "mismatch_count");
|
||||||
const selectedJobMatchedCount = Math.max(0, selectedJobItemCount - selectedJobMismatchCount);
|
const selectedJobMatchedCount = Math.max(0, selectedJobItemCount - selectedJobMismatchCount);
|
||||||
|
const hasSelectedRange = dateFrom.trim() !== "" && dateTo.trim() !== "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full max-w-none flex-col gap-6">
|
<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="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">
|
<div className="min-w-0 text-sm text-muted-foreground">
|
||||||
{selectedPlayer
|
{hasSelectedRange
|
||||||
|
? selectedPlayer
|
||||||
? t("createSummaryPlayer", {
|
? t("createSummaryPlayer", {
|
||||||
player: selectedPlayer.site_player_id,
|
player: selectedPlayer.site_player_id,
|
||||||
from: dateFrom || "—",
|
from: dateFrom,
|
||||||
to: dateTo || "—",
|
to: dateTo,
|
||||||
})
|
})
|
||||||
: t("createSummaryAll", {
|
: t("createSummaryAll", {
|
||||||
from: dateFrom || "—",
|
from: dateFrom,
|
||||||
to: dateTo || "—",
|
to: dateTo,
|
||||||
|
})
|
||||||
|
: t("createSummaryPending", {
|
||||||
|
defaultValue: "请选择完整的对账日期范围后,再创建任务。",
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
import { AdminSubnav, AdminSubnavLink } from "@/components/admin/admin-subnav";
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ category: "profit", href: "/admin/reports/profit" },
|
{ category: "profit", href: "/admin/reports/profit" },
|
||||||
@@ -18,24 +18,15 @@ export function ReportsSubnav(): React.ReactElement {
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
return (
|
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) => {
|
{tabs.map((tab) => {
|
||||||
const active = pathname === tab.href || pathname.startsWith(`${tab.href}/`);
|
const active = pathname === tab.href || pathname.startsWith(`${tab.href}/`);
|
||||||
return (
|
return (
|
||||||
<Link
|
<AdminSubnavLink key={tab.href} href={tab.href} active={active}>
|
||||||
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",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{t(`categories.${tab.category}`)}
|
{t(`categories.${tab.category}`)}
|
||||||
</Link>
|
</AdminSubnavLink>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</AdminSubnav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ import Link from "next/link";
|
|||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useTranslation } from "react-i18next";
|
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";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const segments = [
|
const segments = [
|
||||||
@@ -28,28 +33,26 @@ export function RiskSubnav({ drawId }: { drawId: string }) {
|
|||||||
const base = `/admin/draws/${drawId}/risk`;
|
const base = `/admin/draws/${drawId}/risk`;
|
||||||
|
|
||||||
return (
|
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 }) => {
|
{segments.map(({ suffix, key, label }) => {
|
||||||
const href = `${base}${suffix}`;
|
const href = `${base}${suffix}`;
|
||||||
const active =
|
const active =
|
||||||
key === "pools" ? isPoolsTabActive(pathname, base) : pathname === href;
|
key === "pools" ? isPoolsTabActive(pathname, base) : pathname === href;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<AdminSubnavLink key={key} href={href} active={active}>
|
||||||
key={key}
|
|
||||||
href={href}
|
|
||||||
className={cn(buttonVariants({ variant: active ? "default" : "outline", size: "sm" }))}
|
|
||||||
>
|
|
||||||
{t(label)}
|
{t(label)}
|
||||||
</Link>
|
</AdminSubnavLink>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
</AdminSubnav>
|
||||||
<Link
|
<Link
|
||||||
href="/admin/draws"
|
href="/admin/draws"
|
||||||
className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "ml-auto")}
|
className={cn(adminSubnavItemClassName(false), "text-muted-foreground")}
|
||||||
>
|
>
|
||||||
{t("changeDraw")}
|
{t("changeDraw")}
|
||||||
</Link>
|
</Link>
|
||||||
</nav>
|
</AdminSubnavBar>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,54 +1,30 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
|
import { AdminPermissionGate } from "@/components/admin/admin-permission-gate";
|
||||||
import { PRD_RULES_ODDS_ACCESS_ANY } from "@/lib/admin-prd";
|
import { PRD_RULES_ODDS_ACCESS_ANY } from "@/lib/admin-prd";
|
||||||
import { ConfigDocPage } from "@/modules/config/config-doc-page";
|
import { ConfigDocPage } from "@/modules/config/config-doc-page";
|
||||||
import { OddsConfigDocScreen } from "@/modules/config/doc/odds-config-doc-screen";
|
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 { useOddsConfigWorkspace } from "@/modules/config/use-odds-config-workspace";
|
||||||
import { RulesPageShell } from "@/modules/rules/rules-page-shell";
|
import { RulesPageShell } from "@/modules/rules/rules-page-shell";
|
||||||
|
|
||||||
/** 赔率与回水:共用赔率版本线,主栏三步骤 + 右侧配置摘要。 */
|
/** 赔率与回水:共用赔率版本线,主栏两步骤 + 右侧配置摘要。 */
|
||||||
export function RulesOddsConfigScreen() {
|
export function RulesOddsConfigScreen() {
|
||||||
const { t } = useTranslation("config");
|
const { t } = useTranslation("config");
|
||||||
const [sharedVersionId, setSharedVersionId] = useState("");
|
const [sharedVersionId, setSharedVersionId] = useState("");
|
||||||
const workspace = useOddsConfigWorkspace(sharedVersionId, setSharedVersionId);
|
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 (
|
return (
|
||||||
<RulesPageShell>
|
<RulesPageShell>
|
||||||
<AdminPermissionGate requiredAny={PRD_RULES_ODDS_ACCESS_ANY}>
|
<AdminPermissionGate requiredAny={PRD_RULES_ODDS_ACCESS_ANY}>
|
||||||
<ConfigDocPage
|
<ConfigDocPage
|
||||||
title={t("nav.rulesOddsTitle")}
|
title={t("nav.rulesOddsTitle")}
|
||||||
description={t("nav.rulesOddsDescription")}
|
description={t("nav.rulesOddsDescriptionShort")}
|
||||||
contentClassName="pt-2"
|
contentClassName="pt-2"
|
||||||
>
|
>
|
||||||
<OddsConfigDocScreen
|
<OddsConfigDocScreen embedded mergedLayout workspace={workspace} />
|
||||||
embedded
|
|
||||||
mergedLayout
|
|
||||||
workspace={workspace}
|
|
||||||
rebateSection={rebateSection}
|
|
||||||
/>
|
|
||||||
</ConfigDocPage>
|
</ConfigDocPage>
|
||||||
</AdminPermissionGate>
|
</AdminPermissionGate>
|
||||||
</RulesPageShell>
|
</RulesPageShell>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { ArrowRight } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -16,30 +17,16 @@ import {
|
|||||||
} from "@/api/admin-agent-settlement";
|
} from "@/api/admin-agent-settlement";
|
||||||
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||||||
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
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 = {
|
type AgentBillDetailProps = {
|
||||||
billId: number;
|
billId: number;
|
||||||
currencyCode: string;
|
currencyCode: string;
|
||||||
@@ -53,17 +40,17 @@ export function AgentBillDetail({
|
|||||||
canManage = true,
|
canManage = true,
|
||||||
onUpdated,
|
onUpdated,
|
||||||
}: AgentBillDetailProps): React.ReactElement {
|
}: AgentBillDetailProps): React.ReactElement {
|
||||||
const { t } = useTranslation(["agents", "common"]);
|
const { t } = useTranslation(["agents", "settlementCenter", "common"]);
|
||||||
const [bill, setBill] = useState<SettlementBillRow | null>(null);
|
const [bill, setBill] = useState<SettlementBillRow | null>(null);
|
||||||
const [payments, setPayments] = useState<SettlementPaymentRow[]>([]);
|
const [payments, setPayments] = useState<SettlementPaymentRow[]>([]);
|
||||||
const [rebateAllocations, setRebateAllocations] = useState<RebateAllocationRow[]>([]);
|
const [rebateAllocations, setRebateAllocations] = useState<RebateAllocationRow[]>([]);
|
||||||
const [tierEdge, setTierEdge] = useState<string | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [payAmount, setPayAmount] = useState("");
|
const [payAmount, setPayAmount] = useState("");
|
||||||
const [payMethod, setPayMethod] = useState("");
|
const [payMethod, setPayMethod] = useState("");
|
||||||
const [payProof, setPayProof] = useState("");
|
const [payProof, setPayProof] = useState("");
|
||||||
const [adjustAmount, setAdjustAmount] = useState("");
|
const [adjustAmount, setAdjustAmount] = useState("");
|
||||||
const [badDebtReason, setBadDebtReason] = useState("");
|
const [badDebtReason, setBadDebtReason] = useState("");
|
||||||
|
const [rebateDetailsOpen, setRebateDetailsOpen] = useState(false);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -72,7 +59,6 @@ export function AgentBillDetail({
|
|||||||
setBill(data.bill);
|
setBill(data.bill);
|
||||||
setPayments(data.payments ?? []);
|
setPayments(data.payments ?? []);
|
||||||
setRebateAllocations(data.rebate_allocations ?? []);
|
setRebateAllocations(data.rebate_allocations ?? []);
|
||||||
setTierEdge(data.tier_edge ?? null);
|
|
||||||
setPayAmount(String(data.bill.unpaid_amount ?? 0));
|
setPayAmount(String(data.bill.unpaid_amount ?? 0));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -87,20 +73,12 @@ export function AgentBillDetail({
|
|||||||
return <AdminLoadingState />;
|
return <AdminLoadingState />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const owner =
|
const direction = describeBillPaymentDirection(bill, t);
|
||||||
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 locked = ["confirmed", "partial_paid", "settled", "overdue"].includes(bill.status);
|
const locked = ["confirmed", "partial_paid", "settled", "overdue"].includes(bill.status);
|
||||||
const ownerOwes = bill.net_amount > 0;
|
const paymentTitle = direction.ownerOwes
|
||||||
const paymentTitle = ownerOwes
|
? t("settlementBills.submitReceipt", { defaultValue: "登记收款" })
|
||||||
? t("settlementBills.recordReceipt", { defaultValue: "登记收款" })
|
: t("settlementBills.submitPayout", { defaultValue: "登记付款" });
|
||||||
: t("settlementBills.recordPayout", { defaultValue: "登记付款" });
|
const paymentSubmit = direction.ownerOwes
|
||||||
const paymentSubmit = ownerOwes
|
|
||||||
? t("settlementBills.submitReceipt", { defaultValue: "确认收款" })
|
? t("settlementBills.submitReceipt", { defaultValue: "确认收款" })
|
||||||
: t("settlementBills.submitPayout", { defaultValue: "确认付款" });
|
: t("settlementBills.submitPayout", { defaultValue: "确认付款" });
|
||||||
const canWriteOff =
|
const canWriteOff =
|
||||||
@@ -108,111 +86,138 @@ export function AgentBillDetail({
|
|||||||
bill.unpaid_amount > 0 &&
|
bill.unpaid_amount > 0 &&
|
||||||
["confirmed", "partial_paid", "overdue"].includes(bill.status) &&
|
["confirmed", "partial_paid", "overdue"].includes(bill.status) &&
|
||||||
!["adjustment", "reversal", "bad_debt"].includes(bill.bill_type);
|
!["adjustment", "reversal", "bad_debt"].includes(bill.bill_type);
|
||||||
const meta = parseBillMeta(bill.meta_json);
|
const rebateAllocationSummary = Object.values(
|
||||||
const hasSubtreeFields =
|
rebateAllocations.reduce<Record<string, { key: string; label: string; amount: number; rows: number }>>(
|
||||||
bill.gross_win_loss != null ||
|
(acc, row) => {
|
||||||
bill.rebate_amount != null ||
|
const label = row.participant_label ?? `${row.participant_type}#${row.participant_id}`;
|
||||||
bill.platform_rounding_adjustment != null ||
|
const key = `${row.participant_type}:${row.participant_id}:${row.allocation_rule}`;
|
||||||
meta.share_profit != null;
|
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 (
|
return (
|
||||||
<div className="space-y-4 text-sm">
|
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.35fr)_minmax(340px,0.95fr)]">
|
||||||
<div>
|
<div className="space-y-5 text-sm">
|
||||||
<span className="text-muted-foreground">{t("settlementBills.columns.party", { defaultValue: "本方" })}: </span>
|
<SettlementBillSummaryHeader bill={bill} currencyCode={currencyCode} />
|
||||||
{owner}
|
<SettlementBillPartiesRow bill={bill} />
|
||||||
</div>
|
<SettlementBillAmountBreakdown bill={bill} currencyCode={currencyCode} />
|
||||||
<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}
|
|
||||||
|
|
||||||
{payments.length > 0 ? (
|
{payments.length > 0 ? (
|
||||||
<div className="space-y-1 rounded-md border border-border/60 p-3">
|
<div className="space-y-2 rounded-xl border border-border/70 p-4">
|
||||||
<p className="font-medium">{t("settlementBills.paymentsHistory", { defaultValue: "收付记录" })}</p>
|
<p className="font-medium">
|
||||||
<ul className="space-y-1 text-muted-foreground">
|
{t("settlementBills.paymentsHistory", { defaultValue: "收付记录" })}
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-1.5 text-muted-foreground">
|
||||||
{payments.map((p) => (
|
{payments.map((p) => (
|
||||||
<li key={p.id}>
|
<li key={p.id} className="flex justify-between gap-2">
|
||||||
{formatDashboardMoneyMinor(p.amount, currencyCode)}
|
<span>
|
||||||
{p.method ? ` · ${p.method}` : ""}
|
{p.method
|
||||||
|
? `${p.method}`
|
||||||
|
: t("settlementCenter:billDisplay.payment", { defaultValue: "收付" })}
|
||||||
{p.remark ? ` · ${p.remark}` : ""}
|
{p.remark ? ` · ${p.remark}` : ""}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 tabular-nums">
|
||||||
|
{formatDashboardMoneyMinor(p.amount, currencyCode)}
|
||||||
|
</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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" ? (
|
{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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
className="w-full"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
void postSettlementBillConfirm(billId)
|
void postSettlementBillConfirm(billId)
|
||||||
.then(load)
|
.then(load)
|
||||||
@@ -222,27 +227,58 @@ export function AgentBillDetail({
|
|||||||
>
|
>
|
||||||
{t("settlementBills.confirm", { defaultValue: "确认账单" })}
|
{t("settlementBills.confirm", { defaultValue: "确认账单" })}
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{canManage && ["confirmed", "partial_paid", "overdue"].includes(bill.status) && bill.unpaid_amount > 0 ? (
|
{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>
|
<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">
|
<div className="space-y-1">
|
||||||
<Label>{t("settlementBills.paymentAmount", { defaultValue: "收付金额" })}</Label>
|
<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>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>{t("settlementBills.paymentMethod", { defaultValue: "方式" })}</Label>
|
<Label>{t("settlementBills.paymentMethod", { defaultValue: "收付方式" })}</Label>
|
||||||
<Input value={payMethod} onChange={(e) => setPayMethod(e.target.value)} placeholder="cash" />
|
<Input
|
||||||
|
value={payMethod}
|
||||||
|
onChange={(e) => setPayMethod(e.target.value)}
|
||||||
|
placeholder={t("settlementBills.paymentMethodPlaceholder", {
|
||||||
|
defaultValue: "例如:现金 / 银行转账",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 sm:col-span-2">
|
<div className="space-y-1">
|
||||||
<Label>{t("settlementBills.paymentProof", { defaultValue: "凭证/备注" })}</Label>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
className="w-full"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
void postSettlementBillPayment(billId, {
|
void postSettlementBillPayment(billId, {
|
||||||
amount: Number(payAmount),
|
amount: Number(payAmount),
|
||||||
@@ -260,15 +296,31 @@ export function AgentBillDetail({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{canWriteOff ? (
|
{canWriteOff ? (
|
||||||
<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">
|
||||||
<p className="font-medium">{t("settlementBills.badDebtWriteOff", { defaultValue: "坏账核销" })}</p>
|
<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">
|
<div className="space-y-1">
|
||||||
<Label>{t("settlementBills.badDebtReason", { defaultValue: "核销原因" })}</Label>
|
<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>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
|
className="w-full"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
void postSettlementBillBadDebtWriteOff(billId, {
|
void postSettlementBillBadDebtWriteOff(billId, {
|
||||||
reason: badDebtReason.trim() || undefined,
|
reason: badDebtReason.trim() || undefined,
|
||||||
@@ -286,21 +338,40 @@ export function AgentBillDetail({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{canManage && locked ? (
|
{canManage && locked ? (
|
||||||
<div className="space-y-2 rounded-md border border-dashed border-border/60 p-3">
|
<div className="space-y-3 rounded-xl border border-dashed border-border/70 p-4">
|
||||||
<p className="font-medium">{t("settlementBills.adjustment", { defaultValue: "补差/冲正单" })}</p>
|
<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">
|
<div className="space-y-1">
|
||||||
<Label>{t("settlementBills.adjustmentAmount", { defaultValue: "调整金额(可负)" })}</Label>
|
<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>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
void postSettlementBillAdjustment(billId, {
|
void postSettlementBillAdjustment(billId, {
|
||||||
amount: Number(adjustAmount),
|
amount: Number(adjustAmount),
|
||||||
reason: "manual_adjustment",
|
reason: "manual_adjustment",
|
||||||
})
|
})
|
||||||
.then(() => toast.success(t("settlementBills.adjustmentCreated", { defaultValue: "已创建补差单" })))
|
.then(() =>
|
||||||
|
toast.success(t("settlementBills.adjustmentCreated", { defaultValue: "已创建补差单" })),
|
||||||
|
)
|
||||||
.then(onUpdated)
|
.then(onUpdated)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -309,5 +380,6 @@ export function AgentBillDetail({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -27,23 +27,43 @@ export function AgentSettlementPeriodSelect({
|
|||||||
onChange,
|
onChange,
|
||||||
className,
|
className,
|
||||||
}: AgentSettlementPeriodSelectProps): React.ReactElement {
|
}: AgentSettlementPeriodSelectProps): React.ReactElement {
|
||||||
const { t } = useTranslation("agents");
|
const { t } = useTranslation(["agents", "settlementCenter"]);
|
||||||
|
|
||||||
const sorted = [...periods].sort((a, b) => b.id - a.id);
|
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 (
|
return (
|
||||||
<Select
|
<Select
|
||||||
|
modal={false}
|
||||||
value={value === "all" ? "all" : String(value)}
|
value={value === "all" ? "all" : String(value)}
|
||||||
onValueChange={(next) => {
|
onValueChange={(next) => {
|
||||||
onChange(next === "all" ? "all" : Number(next));
|
onChange(next === "all" ? "all" : Number(next));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className={className ?? "h-9 w-full max-w-md"}>
|
<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>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">
|
<SelectItem value="all">
|
||||||
{t("settlementBills.allPeriods", { defaultValue: "全部账期" })}
|
{t("settlementCenter:filters.allPeriods", {
|
||||||
|
defaultValue: t("agents:settlementBills.allPeriods", { defaultValue: "全部账期" }),
|
||||||
|
})}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
{sorted.map((row) => (
|
{sorted.map((row) => (
|
||||||
<SelectItem key={row.id} value={String(row.id)}>
|
<SelectItem key={row.id} value={String(row.id)}>
|
||||||
@@ -62,10 +82,13 @@ function periodStatusLabel(
|
|||||||
t: (key: string, opts?: { defaultValue?: string }) => string,
|
t: (key: string, opts?: { defaultValue?: string }) => string,
|
||||||
): string {
|
): string {
|
||||||
if (status === "open") {
|
if (status === "open") {
|
||||||
return t("settlementPeriods.statusOpen", { defaultValue: "进行中" });
|
return t("agents:settlementPeriods.statusOpen", { defaultValue: "进行中" });
|
||||||
}
|
}
|
||||||
if (status === "closed") {
|
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;
|
return status;
|
||||||
|
|||||||
@@ -65,22 +65,26 @@ export function AgentSettlementReportsPanel({
|
|||||||
void load();
|
void load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
|
const reportTypeLabel = (type: AgentSettlementReportType): string =>
|
||||||
|
t(`settlementReports.types.${type}`, { defaultValue: type });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 rounded-lg border border-border/60 p-4">
|
<div className="space-y-4 rounded-lg border border-border/60 p-4">
|
||||||
<div className="flex flex-wrap items-end gap-3">
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>{t("settlementReports.type", { defaultValue: "报表类型" })}</Label>
|
<Label>{t("settlementReports.type", { defaultValue: "报表类型" })}</Label>
|
||||||
<Select
|
<Select
|
||||||
|
modal={false}
|
||||||
value={reportType}
|
value={reportType}
|
||||||
onValueChange={(v) => setReportType(v as AgentSettlementReportType)}
|
onValueChange={(v) => setReportType(v as AgentSettlementReportType)}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-52">
|
<SelectTrigger className="w-52">
|
||||||
<SelectValue />
|
<SelectValue>{() => reportTypeLabel(reportType)}</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{REPORT_TYPES.map((key) => (
|
{REPORT_TYPES.map((key) => (
|
||||||
<SelectItem key={key} value={key}>
|
<SelectItem key={key} value={key}>
|
||||||
{t(`settlementReports.types.${key}`, { defaultValue: key })}
|
{reportTypeLabel(key)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|||||||
196
src/modules/settlement/settlement-bill-breakdown.tsx
Normal file
196
src/modules/settlement/settlement-bill-breakdown.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
320
src/modules/settlement/settlement-bill-display.ts
Normal file
320
src/modules/settlement/settlement-bill-display.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,23 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { ArrowRight, Eye } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type { SettlementBillRow } from "@/api/admin-agent-settlement";
|
import type { SettlementBillRow } from "@/api/admin-agent-settlement";
|
||||||
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
||||||
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||||||
|
import { 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 { formatSettlementPeriodSpan } from "@/lib/agent-settlement-period-range";
|
||||||
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
|
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 {
|
import {
|
||||||
settlementBillStatusLabel,
|
settlementBillStatusLabel,
|
||||||
settlementBillTypeLabel,
|
settlementBillTypeLabel,
|
||||||
@@ -21,27 +31,125 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
|
|
||||||
|
type BillTypeFilter = "all" | "player" | "agent";
|
||||||
|
|
||||||
type SettlementBillsTableProps = {
|
type SettlementBillsTableProps = {
|
||||||
rows: SettlementBillRow[];
|
rows: SettlementBillRow[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
currencyCode: string;
|
currencyCode: string;
|
||||||
|
billTypeFilter?: BillTypeFilter;
|
||||||
|
emptyMessage?: string;
|
||||||
onOpenDetail: (billId: number) => void;
|
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({
|
export function SettlementBillsTable({
|
||||||
rows,
|
rows,
|
||||||
loading,
|
loading,
|
||||||
currencyCode,
|
currencyCode,
|
||||||
|
billTypeFilter = "all",
|
||||||
|
emptyMessage,
|
||||||
onOpenDetail,
|
onOpenDetail,
|
||||||
}: SettlementBillsTableProps): React.ReactElement {
|
}: SettlementBillsTableProps): React.ReactElement {
|
||||||
const { t } = useTranslation(["settlementCenter", "agents", "common"]);
|
const { t } = useTranslation(["settlementCenter", "agents", "common"]);
|
||||||
|
|
||||||
|
const agentView = billTypeFilter === "agent";
|
||||||
|
const playerView = billTypeFilter === "player";
|
||||||
|
const mixedView = billTypeFilter === "all";
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <AdminLoadingState />;
|
return <AdminLoadingState />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
return <AdminNoResourceState />;
|
return <AdminNoResourceState message={emptyMessage} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -49,69 +157,153 @@ export function SettlementBillsTable({
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
|
<TableHead>{t("columns.billId", { defaultValue: "账单 ID" })}</TableHead>
|
||||||
<TableHead>{t("columns.period", { defaultValue: "账期" })}</TableHead>
|
<TableHead>{t("columns.period", { defaultValue: "账期" })}</TableHead>
|
||||||
<TableHead>{t("columns.type", { 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.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.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.paid", { defaultValue: "已收付" })}</TableHead>
|
||||||
<TableHead className="text-right">{t("columns.unpaid", { defaultValue: "未结" })}</TableHead>
|
<TableHead className="text-right">{t("columns.unpaid", { defaultValue: "未结" })}</TableHead>
|
||||||
<TableHead>{t("columns.status", { 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>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{rows.map((row) => (
|
{rows.map((row) => {
|
||||||
<TableRow key={row.id}>
|
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">
|
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||||
{formatSettlementPeriodSpan(row.period_start, row.period_end)}
|
{formatSettlementPeriodSpan(row.period_start, row.period_end)}
|
||||||
</TableCell>
|
</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>
|
<TableCell>
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
<span>{row.owner_label ?? `${row.owner_type}#${row.owner_id}`}</span>
|
<SettlementDashCell value={row.player_username ?? row.owner_label} />
|
||||||
{row.owner_type === "player" && row.owner_funding_mode ? (
|
{fundingModeHint(row, t)}
|
||||||
<PlayerFundingModeBadge
|
|
||||||
row={{
|
|
||||||
funding_mode: row.owner_funding_mode,
|
|
||||||
uses_credit: row.owner_funding_mode === "credit",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="font-mono text-xs">
|
||||||
{row.counterparty_label === "platform"
|
<SettlementDashCell
|
||||||
? t("agents:settlementBills.platform", { defaultValue: "平台" })
|
value={row.player_site_player_id ?? row.player_id_display ?? row.owner_id}
|
||||||
: row.counterparty_label ?? `${row.counterparty_type}#${row.counterparty_id}`}
|
mono
|
||||||
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right tabular-nums text-muted-foreground">
|
<TableCell className="text-sm">
|
||||||
{row.gross_win_loss != null
|
<SettlementDashCell value={row.direct_agent_label} />
|
||||||
? formatDashboardMoneyMinor(row.gross_win_loss, currencyCode)
|
|
||||||
: "—"}
|
|
||||||
</TableCell>
|
</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">
|
<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>
|
||||||
<TableCell className="text-right tabular-nums text-muted-foreground">
|
<TableCell className="text-right tabular-nums text-muted-foreground">
|
||||||
{formatDashboardMoneyMinor(row.paid_amount ?? 0, currencyCode)}
|
{formatDashboardMoneyMinor(row.paid_amount ?? 0, currencyCode)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right tabular-nums">
|
<TableCell className={cn("text-right tabular-nums", unpaidMoneyClass(row))}>
|
||||||
{formatDashboardMoneyMinor(row.unpaid_amount, currencyCode)}
|
{formatDashboardMoneyMinor(row.unpaid_amount, currencyCode)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{settlementBillStatusLabel(row.status, t)}</TableCell>
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<button
|
<AdminStatusBadge status={row.status}>
|
||||||
type="button"
|
{settlementBillStatusLabel(row.status, t)}
|
||||||
className="text-sm text-primary underline"
|
</AdminStatusBadge>
|
||||||
onClick={() => onOpenDetail(row.id)}
|
</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: "详情 / 收付" })}
|
<AdminRowActionsMenu
|
||||||
</button>
|
actions={[
|
||||||
|
{
|
||||||
|
key: "detail",
|
||||||
|
label: t("actions.detail", { defaultValue: "详情" }),
|
||||||
|
icon: Eye,
|
||||||
|
onClick: () => onOpenDetail(row.id),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
35
src/modules/settlement/settlement-center-nav.ts
Normal file
35
src/modules/settlement/settlement-center-nav.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
111
src/modules/settlement/settlement-center-period-detail.tsx
Normal file
111
src/modules/settlement/settlement-center-period-detail.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,30 +1,21 @@
|
|||||||
"use client";
|
"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 { useTranslation } from "react-i18next";
|
||||||
import { CalendarClock, CircleDollarSign, ClipboardCheck, Landmark } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { getSettlementPeriods, type SettlementPeriodRow } from "@/api/admin-agent-settlement";
|
import { getSettlementPeriods, type SettlementPeriodRow } from "@/api/admin-agent-settlement";
|
||||||
import { getAdminIntegrationSites } from "@/api/admin-integration-sites";
|
import { getAdminIntegrationSites } from "@/api/admin-integration-sites";
|
||||||
import { 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 { AgentBillDetail } from "@/modules/settlement/agent-bill-detail";
|
||||||
import { AgentPeriodsConsole } from "@/modules/settlement/agent-periods-console";
|
import { SettlementCenterPeriodDetail } from "@/modules/settlement/settlement-center-period-detail";
|
||||||
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
|
|
||||||
import type { AgentSettlementPeriodFilter } from "@/modules/settlement/agent-settlement-period-select";
|
|
||||||
import {
|
import {
|
||||||
SettlementCenterNav,
|
parseSettlementCenterView,
|
||||||
type SettlementCenterSection,
|
settlementPeriodViewHref,
|
||||||
|
type SettlementPeriodView,
|
||||||
} from "@/modules/settlement/settlement-center-nav";
|
} from "@/modules/settlement/settlement-center-nav";
|
||||||
import {
|
import { SettlementPeriodWorkbench } from "@/modules/settlement/settlement-period-workbench";
|
||||||
SettlementBillsPanel,
|
import { formatAdminSiteLabel } from "@/lib/admin-site-display";
|
||||||
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 { adminHasAnyPermission } from "@/lib/admin-permissions";
|
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||||
import { PRD_SETTLEMENT_AGENT_MANAGE } from "@/lib/admin-prd";
|
import { PRD_SETTLEMENT_AGENT_MANAGE } from "@/lib/admin-prd";
|
||||||
import {
|
import {
|
||||||
@@ -44,62 +35,33 @@ import { useAdminProfile } from "@/stores/admin-session";
|
|||||||
|
|
||||||
type SiteOption = { id: number; label: string; currency_code: string };
|
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 {
|
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 profile = useAdminProfile();
|
||||||
const boundAgent = profile?.agent ?? null;
|
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 ||
|
profile?.is_super_admin === true ||
|
||||||
adminHasAnyPermission(profile?.permissions, [PRD_SETTLEMENT_AGENT_MANAGE]);
|
adminHasAnyPermission(profile?.permissions, [PRD_SETTLEMENT_AGENT_MANAGE]);
|
||||||
|
const canManagePeriods = canOperateBills && boundAgent === null;
|
||||||
|
|
||||||
const [activeSection, setActiveSection] = useState<SettlementCenterSection>("overview");
|
|
||||||
const [siteOptions, setSiteOptions] = useState<SiteOption[]>([]);
|
const [siteOptions, setSiteOptions] = useState<SiteOption[]>([]);
|
||||||
const [adminSiteId, setAdminSiteId] = useState<number | null>(null);
|
const [adminSiteId, setAdminSiteId] = useState<number | null>(null);
|
||||||
const [periods, setPeriods] = useState<SettlementPeriodRow[]>([]);
|
const [periods, setPeriods] = useState<SettlementPeriodRow[]>([]);
|
||||||
const [periodFilter, setPeriodFilter] = useState<AgentSettlementPeriodFilter>("all");
|
const [periodsReady, setPeriodsReady] = useState(false);
|
||||||
const [periodFilterReady, setPeriodFilterReady] = useState(false);
|
|
||||||
const [detailBillId, setDetailBillId] = useState<number | null>(null);
|
const [detailBillId, setDetailBillId] = useState<number | null>(null);
|
||||||
const [billsInitialCategory, setBillsInitialCategory] = useState<BillCategory>("all");
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
const [listRevision, setListRevision] = useState(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (boundAgent?.admin_site_id) {
|
if (boundAgent?.admin_site_id) {
|
||||||
const label = boundAgent.name
|
const label = formatAdminSiteLabel(boundAgent.name, boundAgent.site_code ?? boundAgent.code);
|
||||||
? `${boundAgent.name} (${boundAgent.site_code || boundAgent.code})`
|
|
||||||
: boundAgent.code;
|
|
||||||
setSiteOptions([{ id: boundAgent.admin_site_id, label, currency_code: "NPR" }]);
|
setSiteOptions([{ id: boundAgent.admin_site_id, label, currency_code: "NPR" }]);
|
||||||
setAdminSiteId(boundAgent.admin_site_id);
|
setAdminSiteId(boundAgent.admin_site_id);
|
||||||
return;
|
return;
|
||||||
@@ -108,7 +70,7 @@ export function SettlementCenterShell(): React.ReactElement {
|
|||||||
void getAdminIntegrationSites().then((sites) => {
|
void getAdminIntegrationSites().then((sites) => {
|
||||||
const options = (sites.items ?? []).map((site) => ({
|
const options = (sites.items ?? []).map((site) => ({
|
||||||
id: site.id,
|
id: site.id,
|
||||||
label: site.name ? `${site.name} (${site.code})` : site.code,
|
label: formatAdminSiteLabel(site.name, site.code),
|
||||||
currency_code: site.currency_code ?? "NPR",
|
currency_code: site.currency_code ?? "NPR",
|
||||||
}));
|
}));
|
||||||
setSiteOptions(options);
|
setSiteOptions(options);
|
||||||
@@ -118,274 +80,67 @@ export function SettlementCenterShell(): React.ReactElement {
|
|||||||
});
|
});
|
||||||
}, [adminSiteId, boundAgent]);
|
}, [adminSiteId, boundAgent]);
|
||||||
|
|
||||||
const loadPeriods = useCallback(async () => {
|
const siteId = adminSiteId ?? siteOptions[0]?.id ?? null;
|
||||||
if (adminSiteId === null) {
|
const siteLabel = siteOptions.find((s) => s.id === siteId)?.label ?? null;
|
||||||
setPeriods([]);
|
const currency = siteOptions.find((s) => s.id === siteId)?.currency_code ?? "NPR";
|
||||||
return;
|
|
||||||
|
const loadPeriods = useCallback(async (): Promise<SettlementPeriodRow[]> => {
|
||||||
|
if (siteId === null) {
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const data = await getSettlementPeriods({ admin_site_id: adminSiteId });
|
const data = await getSettlementPeriods({ admin_site_id: siteId });
|
||||||
setPeriods(data.items ?? []);
|
const items = data.items ?? [];
|
||||||
|
setPeriods(items);
|
||||||
|
setPeriodsReady(true);
|
||||||
|
return items;
|
||||||
} catch {
|
} catch {
|
||||||
setPeriods([]);
|
setPeriods([]);
|
||||||
toast.error(t("periods.loadFailed", { defaultValue: "账期列表加载失败" }));
|
setPeriodsReady(true);
|
||||||
|
toast.error(t("periods.loadFailed", { defaultValue: "账期加载失败" }));
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
}, [adminSiteId, t]);
|
}, [siteId, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (canManagePeriods || adminSiteId === null) {
|
setPeriodsReady(false);
|
||||||
return;
|
|
||||||
}
|
|
||||||
void loadPeriods();
|
void loadPeriods();
|
||||||
}, [adminSiteId, canManagePeriods, loadPeriods]);
|
}, [loadPeriods]);
|
||||||
|
|
||||||
const handlePeriodsChange = useCallback((items: SettlementPeriodRow[]) => {
|
const activePeriod =
|
||||||
setPeriods(items);
|
activePeriodId !== null ? (periods.find((row) => row.id === activePeriodId) ?? null) : null;
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const openPeriodView = (periodId: number, view: SettlementPeriodView): void => {
|
||||||
if (periodFilterReady || adminSiteId === null) {
|
router.push(settlementPeriodViewHref(periodId, view));
|
||||||
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 overviewStats = [
|
const isListMode = activePeriodId === null;
|
||||||
{
|
|
||||||
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 />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
|
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4">
|
||||||
<header className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="space-y-2">
|
<div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<h1 className="text-xl font-semibold tracking-tight">
|
<h1 className="text-xl font-semibold tracking-tight">
|
||||||
{t("title", { defaultValue: "结算中心" })}
|
{t("title", { defaultValue: "结算中心" })}
|
||||||
</h1>
|
</h1>
|
||||||
<AdminStatusBadge
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
status={openPeriod ? "processing" : allPeriodsCompleted ? "completed" : "idle"}
|
{isListMode
|
||||||
>
|
? t("subtitleList", { defaultValue: "账期列表:开账、关账,从行操作进入账单与报表。" })
|
||||||
{openPeriod
|
: t("subtitle", { defaultValue: "账期关账、账单确认与收付登记" })}
|
||||||
? 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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{siteOptions.length <= 1 && selectedSiteLabel ? (
|
|
||||||
<p className="text-sm text-muted-foreground">{selectedSiteLabel}</p>
|
|
||||||
) : null}
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{adminSiteId === null ? (
|
{siteOptions.length >= 1 && siteId !== 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 ? (
|
|
||||||
<Select
|
<Select
|
||||||
value={String(selectSiteId)}
|
value={String(siteId)}
|
||||||
onValueChange={(value) => {
|
onValueChange={(v) => {
|
||||||
setAdminSiteId(Number(value));
|
setAdminSiteId(Number(v));
|
||||||
setPeriodFilter("all");
|
setPeriodsReady(false);
|
||||||
setPeriodFilterReady(false);
|
router.push("/admin/settlement-center");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-9 w-[220px] bg-background">
|
<SelectTrigger className="h-9 w-[220px]">
|
||||||
<SelectValue>{selectedSiteLabel}</SelectValue>
|
<SelectValue>{siteLabel}</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{siteOptions.map((site) => (
|
{siteOptions.map((site) => (
|
||||||
@@ -395,42 +150,70 @@ export function SettlementCenterShell(): React.ReactElement {
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
) : null
|
) : null}
|
||||||
}
|
</div>
|
||||||
/>
|
|
||||||
|
|
||||||
{showPeriodToolbar && periodFilterReady ? (
|
{siteId === null || !periodsReady ? (
|
||||||
<SettlementPeriodToolbar
|
<p className="text-sm text-muted-foreground">{t("empty.noSite", { defaultValue: "请选择站点。" })}</p>
|
||||||
|
) : isListMode ? (
|
||||||
|
<SettlementPeriodWorkbench
|
||||||
|
adminSiteId={siteId}
|
||||||
|
currencyCode={currency}
|
||||||
|
canManage={canManagePeriods}
|
||||||
periods={periods}
|
periods={periods}
|
||||||
value={periodFilter}
|
onViewDetail={(id) => openPeriodView(id, "bills")}
|
||||||
onChange={(next) => {
|
onReloadPeriods={loadPeriods}
|
||||||
setPeriodFilter(next);
|
onPeriodOpened={() => {
|
||||||
setPeriodFilterReady(true);
|
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}
|
) : activePeriod === null ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
<AdminPageCard title={panelTitle}>{renderMainPanel()}</AdminPageCard>
|
{t("periodDetail.notFound", { defaultValue: "账期不存在或已切换站点,请返回列表。" })}
|
||||||
</div>
|
</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)}>
|
<Dialog open={detailBillId !== null} onOpenChange={(open) => !open && setDetailBillId(null)}>
|
||||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
|
<DialogContent
|
||||||
<DialogHeader>
|
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"
|
||||||
<DialogTitle>
|
>
|
||||||
{t("actions.billDetail", { defaultValue: "账单详情 · 确认 / 收付" })}
|
<DialogHeader className="border-b px-6 py-4">
|
||||||
</DialogTitle>
|
<DialogTitle>{t("actions.billDetail", { defaultValue: "账单详情" })}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{detailBillId !== null ? (
|
{detailBillId !== null ? (
|
||||||
|
<div className="min-h-0 overflow-y-auto px-6 py-5">
|
||||||
<AgentBillDetail
|
<AgentBillDetail
|
||||||
billId={detailBillId}
|
billId={detailBillId}
|
||||||
currencyCode={activeCurrency}
|
currencyCode={currency}
|
||||||
canManage={canManagePeriods}
|
canManage={canOperateBills}
|
||||||
onUpdated={() => {
|
onUpdated={() => {
|
||||||
void loadPeriods();
|
void loadPeriods();
|
||||||
setListRevision((n) => n + 1);
|
setRefreshKey((n) => n + 1);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
361
src/modules/settlement/settlement-credit-ledger-panel.tsx
Normal file
361
src/modules/settlement/settlement-credit-ledger-panel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
342
src/modules/settlement/settlement-main-panel.tsx
Normal file
342
src/modules/settlement/settlement-main-panel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
src/modules/settlement/settlement-party-cells.tsx
Normal file
35
src/modules/settlement/settlement-party-cells.tsx
Normal 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() || "—";
|
||||||
|
}
|
||||||
508
src/modules/settlement/settlement-period-workbench.tsx
Normal file
508
src/modules/settlement/settlement-period-workbench.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
169
src/modules/settlement/settlement-periods-table.tsx
Normal file
169
src/modules/settlement/settlement-periods-table.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
src/modules/settlement/settlement-signed-money.ts
Normal file
13
src/modules/settlement/settlement-signed-money.ts
Normal 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";
|
||||||
|
}
|
||||||
@@ -52,6 +52,13 @@ export function creditLedgerReasonLabel(
|
|||||||
reason: string,
|
reason: string,
|
||||||
t: TFunction<"settlementCenter">,
|
t: TFunction<"settlementCenter">,
|
||||||
): string {
|
): 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;
|
const key = `creditLedger.reason.${reason}` as const;
|
||||||
return t(key, { defaultValue: reason });
|
return t(key, { defaultValue: reason });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
|||||||
import { PRD_SETTLEMENT_AGENT_ACCESS_ANY } from "@/lib/admin-prd";
|
import { PRD_SETTLEMENT_AGENT_ACCESS_ANY } from "@/lib/admin-prd";
|
||||||
import { useAdminProfile } from "@/stores/admin-session";
|
import { useAdminProfile } from "@/stores/admin-session";
|
||||||
|
|
||||||
/** 钱包模块仅服务主站钱包玩家;信用盘流水在结算中心。 */
|
/** 钱包模块仅服务主站钱包玩家;信用盘结账在结算中心。 */
|
||||||
export function WalletScopeHint(): React.ReactElement {
|
export function WalletScopeHint(): React.ReactElement {
|
||||||
const { t } = useTranslation("wallet");
|
const { t } = useTranslation("wallet");
|
||||||
const profile = useAdminProfile();
|
const profile = useAdminProfile();
|
||||||
@@ -23,11 +23,11 @@ export function WalletScopeHint(): React.ReactElement {
|
|||||||
})}
|
})}
|
||||||
{canSettlement ? (
|
{canSettlement ? (
|
||||||
<Link href="/admin/settlement-center" className="mx-1 text-primary underline">
|
<Link href="/admin/settlement-center" className="mx-1 text-primary underline">
|
||||||
{t("scopeHintSettlementLink", { defaultValue: "结算中心 → 信用流水" })}
|
{t("scopeHintSettlementLink", { defaultValue: "结算中心" })}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<span className="mx-1 font-medium text-foreground">
|
<span className="mx-1 font-medium text-foreground">
|
||||||
{t("scopeHintSettlement", { defaultValue: "结算中心 → 信用流水" })}
|
{t("scopeHintSettlement", { defaultValue: "结算中心" })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
。
|
。
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { AdminSubnav, AdminSubnavLink } from "@/components/admin/admin-subnav";
|
||||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { useAdminProfile } from "@/stores/admin-session";
|
import { useAdminProfile } from "@/stores/admin-session";
|
||||||
|
|
||||||
const RECONCILE_PERMS = [
|
const RECONCILE_PERMS = [
|
||||||
@@ -26,44 +25,23 @@ export function WalletSubnav(): React.ReactElement {
|
|||||||
const perms = profile?.permissions;
|
const perms = profile?.permissions;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<AdminSubnav aria-label={t("subnavLabel")}>
|
||||||
aria-label={t("subnavLabel")}
|
|
||||||
className="flex w-full flex-wrap items-end gap-1 border-b border-border/60 px-1"
|
|
||||||
>
|
|
||||||
{tabs.map((tab) => {
|
{tabs.map((tab) => {
|
||||||
const allowed = adminHasAnyPermission(perms, [...tab.requiredAny]);
|
const allowed = adminHasAnyPermission(perms, [...tab.requiredAny]);
|
||||||
const active = pathname === tab.href || pathname.startsWith(`${tab.href}/`);
|
const active = pathname === tab.href || pathname.startsWith(`${tab.href}/`);
|
||||||
|
|
||||||
if (!allowed) {
|
|
||||||
return (
|
return (
|
||||||
<span
|
<AdminSubnavLink
|
||||||
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
|
|
||||||
key={tab.href}
|
key={tab.href}
|
||||||
href={tab.href}
|
href={tab.href}
|
||||||
className={cn(
|
active={active}
|
||||||
"border-b-2 px-4 py-3 text-sm font-medium transition-colors",
|
disabled={!allowed}
|
||||||
active
|
disabledTitle={t("noPermission")}
|
||||||
? "border-primary text-primary"
|
|
||||||
: "border-transparent text-muted-foreground hover:border-border/80 hover:text-foreground",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{t(tab.label)}
|
{t(tab.label)}
|
||||||
</Link>
|
</AdminSubnavLink>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</AdminSubnav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,16 +5,24 @@ export type AdminPlayerTicketItemRow = {
|
|||||||
currency_code: string | null;
|
currency_code: string | null;
|
||||||
play_code: string;
|
play_code: string;
|
||||||
original_number: string | null;
|
original_number: string | null;
|
||||||
|
/** @deprecated 历史字段,单位为 minor;优先使用 total_bet_amount_minor */
|
||||||
total_bet_amount: number;
|
total_bet_amount: number;
|
||||||
|
total_bet_amount_minor: number;
|
||||||
total_bet_amount_formatted: string;
|
total_bet_amount_formatted: string;
|
||||||
|
/** @deprecated 历史字段,单位为 minor;优先使用 actual_deduct_amount_minor */
|
||||||
actual_deduct_amount: number;
|
actual_deduct_amount: number;
|
||||||
|
actual_deduct_amount_minor: number;
|
||||||
actual_deduct_amount_formatted: string;
|
actual_deduct_amount_formatted: string;
|
||||||
status: string;
|
status: string;
|
||||||
fail_reason_code: string | null;
|
fail_reason_code: string | null;
|
||||||
fail_reason_text: string | null;
|
fail_reason_text: string | null;
|
||||||
|
/** @deprecated 历史字段,单位为 minor;优先使用 win_amount_minor */
|
||||||
win_amount: number;
|
win_amount: number;
|
||||||
|
win_amount_minor: number;
|
||||||
win_amount_formatted: string;
|
win_amount_formatted: string;
|
||||||
|
/** @deprecated 历史字段,单位为 minor;优先使用 jackpot_win_amount_minor */
|
||||||
jackpot_win_amount: number;
|
jackpot_win_amount: number;
|
||||||
|
jackpot_win_amount_minor: number;
|
||||||
jackpot_win_amount_formatted: string;
|
jackpot_win_amount_formatted: string;
|
||||||
placed_at: string | null;
|
placed_at: string | null;
|
||||||
updated_at: string | null;
|
updated_at: string | null;
|
||||||
|
|||||||
@@ -11,11 +11,21 @@ export type AdminSettlementBatchRow = {
|
|||||||
paid_at: string | null;
|
paid_at: string | null;
|
||||||
total_ticket_count: number;
|
total_ticket_count: number;
|
||||||
total_win_count: number;
|
total_win_count: number;
|
||||||
|
/** @deprecated 历史字段,单位为 minor;优先使用 total_bet_amount_minor */
|
||||||
total_bet_amount: number;
|
total_bet_amount: number;
|
||||||
|
total_bet_amount_minor: number;
|
||||||
|
/** @deprecated 历史字段,单位为 minor;优先使用 total_actual_deduct_minor */
|
||||||
total_actual_deduct: number;
|
total_actual_deduct: number;
|
||||||
|
total_actual_deduct_minor: number;
|
||||||
|
/** @deprecated 历史字段,单位为 minor;优先使用 total_payout_amount_minor */
|
||||||
total_payout_amount: number;
|
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: number;
|
||||||
|
total_jackpot_payout_amount_minor: number;
|
||||||
|
/** @deprecated 历史字段,单位为 minor;优先使用 platform_profit_minor */
|
||||||
platform_profit: number;
|
platform_profit: number;
|
||||||
|
platform_profit_minor: number;
|
||||||
started_at: string | null;
|
started_at: string | null;
|
||||||
finished_at: string | null;
|
finished_at: string | null;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
@@ -49,11 +59,21 @@ export type AdminSettlementBatchShowData = {
|
|||||||
paid_at: string | null;
|
paid_at: string | null;
|
||||||
total_ticket_count: number;
|
total_ticket_count: number;
|
||||||
total_win_count: number;
|
total_win_count: number;
|
||||||
|
/** @deprecated 历史字段,单位为 minor;优先使用 total_bet_amount_minor */
|
||||||
total_bet_amount: number;
|
total_bet_amount: number;
|
||||||
|
total_bet_amount_minor: number;
|
||||||
|
/** @deprecated 历史字段,单位为 minor;优先使用 total_actual_deduct_minor */
|
||||||
total_actual_deduct: number;
|
total_actual_deduct: number;
|
||||||
|
total_actual_deduct_minor: number;
|
||||||
|
/** @deprecated 历史字段,单位为 minor;优先使用 total_payout_amount_minor */
|
||||||
total_payout_amount: number;
|
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: number;
|
||||||
|
total_jackpot_payout_amount_minor: number;
|
||||||
|
/** @deprecated 历史字段,单位为 minor;优先使用 platform_profit_minor */
|
||||||
platform_profit: number;
|
platform_profit: number;
|
||||||
|
platform_profit_minor: number;
|
||||||
started_at: string | null;
|
started_at: string | null;
|
||||||
finished_at: string | null;
|
finished_at: string | null;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
|
|||||||
@@ -14,16 +14,24 @@ export type AdminTicketItemRow = {
|
|||||||
currency_code: string | null;
|
currency_code: string | null;
|
||||||
play_code: string;
|
play_code: string;
|
||||||
original_number: string | null;
|
original_number: string | null;
|
||||||
|
/** @deprecated 历史字段,单位为 minor;优先使用 total_bet_amount_minor */
|
||||||
total_bet_amount: number;
|
total_bet_amount: number;
|
||||||
|
total_bet_amount_minor: number;
|
||||||
total_bet_amount_formatted: string;
|
total_bet_amount_formatted: string;
|
||||||
|
/** @deprecated 历史字段,单位为 minor;优先使用 actual_deduct_amount_minor */
|
||||||
actual_deduct_amount: number;
|
actual_deduct_amount: number;
|
||||||
|
actual_deduct_amount_minor: number;
|
||||||
actual_deduct_amount_formatted: string;
|
actual_deduct_amount_formatted: string;
|
||||||
status: string;
|
status: string;
|
||||||
fail_reason_code: string | null;
|
fail_reason_code: string | null;
|
||||||
fail_reason_text: string | null;
|
fail_reason_text: string | null;
|
||||||
|
/** @deprecated 历史字段,单位为 minor;优先使用 win_amount_minor */
|
||||||
win_amount: number;
|
win_amount: number;
|
||||||
|
win_amount_minor: number;
|
||||||
win_amount_formatted: string;
|
win_amount_formatted: string;
|
||||||
|
/** @deprecated 历史字段,单位为 minor;优先使用 jackpot_win_amount_minor */
|
||||||
jackpot_win_amount: number;
|
jackpot_win_amount: number;
|
||||||
|
jackpot_win_amount_minor: number;
|
||||||
jackpot_win_amount_formatted: string;
|
jackpot_win_amount_formatted: string;
|
||||||
placed_at: string | null;
|
placed_at: string | null;
|
||||||
updated_at: string | null;
|
updated_at: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user