feat(agents, i18n): enhance agent management and settlement features with new translations and UI updates

Added new translations for agent management and settlement features in English, Nepali, and Chinese, improving multi-language support. Updated the agents console to reflect changes in funding modes and player details, enhancing user experience. Refactored the admin permission gate to include new logic for handling bound line agents, ensuring better permission management. Additionally, streamlined the UI for agent-related pages and improved navigation to the settlement center, consolidating related functionalities for better accessibility.
This commit is contained in:
2026-06-04 18:01:05 +08:00
parent c2eac2fafc
commit 65eaeecf8c
139 changed files with 8852 additions and 1435 deletions

View File

@@ -0,0 +1,313 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
getSettlementBill,
postSettlementBillAdjustment,
postSettlementBillBadDebtWriteOff,
postSettlementBillConfirm,
postSettlementBillPayment,
type RebateAllocationRow,
type SettlementBillRow,
type SettlementPaymentRow,
} from "@/api/admin-agent-settlement";
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
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";
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;
canManage?: boolean;
onUpdated?: () => void;
};
export function AgentBillDetail({
billId,
currencyCode,
canManage = true,
onUpdated,
}: AgentBillDetailProps): React.ReactElement {
const { t } = useTranslation(["agents", "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 load = useCallback(async () => {
setLoading(true);
try {
const data = await getSettlementBill(billId);
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);
}
}, [billId]);
useEffect(() => {
void load();
}, [load]);
if (loading || !bill) {
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 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
? t("settlementBills.submitReceipt", { defaultValue: "确认收款" })
: t("settlementBills.submitPayout", { defaultValue: "确认付款" });
const canWriteOff =
canManage &&
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;
return (
<div className="space-y-4 text-sm">
<div>
<span className="text-muted-foreground">{t("settlementBills.columns.party", { defaultValue: "本方" })}: </span>
{owner}
</div>
<div>
<span className="text-muted-foreground">
{t("settlementBills.columns.counterparty", { defaultValue: "对方" })}:{" "}
</span>
{counterparty}
</div>
<div>
<span className="text-muted-foreground">{t("settlementBills.columns.type", { defaultValue: "类型" })}: </span>
{bill.bill_type} / {bill.status}
{tierEdge ? ` · ${tierEdge}` : ""}
</div>
{hasSubtreeFields ? (
<div className="space-y-1 rounded-md border border-border/60 p-3">
<p className="font-medium">
{t("settlementBills.subtreeSummary", { defaultValue: "子树汇总" })}
</p>
{bill.gross_win_loss != null ? (
<div>
<span className="text-muted-foreground">
{t("settlementBills.grossWinLoss", { defaultValue: "输赢 (gross_win_loss)" })}:{" "}
</span>
{formatDashboardMoneyMinor(bill.gross_win_loss, currencyCode)}
</div>
) : null}
{bill.rebate_amount != null ? (
<div>
<span className="text-muted-foreground">
{t("settlementBills.rebateAmount", { defaultValue: "回水" })}:{" "}
</span>
{formatDashboardMoneyMinor(bill.rebate_amount, currencyCode)}
</div>
) : null}
{meta.share_profit != null ? (
<div>
<span className="text-muted-foreground">
{t("settlementBills.shareProfit", { defaultValue: "占成利润" })}:{" "}
</span>
{formatDashboardMoneyMinor(meta.share_profit, currencyCode)}
</div>
) : null}
{bill.platform_rounding_adjustment != null && bill.platform_rounding_adjustment !== 0 ? (
<div>
<span className="text-muted-foreground">
{t("settlementBills.platformRounding", { defaultValue: "平台尾差" })}:{" "}
</span>
{formatDashboardMoneyMinor(bill.platform_rounding_adjustment, currencyCode)}
</div>
) : null}
</div>
) : null}
<div>
<span className="text-muted-foreground">{t("settlementBills.columns.net", { defaultValue: "净额" })}: </span>
{formatDashboardMoneyMinor(bill.net_amount, currencyCode)}
</div>
<div>
<span className="text-muted-foreground">{t("settlementBills.columns.unpaid", { defaultValue: "未结" })}: </span>
{formatDashboardMoneyMinor(bill.unpaid_amount, currencyCode)}
</div>
{rebateAllocations.length > 0 ? (
<div className="space-y-1 rounded-md border border-border/60 p-3">
<p className="font-medium">{t("settlementBills.rebateAllocations", { defaultValue: "回水分摊" })}</p>
<ul className="space-y-1 text-muted-foreground">
{rebateAllocations.map((row) => (
<li key={row.id}>
{row.participant_type}#{row.participant_id} · {row.allocation_rule} ·{" "}
{formatDashboardMoneyMinor(row.allocated_amount, currencyCode)}
</li>
))}
</ul>
</div>
) : null}
{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">
<div className="space-y-1">
<Label>{t("settlementBills.paymentAmount", { defaultValue: "收付金额" })}</Label>
<Input value={payAmount} onChange={(e) => setPayAmount(e.target.value)} />
</div>
<div className="space-y-1">
<Label>{t("settlementBills.paymentMethod", { defaultValue: "方式" })}</Label>
<Input value={payMethod} onChange={(e) => setPayMethod(e.target.value)} placeholder="cash" />
</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)} />
</div>
</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}
</div>
);
}

View File

@@ -1,79 +1,8 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { AdminPageCard } from "@/components/admin/admin-page-card";
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { adminRequest } from "@/lib/admin-http";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
type BillRow = {
id: number;
bill_type: string;
net_amount: number;
unpaid_amount: number;
status: string;
};
import { SettlementCenterShell } from "@/modules/settlement/settlement-center-shell";
/** 兼容旧引用:结算中心完整表格化界面 */
export function AgentBillsConsole(): React.ReactElement {
const { t } = useTranslation(["agents", "common"]);
const [rows, setRows] = useState<BillRow[]>([]);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
setLoading(true);
try {
const data = await adminRequest.get<{ items: BillRow[] }>("/admin/settlement-bills");
setRows(data.items ?? []);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
return (
<AdminPageCard title={t("agents:settlementBills.title", { defaultValue: "代理账单" })}>
{loading ? (
<AdminLoadingState />
) : rows.length === 0 ? (
<p className="text-sm text-muted-foreground">
{t("common:states.noData", { defaultValue: "暂无数据" })}
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("agents:settlementBills.columns.id", { defaultValue: "ID" })}</TableHead>
<TableHead>{t("agents:settlementBills.columns.type", { defaultValue: "类型" })}</TableHead>
<TableHead>{t("agents:settlementBills.columns.net", { defaultValue: "净额" })}</TableHead>
<TableHead>{t("agents:settlementBills.columns.unpaid", { defaultValue: "未结" })}</TableHead>
<TableHead>{t("agents:settlementBills.columns.status", { defaultValue: "状态" })}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.id}>
<TableCell>{row.id}</TableCell>
<TableCell>{row.bill_type}</TableCell>
<TableCell>{row.net_amount}</TableCell>
<TableCell>{row.unpaid_amount}</TableCell>
<TableCell>{row.status}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</AdminPageCard>
);
return <SettlementCenterShell />;
}

View File

@@ -0,0 +1,307 @@
"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

@@ -0,0 +1,72 @@
"use client";
import { useTranslation } from "react-i18next";
import { formatSettlementPeriodSpan } from "@/lib/agent-settlement-period-range";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { SettlementPeriodRow } from "@/api/admin-agent-settlement";
export type AgentSettlementPeriodFilter = number | "all";
type AgentSettlementPeriodSelectProps = {
periods: SettlementPeriodRow[];
value: AgentSettlementPeriodFilter;
onChange: (value: AgentSettlementPeriodFilter) => void;
className?: string;
};
export function AgentSettlementPeriodSelect({
periods,
value,
onChange,
className,
}: AgentSettlementPeriodSelectProps): React.ReactElement {
const { t } = useTranslation("agents");
const sorted = [...periods].sort((a, b) => b.id - a.id);
return (
<Select
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: "选择账期" })} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{t("settlementBills.allPeriods", { defaultValue: "全部账期" })}
</SelectItem>
{sorted.map((row) => (
<SelectItem key={row.id} value={String(row.id)}>
{formatSettlementPeriodSpan(row.period_start, row.period_end)}
{" · "}
{periodStatusLabel(row.status, t)}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
function periodStatusLabel(
status: string,
t: (key: string, opts?: { defaultValue?: string }) => string,
): string {
if (status === "open") {
return t("settlementPeriods.statusOpen", { defaultValue: "进行中" });
}
if (status === "closed") {
return t("settlementPeriods.statusClosed", { defaultValue: "已关账" });
}
return status;
}

View File

@@ -0,0 +1,286 @@
"use client";
import { AdminNoResourceState, AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { useTranslation } from "react-i18next";
import { formatDashboardCreditMajor, formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { AgentSettlementReportType } from "@/api/admin-agent-settlement";
type AgentSettlementReportViewProps = {
reportType: AgentSettlementReportType;
data: unknown;
currencyCode: string;
};
function asRecord(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function asRows(value: unknown): Record<string, unknown>[] {
if (!Array.isArray(value)) {
return [];
}
return value.filter((row): row is Record<string, unknown> => row !== null && typeof row === "object");
}
function money(
value: unknown,
currencyCode: string,
): string {
return formatDashboardMoneyMinor(Number(value ?? 0), currencyCode);
}
function creditMoney(value: unknown, currencyCode: string): string {
return formatDashboardCreditMajor(Number(value ?? 0), currencyCode);
}
export function AgentSettlementReportView({
reportType,
data,
currencyCode,
}: AgentSettlementReportViewProps): React.ReactElement {
const { t } = useTranslation(["agents", "common"]);
const root = asRecord(data);
if (reportType === "summary" && root) {
const stats = [
{ label: t("settlementReports.summary.billCount", { defaultValue: "账单数" }), value: String(root.bill_count ?? 0) },
{ label: t("settlementReports.summary.totalNet", { defaultValue: "净额合计" }), value: money(root.total_net, currencyCode) },
{ label: t("settlementReports.summary.totalUnpaid", { defaultValue: "未结合计" }), value: money(root.total_unpaid, currencyCode) },
{ label: t("settlementReports.summary.overdueCount", { defaultValue: "逾期账单" }), value: String(root.overdue_count ?? 0) },
{
label: t("settlementReports.summary.platformRounding", { defaultValue: "平台尾差合计" }),
value: money(root.platform_rounding_total, currencyCode),
},
];
return (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{stats.map((item) => (
<div key={item.label} className="rounded-md border border-border/60 px-3 py-2">
<div className="text-xs text-muted-foreground">{item.label}</div>
<div className="mt-1 text-sm font-semibold tabular-nums">{item.value}</div>
</div>
))}
</div>
);
}
if (reportType === "rebate" && root) {
const byType = asRows(root.by_type);
return (
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{[
["accrued_total", t("settlementReports.rebate.accrued", { defaultValue: "应计" })],
["in_bill_total", t("settlementReports.rebate.inBill", { defaultValue: "已入账单" })],
["settled_total", t("settlementReports.rebate.settled", { defaultValue: "已结算" })],
["allocated_total", t("settlementReports.rebate.allocated", { defaultValue: "已分摊" })],
].map(([key, label]) => (
<div key={key} className="rounded-md border border-border/60 px-3 py-2">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="mt-1 text-sm font-semibold tabular-nums">{money(root[key], currencyCode)}</div>
</div>
))}
</div>
{byType.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("settlementReports.columns.rebateType", { defaultValue: "类型" })}</TableHead>
<TableHead>{t("settlementReports.columns.status", { defaultValue: "状态" })}</TableHead>
<TableHead className="text-right">{t("settlementReports.columns.amount", { defaultValue: "金额" })}</TableHead>
<TableHead className="text-right">{t("settlementReports.columns.count", { defaultValue: "笔数" })}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{byType.map((row, idx) => (
<TableRow key={`${row.rebate_type}-${row.status}-${idx}`}>
<TableCell>{String(row.rebate_type ?? "")}</TableCell>
<TableCell>{String(row.status ?? "")}</TableCell>
<TableCell className="text-right tabular-nums">{money(row.total, currencyCode)}</TableCell>
<TableCell className="text-right tabular-nums">{String(row.count ?? 0)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : null}
</div>
);
}
if (reportType === "credit" && root) {
const agents = asRows(root.agents);
const players = asRows(root.players);
return (
<div className="space-y-6">
<div>
<p className="mb-2 text-sm font-medium">{t("settlementReports.credit.agents", { defaultValue: "代理授信" })}</p>
<ReportTable
rows={agents}
columns={[
{ key: "code", header: t("settlementReports.columns.code", { defaultValue: "编码" }) },
{ key: "name", header: t("settlementReports.columns.name", { defaultValue: "名称" }) },
{ key: "credit_limit", header: t("settlementReports.columns.creditLimit", { defaultValue: "授信" }), creditMajor: true },
{ key: "allocated_credit", header: t("settlementReports.columns.allocated", { defaultValue: "已下发" }), creditMajor: true },
{ key: "available_credit", header: t("settlementReports.columns.available", { defaultValue: "可用" }), creditMajor: true },
]}
currencyCode={currencyCode}
/>
</div>
<div>
<p className="mb-2 text-sm font-medium">{t("settlementReports.credit.players", { defaultValue: "玩家授信" })}</p>
<ReportTable
rows={players}
columns={[
{ key: "username", header: t("settlementReports.columns.player", { defaultValue: "玩家" }) },
{ key: "credit_limit", header: t("settlementReports.columns.creditLimit", { defaultValue: "授信" }), creditMajor: true },
{ key: "used_credit", header: t("settlementReports.columns.used", { defaultValue: "已用" }), creditMajor: true },
{ key: "frozen_credit", header: t("settlementReports.columns.frozen", { defaultValue: "冻结" }), creditMajor: true },
{ key: "available_credit", header: t("settlementReports.columns.available", { defaultValue: "可用" }), creditMajor: true },
]}
currencyCode={currencyCode}
/>
</div>
</div>
);
}
if (reportType === "platform_pnl" && root) {
if (root.error) {
return (
<p className="text-sm text-amber-800">
{t("settlementReports.platformPnl.periodRequired", {
defaultValue: "请选择具体账期后查看平台盈亏(需 settlement_period_id。",
})}
</p>
);
}
const stats = [
{ label: t("settlementReports.platformPnl.billNet", { defaultValue: "平台账单净额" }), value: money(root.platform_bill_net, currencyCode) },
{
label: t("settlementReports.platformPnl.rounding", { defaultValue: "尾差调整" }),
value: money(root.platform_rounding_adjustment, currencyCode),
},
{
label: t("settlementReports.platformPnl.shareProfit", { defaultValue: "占成利润(元数据)" }),
value: money(root.share_profit_meta, currencyCode),
},
];
return (
<div className="grid gap-3 sm:grid-cols-3">
{stats.map((item) => (
<div key={item.label} className="rounded-md border border-border/60 px-3 py-2">
<div className="text-xs text-muted-foreground">{item.label}</div>
<div className="mt-1 text-sm font-semibold tabular-nums">{item.value}</div>
</div>
))}
</div>
);
}
const items = asRows(root?.items ?? (reportType === "player_win_loss" || reportType === "agent_share" || reportType === "unpaid_bills" || reportType === "overdue" || reportType === "draw_period" ? data : null));
const columnSets: Record<string, { key: string; header: string; money?: boolean }[]> = {
player_win_loss: [
{ key: "username", header: t("settlementReports.columns.player", { defaultValue: "玩家" }) },
{ key: "game_type", header: t("settlementReports.columns.gameType", { defaultValue: "玩法" }) },
{ key: "game_win_loss", header: t("settlementReports.columns.grossWinLoss", { defaultValue: "输赢" }), money: true },
{ key: "basic_rebate", header: t("settlementReports.columns.rebate", { defaultValue: "回水" }), money: true },
],
agent_share: [
{ key: "agent_node_id", header: t("settlementReports.columns.agentId", { defaultValue: "代理 ID" }) },
{ key: "game_win_loss", header: t("settlementReports.columns.grossWinLoss", { defaultValue: "输赢" }), money: true },
{ key: "basic_rebate", header: t("settlementReports.columns.rebate", { defaultValue: "回水" }), money: true },
{ key: "entry_count", header: t("settlementReports.columns.count", { defaultValue: "笔数" }) },
],
unpaid_bills: [
{ key: "bill_id", header: t("settlementReports.columns.billId", { defaultValue: "账单" }) },
{ key: "bill_type", header: t("settlementReports.columns.billType", { defaultValue: "类型" }) },
{ key: "unpaid_amount", header: t("settlementReports.columns.unpaid", { defaultValue: "未结" }), money: true },
{ key: "status", header: t("settlementReports.columns.status", { defaultValue: "状态" }) },
],
overdue: [
{ key: "bill_id", header: t("settlementReports.columns.billId", { defaultValue: "账单" }) },
{ key: "overdue_days", header: t("settlementReports.columns.overdueDays", { defaultValue: "逾期天数" }) },
{ key: "unpaid_amount", header: t("settlementReports.columns.unpaid", { defaultValue: "未结" }), money: true },
],
draw_period: [
{ key: "draw_no", header: t("settlementReports.columns.drawNo", { defaultValue: "期号" }) },
{ key: "game_win_loss", header: t("settlementReports.columns.grossWinLoss", { defaultValue: "输赢" }), money: true },
{ key: "basic_rebate", header: t("settlementReports.columns.rebate", { defaultValue: "回水" }), money: true },
{ key: "ticket_count", header: t("settlementReports.columns.count", { defaultValue: "笔数" }) },
],
};
const columns = columnSets[reportType];
if (!columns) {
return (
<AdminNoResourceState className="text-sm text-muted-foreground" />
);
}
return <ReportTable rows={items} columns={columns} currencyCode={currencyCode} />;
}
function ReportTable({
rows,
columns,
currencyCode,
}: {
rows: Record<string, unknown>[];
columns: { key: string; header: string; money?: boolean; creditMajor?: boolean }[];
currencyCode: string;
}): React.ReactElement {
const { t } = useTranslation("common");
if (rows.length === 0) {
return <AdminNoResourceState className="text-sm text-muted-foreground" />;
}
return (
<div className="admin-table-shell max-h-96 overflow-auto">
<Table>
<TableHeader>
<TableRow>
{columns.map((col) => (
<TableHead
key={col.key}
className={col.money || col.creditMajor ? "text-right" : undefined}
>
{col.header}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row, idx) => (
<TableRow key={idx}>
{columns.map((col) => (
<TableCell
key={col.key}
className={col.money || col.creditMajor ? "text-right tabular-nums" : undefined}
>
{col.creditMajor
? creditMoney(row[col.key], currencyCode)
: col.money
? money(row[col.key], currencyCode)
: String(row[col.key] ?? "—")}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}

View File

@@ -0,0 +1,115 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
getAgentSettlementReport,
type AgentSettlementReportResponse,
type AgentSettlementReportType,
} from "@/api/admin-agent-settlement";
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { AgentSettlementReportView } from "@/modules/settlement/agent-settlement-report-view";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const REPORT_TYPES: AgentSettlementReportType[] = [
"summary",
"player_win_loss",
"agent_share",
"rebate",
"credit",
"unpaid_bills",
"overdue",
"platform_pnl",
"draw_period",
];
type AgentSettlementReportsPanelProps = {
adminSiteId: number;
settlementPeriodId: number | null;
currencyCode: string;
};
export function AgentSettlementReportsPanel({
adminSiteId,
settlementPeriodId,
currencyCode,
}: AgentSettlementReportsPanelProps): React.ReactElement {
const { t } = useTranslation(["agents", "common"]);
const [reportType, setReportType] = useState<AgentSettlementReportType>("summary");
const [response, setResponse] = useState<AgentSettlementReportResponse | null>(null);
const [loading, setLoading] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await getAgentSettlementReport({
type: reportType,
settlement_period_id: settlementPeriodId ?? undefined,
admin_site_id: adminSiteId,
});
setResponse(res);
} finally {
setLoading(false);
}
}, [adminSiteId, reportType, settlementPeriodId]);
useEffect(() => {
void load();
}, [load]);
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
value={reportType}
onValueChange={(v) => setReportType(v as AgentSettlementReportType)}
>
<SelectTrigger className="w-52">
<SelectValue />
</SelectTrigger>
<SelectContent>
{REPORT_TYPES.map((key) => (
<SelectItem key={key} value={key}>
{t(`settlementReports.types.${key}`, { defaultValue: key })}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{settlementPeriodId === null ? (
<p className="text-xs text-muted-foreground pb-1">
{t("settlementReports.noPeriodHint", {
defaultValue: "未选具体账期时使用近 7 日区间;平台盈亏需选择账期。",
})}
</p>
) : null}
</div>
<p className="text-xs text-muted-foreground">
{t("settlementReports.footnote", {
defaultValue: "本组报表为信用占成盘账期口径,与「佣金/回水」旧钱包报表不同。",
})}
</p>
{loading ? (
<AdminLoadingState minHeight="8rem" />
) : response ? (
<AgentSettlementReportView
reportType={reportType}
data={response.data}
currencyCode={currencyCode}
/>
) : null}
</div>
);
}

View File

@@ -0,0 +1,92 @@
"use client";
import { useTranslation } from "react-i18next";
import type { SettlementAdjustmentRow } from "@/api/admin-agent-settlement";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { formatSettlementPeriodSpan } from "@/lib/agent-settlement-period-range";
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
type SettlementAdjustmentsTableProps = {
rows: SettlementAdjustmentRow[];
loading: boolean;
currencyCode: string;
onOpenBill: (billId: number) => void;
};
export function SettlementAdjustmentsTable({
rows,
loading,
currencyCode,
onOpenBill,
}: SettlementAdjustmentsTableProps): React.ReactElement {
const { t } = useTranslation(["settlementCenter", "common"]);
if (loading) {
return <AdminLoadingState />;
}
if (rows.length === 0) {
return <AdminNoResourceState />;
}
return (
<div className="admin-table-shell overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("columns.period", { defaultValue: "账期" })}</TableHead>
<TableHead>{t("columns.adjustmentType", { defaultValue: "调账类型" })}</TableHead>
<TableHead>{t("columns.originalBill", { defaultValue: "原账单" })}</TableHead>
<TableHead className="text-right">{t("columns.amount", { defaultValue: "调整金额" })}</TableHead>
<TableHead>{t("columns.reason", { defaultValue: "原因" })}</TableHead>
<TableHead>{t("columns.time", { defaultValue: "时间" })}</TableHead>
<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>
{t(`adjustmentType.${row.adjustment_type}`, {
defaultValue: row.adjustment_type,
})}
</TableCell>
<TableCell className="tabular-nums">
{row.original_bill_id != null ? `#${row.original_bill_id}` : "—"}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatDashboardMoneyMinor(row.amount, currencyCode)}
</TableCell>
<TableCell className="max-w-[200px] truncate text-sm">{row.reason ?? "—"}</TableCell>
<TableCell className="text-xs text-muted-foreground">{row.created_at ?? "—"}</TableCell>
<TableCell>
{row.original_bill_id != null ? (
<button
type="button"
className="text-sm text-primary underline"
onClick={() => onOpenBill(row.original_bill_id!)}
>
{t("actions.viewBill", { defaultValue: "查看原账单" })}
</button>
) : null}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}

View File

@@ -0,0 +1,88 @@
"use client";
import { useTranslation } from "react-i18next";
import type { SettlementAdjustmentRow } from "@/api/admin-agent-settlement";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { formatSettlementPeriodSpan } from "@/lib/agent-settlement-period-range";
type SettlementBadDebtTableProps = {
rows: SettlementAdjustmentRow[];
loading: boolean;
currencyCode: string;
onOpenBill: (billId: number) => void;
};
export function SettlementBadDebtTable({
rows,
loading,
currencyCode,
onOpenBill,
}: SettlementBadDebtTableProps): React.ReactElement {
const { t } = useTranslation(["settlementCenter", "common"]);
if (loading) {
return <AdminLoadingState />;
}
if (rows.length === 0) {
return <AdminNoResourceState />;
}
return (
<div className="admin-table-shell overflow-x-auto rounded-lg border border-border/60">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("columns.period", { defaultValue: "账期" })}</TableHead>
<TableHead>{t("columns.originalBill", { defaultValue: "原账单" })}</TableHead>
<TableHead className="text-right">{t("columns.badDebtAmount", { defaultValue: "核销金额" })}</TableHead>
<TableHead>{t("columns.reason", { defaultValue: "原因" })}</TableHead>
<TableHead className="text-right" />
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.id}>
<TableCell className="text-sm text-muted-foreground">
{row.period_start && row.period_end
? formatSettlementPeriodSpan(row.period_start, row.period_end)
: "—"}
</TableCell>
<TableCell className="tabular-nums">#{row.original_bill_id ?? "—"}</TableCell>
<TableCell className="text-right tabular-nums">
{formatDashboardMoneyMinor(row.amount, currencyCode)}
</TableCell>
<TableCell className="max-w-[200px] truncate text-sm text-muted-foreground">
{row.reason?.trim() || "—"}
</TableCell>
<TableCell className="text-right">
{row.original_bill_id != null ? (
<Button
type="button"
size="sm"
variant="outline"
onClick={() => onOpenBill(row.original_bill_id!)}
>
{t("actions.viewBill", { defaultValue: "查看账单" })}
</Button>
) : null}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}

View File

@@ -16,6 +16,7 @@ import {
postAdminRejectSettlementBatch,
} from "@/api/admin-settlement";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { AdminAgentIdentityCells, AdminAgentIdentityHeads } from "@/components/admin/admin-agent-columns";
import { AdminPlayerIdentityCells, AdminPlayerIdentityHeads } from "@/components/admin/admin-player-identity-columns";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
@@ -388,14 +389,10 @@ export function SettlementBatchDetailsConsole({ batchId }: Props) {
onPageChange={setPage}
/>
</>
) : loading ? (
<AdminLoadingInline label={t("loadingDetails")} />
) : (
<p className="text-muted-foreground text-sm">
{loading ? (
<AdminLoadingInline label={t("loadingDetails")} />
) : (
t("states.noData", { ns: "common" })
)}
</p>
<AdminNoResourceState className="py-6" />
)}
</CardContent>
</Card>

View File

@@ -0,0 +1,140 @@
"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

@@ -0,0 +1,119 @@
"use client";
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 { formatSettlementPeriodSpan } from "@/lib/agent-settlement-period-range";
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
import { PlayerFundingModeBadge } from "@/components/admin/player-funding-badges";
import {
settlementBillStatusLabel,
settlementBillTypeLabel,
} from "@/modules/settlement/settlement-status-label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
type SettlementBillsTableProps = {
rows: SettlementBillRow[];
loading: boolean;
currencyCode: string;
onOpenDetail: (billId: number) => void;
};
export function SettlementBillsTable({
rows,
loading,
currencyCode,
onOpenDetail,
}: SettlementBillsTableProps): React.ReactElement {
const { t } = useTranslation(["settlementCenter", "agents", "common"]);
if (loading) {
return <AdminLoadingState />;
}
if (rows.length === 0) {
return <AdminNoResourceState />;
}
return (
<div className="admin-table-shell overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<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>
<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 />
</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)}
>
{t("actions.detail", { defaultValue: "详情 / 收付" })}
</button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}

View File

@@ -0,0 +1,8 @@
"use client";
import { SettlementCenterShell } from "@/modules/settlement/settlement-center-shell";
/** @deprecated 使用 SettlementCenterShell */
export function SettlementCenterConsole(): React.ReactElement {
return <SettlementCenterShell />;
}

View File

@@ -0,0 +1,101 @@
"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,439 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
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 {
SettlementCenterNav,
type SettlementCenterSection,
} 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 { adminHasAnyPermission } from "@/lib/admin-permissions";
import { PRD_SETTLEMENT_AGENT_MANAGE } from "@/lib/admin-prd";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
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 profile = useAdminProfile();
const boundAgent = profile?.agent ?? null;
const canManagePeriods =
profile?.is_super_admin === true ||
adminHasAnyPermission(profile?.permissions, [PRD_SETTLEMENT_AGENT_MANAGE]);
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 [detailBillId, setDetailBillId] = useState<number | null>(null);
const [billsInitialCategory, setBillsInitialCategory] = useState<BillCategory>("all");
const [listRevision, setListRevision] = useState(0);
useEffect(() => {
if (boundAgent?.admin_site_id) {
const label = boundAgent.name
? `${boundAgent.name} (${boundAgent.site_code || boundAgent.code})`
: boundAgent.code;
setSiteOptions([{ id: boundAgent.admin_site_id, label, currency_code: "NPR" }]);
setAdminSiteId(boundAgent.admin_site_id);
return;
}
void getAdminIntegrationSites().then((sites) => {
const options = (sites.items ?? []).map((site) => ({
id: site.id,
label: site.name ? `${site.name} (${site.code})` : site.code,
currency_code: site.currency_code ?? "NPR",
}));
setSiteOptions(options);
if (adminSiteId === null && options[0]) {
setAdminSiteId(options[0].id);
}
});
}, [adminSiteId, boundAgent]);
const loadPeriods = useCallback(async () => {
if (adminSiteId === null) {
setPeriods([]);
return;
}
try {
const data = await getSettlementPeriods({ admin_site_id: adminSiteId });
setPeriods(data.items ?? []);
} catch {
setPeriods([]);
toast.error(t("periods.loadFailed", { defaultValue: "账期列表加载失败" }));
}
}, [adminSiteId, t]);
useEffect(() => {
if (canManagePeriods || adminSiteId === null) {
return;
}
void loadPeriods();
}, [adminSiteId, canManagePeriods, loadPeriods]);
const handlePeriodsChange = useCallback((items: SettlementPeriodRow[]) => {
setPeriods(items);
}, []);
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 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 />;
}
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: "信用占成账务" })}
</p>
</div>
{siteOptions.length <= 1 && selectedSiteLabel ? (
<p className="text-sm text-muted-foreground">{selectedSiteLabel}</p>
) : null}
</header>
{adminSiteId === null ? (
<p className="text-sm text-muted-foreground">{t("empty.noSite", { defaultValue: "请选择接入站点。" })}</p>
) : (
<div className="min-w-0 space-y-4">
<SettlementCenterNav
active={activeSection}
onChange={(section) => {
if (section === "bills") {
setBillsInitialCategory("all");
}
setActiveSection(section);
}}
counts={{
pendingConfirm: summaryTotals.pendingConfirm,
awaitingPayment: summaryTotals.awaitingPayment,
}}
siteSelector={
siteOptions.length > 1 && selectSiteId !== null ? (
<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
}
/>
{showPeriodToolbar && periodFilterReady ? (
<SettlementPeriodToolbar
periods={periods}
value={periodFilter}
onChange={(next) => {
setPeriodFilter(next);
setPeriodFilterReady(true);
}}
/>
) : null}
<AdminPageCard title={panelTitle}>{renderMainPanel()}</AdminPageCard>
</div>
)}
<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>
</DialogHeader>
{detailBillId !== null ? (
<AgentBillDetail
billId={detailBillId}
currencyCode={activeCurrency}
canManage={canManagePeriods}
onUpdated={() => {
void loadPeriods();
setListRevision((n) => n + 1);
}}
/>
) : null}
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,172 @@
"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

@@ -0,0 +1,378 @@
"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

@@ -0,0 +1,127 @@
"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,99 @@
"use client";
import { useTranslation } from "react-i18next";
import type { SettlementPaymentRow } from "@/api/admin-agent-settlement";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { formatSettlementPeriodSpan } from "@/lib/agent-settlement-period-range";
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
import { settlementBillTypeLabel } from "@/modules/settlement/settlement-status-label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
type SettlementPaymentsTableProps = {
rows: SettlementPaymentRow[];
loading: boolean;
currencyCode: string;
onOpenBill: (billId: number) => void;
};
export function SettlementPaymentsTable({
rows,
loading,
currencyCode,
onOpenBill,
}: SettlementPaymentsTableProps): React.ReactElement {
const { t } = useTranslation(["settlementCenter", "agents", "common"]);
if (loading) {
return <AdminLoadingState />;
}
if (rows.length === 0) {
return <AdminNoResourceState />;
}
return (
<div className="admin-table-shell overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("columns.period", { defaultValue: "账期" })}</TableHead>
<TableHead>{t("columns.billId", { defaultValue: "账单 ID" })}</TableHead>
<TableHead>{t("columns.type", { defaultValue: "类型" })}</TableHead>
<TableHead>{t("columns.payer", { defaultValue: "付款方" })}</TableHead>
<TableHead>{t("columns.payee", { defaultValue: "收款方" })}</TableHead>
<TableHead className="text-right">{t("columns.amount", { defaultValue: "金额" })}</TableHead>
<TableHead>{t("columns.method", { defaultValue: "方式" })}</TableHead>
<TableHead>{t("columns.status", { defaultValue: "状态" })}</TableHead>
<TableHead>{t("columns.time", { defaultValue: "时间" })}</TableHead>
<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 className="tabular-nums">#{row.settlement_bill_id}</TableCell>
<TableCell>{settlementBillTypeLabel(row.bill_type, t)}</TableCell>
<TableCell>
{row.payer_type}#{row.payer_id}
</TableCell>
<TableCell>
{row.payee_type === "platform"
? t("agents:settlementBills.platform", { defaultValue: "平台" })
: `${row.payee_type}#${row.payee_id}`}
</TableCell>
<TableCell className="text-right tabular-nums font-medium">
{formatDashboardMoneyMinor(row.amount, currencyCode)}
</TableCell>
<TableCell>{row.method ?? "—"}</TableCell>
<TableCell>{row.status}</TableCell>
<TableCell className="text-xs text-muted-foreground">
{row.confirmed_at ?? row.created_at ?? "—"}
</TableCell>
<TableCell>
<button
type="button"
className="text-sm text-primary underline"
onClick={() => onOpenBill(row.settlement_bill_id)}
>
{t("actions.viewBill", { defaultValue: "查看账单" })}
</button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}

View File

@@ -0,0 +1,51 @@
"use client";
import { useTranslation } from "react-i18next";
import type { SettlementPeriodRow } from "@/api/admin-agent-settlement";
import {
AgentSettlementPeriodSelect,
type AgentSettlementPeriodFilter,
} from "@/modules/settlement/agent-settlement-period-select";
import { formatSettlementPeriodSpan } from "@/lib/agent-settlement-period-range";
import { settlementPeriodStatusLabel } from "@/modules/settlement/settlement-status-label";
type SettlementPeriodToolbarProps = {
periods: SettlementPeriodRow[];
value: AgentSettlementPeriodFilter;
onChange: (next: AgentSettlementPeriodFilter) => void;
};
export function SettlementPeriodToolbar({
periods,
value,
onChange,
}: SettlementPeriodToolbarProps): React.ReactElement | null {
const { t } = useTranslation("settlementCenter");
if (periods.length === 0) {
return null;
}
const selected =
typeof value === "number" ? periods.find((row) => row.id === value) ?? null : null;
return (
<div className="flex flex-wrap items-center gap-3 rounded-xl border border-border/70 bg-muted/20 px-4 py-3">
<span className="text-sm font-medium text-foreground">
{t("filters.period", { defaultValue: "账期范围" })}
</span>
<AgentSettlementPeriodSelect periods={periods} value={value} onChange={onChange} />
{selected ? (
<span className="text-sm text-muted-foreground">
{formatSettlementPeriodSpan(selected.period_start, selected.period_end)}
{` · ${settlementPeriodStatusLabel(selected.status, t)}`}
</span>
) : (
<span className="text-sm text-muted-foreground">
{t("filters.allPeriods", { defaultValue: "全部账期" })}
</span>
)}
</div>
);
}

View File

@@ -0,0 +1,69 @@
import type { TFunction } from "i18next";
export function settlementBillStatusLabel(
status: string,
t: TFunction<"settlementCenter">,
): string {
const key = `billStatus.${status}` as const;
return t(key, { defaultValue: status });
}
export function settlementBillTypeLabel(
billType: string,
t: TFunction<["settlementCenter", "agents"]>,
): string {
if (billType === "player") {
return t("agents:settlementBills.typePlayer", { defaultValue: "玩家账单" });
}
if (billType === "agent") {
return t("agents:settlementBills.typeAgent", { defaultValue: "代理层级账单" });
}
if (billType === "adjustment") {
return t("settlementCenter:billType.adjustment", { defaultValue: "补差单" });
}
if (billType === "reversal") {
return t("settlementCenter:billType.reversal", { defaultValue: "冲正单" });
}
if (billType === "bad_debt") {
return t("settlementCenter:billType.badDebt", { defaultValue: "坏账核销" });
}
return billType;
}
export function settlementPeriodStatusLabel(
status: string,
t: TFunction<"settlementCenter">,
): string {
if (status === "open") {
return t("filters.statusOpen", { defaultValue: "进行中" });
}
if (status === "closed") {
return t("filters.statusClosed", { defaultValue: "已关账" });
}
if (status === "completed") {
return t("filters.statusCompleted", { defaultValue: "已结清" });
}
return status;
}
export function creditLedgerReasonLabel(
reason: string,
t: TFunction<"settlementCenter">,
): string {
const key = `creditLedger.reason.${reason}` as const;
return t(key, { defaultValue: reason });
}
export function settlementAdjustmentTypeLabel(
type: string,
t: TFunction<"settlementCenter">,
): string {
if (type === "bad_debt") {
return t("adjustmentType.bad_debt", { defaultValue: "坏账核销" });
}
const key = `adjustmentType.${type}` as const;
return t(key, { defaultValue: type });
}