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:
313
src/modules/settlement/agent-bill-detail.tsx
Normal file
313
src/modules/settlement/agent-bill-detail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
307
src/modules/settlement/agent-periods-console.tsx
Normal file
307
src/modules/settlement/agent-periods-console.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
src/modules/settlement/agent-settlement-period-select.tsx
Normal file
72
src/modules/settlement/agent-settlement-period-select.tsx
Normal 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;
|
||||
}
|
||||
286
src/modules/settlement/agent-settlement-report-view.tsx
Normal file
286
src/modules/settlement/agent-settlement-report-view.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
115
src/modules/settlement/agent-settlement-reports-panel.tsx
Normal file
115
src/modules/settlement/agent-settlement-reports-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
92
src/modules/settlement/settlement-adjustments-table.tsx
Normal file
92
src/modules/settlement/settlement-adjustments-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
88
src/modules/settlement/settlement-bad-debt-table.tsx
Normal file
88
src/modules/settlement/settlement-bad-debt-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
140
src/modules/settlement/settlement-bills-panel.tsx
Normal file
140
src/modules/settlement/settlement-bills-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
119
src/modules/settlement/settlement-bills-table.tsx
Normal file
119
src/modules/settlement/settlement-bills-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
8
src/modules/settlement/settlement-center-console.tsx
Normal file
8
src/modules/settlement/settlement-center-console.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { SettlementCenterShell } from "@/modules/settlement/settlement-center-shell";
|
||||
|
||||
/** @deprecated 使用 SettlementCenterShell */
|
||||
export function SettlementCenterConsole(): React.ReactElement {
|
||||
return <SettlementCenterShell />;
|
||||
}
|
||||
101
src/modules/settlement/settlement-center-nav.tsx
Normal file
101
src/modules/settlement/settlement-center-nav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
439
src/modules/settlement/settlement-center-shell.tsx
Normal file
439
src/modules/settlement/settlement-center-shell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
172
src/modules/settlement/settlement-credit-ledger-table.tsx
Normal file
172
src/modules/settlement/settlement-credit-ledger-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
378
src/modules/settlement/settlement-ledger-panel.tsx
Normal file
378
src/modules/settlement/settlement-ledger-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
127
src/modules/settlement/settlement-ledger-row-actions.tsx
Normal file
127
src/modules/settlement/settlement-ledger-row-actions.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
99
src/modules/settlement/settlement-payments-table.tsx
Normal file
99
src/modules/settlement/settlement-payments-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
src/modules/settlement/settlement-period-toolbar.tsx
Normal file
51
src/modules/settlement/settlement-period-toolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
69
src/modules/settlement/settlement-status-label.ts
Normal file
69
src/modules/settlement/settlement-status-label.ts
Normal 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 });
|
||||
}
|
||||
Reference in New Issue
Block a user