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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,18 +1,21 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
AdminSubnav,
AdminSubnavBar,
AdminSubnavLink,
} from "@/components/admin/admin-subnav";
import { useAdminSiteCodeOptions } from "@/hooks/use-admin-site-code-options";
import { isAgentLineSubnavTabVisible } from "@/modules/agents/agent-line-subnav-visibility";
import { cn } from "@/lib/utils";
import { useAdminProfile } from "@/stores/admin-session";
import { useAgentManagementSiteStore } from "@/stores/agent-management-site";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { PRD_INTEGRATION_ACCESS_ANY } from "@/lib/admin-prd";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useAdminProfile } from "@/stores/admin-session";
import { useAgentManagementSiteStore } from "@/stores/agent-management-site";
const primaryTabs: {
href: string;
@@ -86,68 +89,49 @@ export function AgentsSubnav(): React.ReactElement {
return <></>;
}
return (
<div className="flex w-full flex-wrap items-center justify-between gap-3 rounded-lg bg-muted/50 p-1">
<nav
aria-label={t("subnav.label", { defaultValue: "代理管理导航" })}
className="inline-flex max-w-full flex-wrap items-center gap-1"
const siteSelector =
canSwitchSite && siteOptions.length > 0 && selectSiteId !== null ? (
<Select
value={String(selectSiteId)}
onValueChange={(value) => setAdminSiteId(Number(value))}
>
{visiblePrimaryTabs.map((tab) => {
const active = isTabActive(pathname, tab.href, tab.matchPrefix);
<SelectTrigger className="h-9 w-[200px] bg-background">
<SelectValue placeholder={t("lineFilter", { defaultValue: "一级代理" })}>
{selectedSiteLabel ?? t("lineFilter", { defaultValue: "一级代理" })}
</SelectValue>
</SelectTrigger>
<SelectContent>
{siteOptions.map((site) => (
<SelectItem key={site.id} value={String(site.id)}>
{site.name} ({site.code})
</SelectItem>
))}
</SelectContent>
</Select>
) : null;
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>
);
})}
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 ? (
<>
<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>
</>
<AdminSubnavLink
href={provisionTab.href}
active={isTabActive(pathname, provisionTab.href, provisionTab.matchPrefix)}
>
{t(provisionTab.labelKey)}
</AdminSubnavLink>
) : null}
</nav>
{canSwitchSite && siteOptions.length > 0 && selectSiteId !== null ? (
<Select
value={String(selectSiteId)}
onValueChange={(value) => setAdminSiteId(Number(value))}
>
<SelectTrigger className="h-9 w-[200px] bg-background">
<SelectValue placeholder={t("lineFilter", { defaultValue: "一级代理" })}>
{selectedSiteLabel ?? t("lineFilter", { defaultValue: "一级代理" })}
</SelectValue>
</SelectTrigger>
<SelectContent>
{siteOptions.map((site) => (
<SelectItem key={site.id} value={String(site.id)}>
{site.name} ({site.code})
</SelectItem>
))}
</SelectContent>
</Select>
) : null}
</div>
</AdminSubnav>
</AdminSubnavBar>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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