feat(api, agents, i18n): enhance settlement features and multi-language support
Added new types and API functions for settlement period summaries and credit ledgers, improving the management of agent settlements. Updated the admin console to reflect these changes, enhancing user experience with better navigation and data presentation. Additionally, expanded multi-language support by incorporating new translations in English, Nepali, and Chinese for settlement-related terms, ensuring consistency across the platform.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -16,30 +17,16 @@ import {
|
||||
} from "@/api/admin-agent-settlement";
|
||||
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||||
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
|
||||
import {
|
||||
SettlementBillAmountBreakdown,
|
||||
SettlementBillPartiesRow,
|
||||
SettlementBillSummaryHeader,
|
||||
} from "@/modules/settlement/settlement-bill-breakdown";
|
||||
import { describeBillPaymentDirection } from "@/modules/settlement/settlement-bill-display";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
function parseBillMeta(metaJson: SettlementBillRow["meta_json"]): {
|
||||
share_profit?: number;
|
||||
platform_share_profit?: number;
|
||||
} {
|
||||
if (metaJson == null || metaJson === "") {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const parsed =
|
||||
typeof metaJson === "string" ? (JSON.parse(metaJson) as Record<string, unknown>) : metaJson;
|
||||
return {
|
||||
share_profit: parsed.share_profit != null ? Number(parsed.share_profit) : undefined,
|
||||
platform_share_profit:
|
||||
parsed.platform_share_profit != null ? Number(parsed.platform_share_profit) : undefined,
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
type AgentBillDetailProps = {
|
||||
billId: number;
|
||||
currencyCode: string;
|
||||
@@ -53,17 +40,17 @@ export function AgentBillDetail({
|
||||
canManage = true,
|
||||
onUpdated,
|
||||
}: AgentBillDetailProps): React.ReactElement {
|
||||
const { t } = useTranslation(["agents", "common"]);
|
||||
const { t } = useTranslation(["agents", "settlementCenter", "common"]);
|
||||
const [bill, setBill] = useState<SettlementBillRow | null>(null);
|
||||
const [payments, setPayments] = useState<SettlementPaymentRow[]>([]);
|
||||
const [rebateAllocations, setRebateAllocations] = useState<RebateAllocationRow[]>([]);
|
||||
const [tierEdge, setTierEdge] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [payAmount, setPayAmount] = useState("");
|
||||
const [payMethod, setPayMethod] = useState("");
|
||||
const [payProof, setPayProof] = useState("");
|
||||
const [adjustAmount, setAdjustAmount] = useState("");
|
||||
const [badDebtReason, setBadDebtReason] = useState("");
|
||||
const [rebateDetailsOpen, setRebateDetailsOpen] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -72,7 +59,6 @@ export function AgentBillDetail({
|
||||
setBill(data.bill);
|
||||
setPayments(data.payments ?? []);
|
||||
setRebateAllocations(data.rebate_allocations ?? []);
|
||||
setTierEdge(data.tier_edge ?? null);
|
||||
setPayAmount(String(data.bill.unpaid_amount ?? 0));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -87,20 +73,12 @@ export function AgentBillDetail({
|
||||
return <AdminLoadingState />;
|
||||
}
|
||||
|
||||
const owner =
|
||||
bill.owner_label ??
|
||||
`${bill.owner_type}#${bill.owner_id}`;
|
||||
const counterparty =
|
||||
bill.counterparty_label === "platform"
|
||||
? t("settlementBills.platform", { defaultValue: "平台" })
|
||||
: bill.counterparty_label ?? `${bill.counterparty_type}#${bill.counterparty_id}`;
|
||||
|
||||
const direction = describeBillPaymentDirection(bill, t);
|
||||
const locked = ["confirmed", "partial_paid", "settled", "overdue"].includes(bill.status);
|
||||
const ownerOwes = bill.net_amount > 0;
|
||||
const paymentTitle = ownerOwes
|
||||
? t("settlementBills.recordReceipt", { defaultValue: "登记收款" })
|
||||
: t("settlementBills.recordPayout", { defaultValue: "登记付款" });
|
||||
const paymentSubmit = ownerOwes
|
||||
const paymentTitle = direction.ownerOwes
|
||||
? t("settlementBills.submitReceipt", { defaultValue: "登记收款" })
|
||||
: t("settlementBills.submitPayout", { defaultValue: "登记付款" });
|
||||
const paymentSubmit = direction.ownerOwes
|
||||
? t("settlementBills.submitReceipt", { defaultValue: "确认收款" })
|
||||
: t("settlementBills.submitPayout", { defaultValue: "确认付款" });
|
||||
const canWriteOff =
|
||||
@@ -108,206 +86,300 @@ export function AgentBillDetail({
|
||||
bill.unpaid_amount > 0 &&
|
||||
["confirmed", "partial_paid", "overdue"].includes(bill.status) &&
|
||||
!["adjustment", "reversal", "bad_debt"].includes(bill.bill_type);
|
||||
const meta = parseBillMeta(bill.meta_json);
|
||||
const hasSubtreeFields =
|
||||
bill.gross_win_loss != null ||
|
||||
bill.rebate_amount != null ||
|
||||
bill.platform_rounding_adjustment != null ||
|
||||
meta.share_profit != null;
|
||||
const rebateAllocationSummary = Object.values(
|
||||
rebateAllocations.reduce<Record<string, { key: string; label: string; amount: number; rows: number }>>(
|
||||
(acc, row) => {
|
||||
const label = row.participant_label ?? `${row.participant_type}#${row.participant_id}`;
|
||||
const key = `${row.participant_type}:${row.participant_id}:${row.allocation_rule}`;
|
||||
const current = acc[key];
|
||||
if (current) {
|
||||
current.amount += row.allocated_amount;
|
||||
current.rows += 1;
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc[key] = {
|
||||
key,
|
||||
label: `${label} · ${row.allocation_rule}`,
|
||||
amount: row.allocated_amount,
|
||||
rows: 1,
|
||||
};
|
||||
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
),
|
||||
).sort((a, b) => b.amount - a.amount || a.label.localeCompare(b.label, "zh-CN"));
|
||||
|
||||
return (
|
||||
<div className="space-y-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t("settlementBills.columns.party", { defaultValue: "本方" })}: </span>
|
||||
{owner}
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.35fr)_minmax(340px,0.95fr)]">
|
||||
<div className="space-y-5 text-sm">
|
||||
<SettlementBillSummaryHeader bill={bill} currencyCode={currencyCode} />
|
||||
<SettlementBillPartiesRow bill={bill} />
|
||||
<SettlementBillAmountBreakdown bill={bill} currencyCode={currencyCode} />
|
||||
|
||||
{payments.length > 0 ? (
|
||||
<div className="space-y-2 rounded-xl border border-border/70 p-4">
|
||||
<p className="font-medium">
|
||||
{t("settlementBills.paymentsHistory", { defaultValue: "收付记录" })}
|
||||
</p>
|
||||
<ul className="space-y-1.5 text-muted-foreground">
|
||||
{payments.map((p) => (
|
||||
<li key={p.id} className="flex justify-between gap-2">
|
||||
<span>
|
||||
{p.method
|
||||
? `${p.method}`
|
||||
: t("settlementCenter:billDisplay.payment", { defaultValue: "收付" })}
|
||||
{p.remark ? ` · ${p.remark}` : ""}
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums">
|
||||
{formatDashboardMoneyMinor(p.amount, currencyCode)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
{t("settlementBills.columns.counterparty", { defaultValue: "对方" })}:{" "}
|
||||
</span>
|
||||
{counterparty}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t("settlementBills.columns.type", { defaultValue: "类型" })}: </span>
|
||||
{bill.bill_type} / {bill.status}
|
||||
{tierEdge ? ` · ${tierEdge}` : ""}
|
||||
</div>
|
||||
{hasSubtreeFields ? (
|
||||
<div className="space-y-1 rounded-md border border-border/60 p-3">
|
||||
<p className="font-medium">
|
||||
{t("settlementBills.subtreeSummary", { defaultValue: "子树汇总" })}
|
||||
</p>
|
||||
{bill.gross_win_loss != null ? (
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
{t("settlementBills.grossWinLoss", { defaultValue: "输赢 (gross_win_loss)" })}:{" "}
|
||||
</span>
|
||||
{formatDashboardMoneyMinor(bill.gross_win_loss, currencyCode)}
|
||||
|
||||
<div className="space-y-5 text-sm">
|
||||
{rebateAllocations.length > 0 ? (
|
||||
<div className="space-y-2 rounded-xl border border-border/70 p-4">
|
||||
<p className="font-medium">
|
||||
{t("settlementBills.rebateAllocations", { defaultValue: "回水分摊" })}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settlementCenter:billDisplay.rebateAllocationsHint", {
|
||||
defaultValue: "各层级代理对回水的承担明细。",
|
||||
})}
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<ul className="space-y-1.5 text-muted-foreground">
|
||||
{rebateAllocationSummary.map((row) => (
|
||||
<li key={row.key} className="flex justify-between gap-2">
|
||||
<span className="min-w-0">
|
||||
{row.label}
|
||||
<span className="ml-2 text-xs text-muted-foreground/75">
|
||||
{t("common:count", { defaultValue: "{{count}} 条", count: row.rows })}
|
||||
</span>
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums">
|
||||
{formatDashboardMoneyMinor(row.amount, currencyCode)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs font-medium text-primary underline-offset-4 hover:underline"
|
||||
onClick={() => setRebateDetailsOpen((open) => !open)}
|
||||
>
|
||||
{rebateDetailsOpen
|
||||
? t("settlementCenter:billDisplay.hideRawRebateAllocations", {
|
||||
defaultValue: "收起原始明细",
|
||||
})
|
||||
: t("settlementCenter:billDisplay.showRawRebateAllocations", {
|
||||
defaultValue: "展开原始明细",
|
||||
})}
|
||||
</button>
|
||||
|
||||
{rebateDetailsOpen ? (
|
||||
<ul className="max-h-[280px] space-y-1.5 overflow-y-auto rounded-lg border border-dashed border-border/70 p-3 pr-2 text-muted-foreground">
|
||||
{rebateAllocations.map((row) => (
|
||||
<li key={row.id} className="flex justify-between gap-2">
|
||||
<span>
|
||||
{row.participant_label ?? `${row.participant_type}#${row.participant_id}`} ·{" "}
|
||||
{row.allocation_rule}
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums">
|
||||
{formatDashboardMoneyMinor(row.allocated_amount, currencyCode)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{bill.rebate_amount != null ? (
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
{t("settlementBills.rebateAmount", { defaultValue: "回水" })}:{" "}
|
||||
</span>
|
||||
{formatDashboardMoneyMinor(bill.rebate_amount, currencyCode)}
|
||||
</div>
|
||||
) : null}
|
||||
{meta.share_profit != null ? (
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
{t("settlementBills.shareProfit", { defaultValue: "占成利润" })}:{" "}
|
||||
</span>
|
||||
{formatDashboardMoneyMinor(meta.share_profit, currencyCode)}
|
||||
</div>
|
||||
) : null}
|
||||
{bill.platform_rounding_adjustment != null && bill.platform_rounding_adjustment !== 0 ? (
|
||||
<div>
|
||||
<span className="text-muted-foreground">
|
||||
{t("settlementBills.platformRounding", { defaultValue: "平台尾差" })}:{" "}
|
||||
</span>
|
||||
{formatDashboardMoneyMinor(bill.platform_rounding_adjustment, currencyCode)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t("settlementBills.columns.net", { defaultValue: "净额" })}: </span>
|
||||
{formatDashboardMoneyMinor(bill.net_amount, currencyCode)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">{t("settlementBills.columns.unpaid", { defaultValue: "未结" })}: </span>
|
||||
{formatDashboardMoneyMinor(bill.unpaid_amount, currencyCode)}
|
||||
</div>
|
||||
|
||||
{rebateAllocations.length > 0 ? (
|
||||
<div className="space-y-1 rounded-md border border-border/60 p-3">
|
||||
<p className="font-medium">{t("settlementBills.rebateAllocations", { defaultValue: "回水分摊" })}</p>
|
||||
<ul className="space-y-1 text-muted-foreground">
|
||||
{rebateAllocations.map((row) => (
|
||||
<li key={row.id}>
|
||||
{row.participant_type}#{row.participant_id} · {row.allocation_rule} ·{" "}
|
||||
{formatDashboardMoneyMinor(row.allocated_amount, currencyCode)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{payments.length > 0 ? (
|
||||
<div className="space-y-1 rounded-md border border-border/60 p-3">
|
||||
<p className="font-medium">{t("settlementBills.paymentsHistory", { defaultValue: "收付记录" })}</p>
|
||||
<ul className="space-y-1 text-muted-foreground">
|
||||
{payments.map((p) => (
|
||||
<li key={p.id}>
|
||||
{formatDashboardMoneyMinor(p.amount, currencyCode)}
|
||||
{p.method ? ` · ${p.method}` : ""}
|
||||
{p.remark ? ` · ${p.remark}` : ""}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canManage && bill.status === "pending_confirm" ? (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void postSettlementBillConfirm(billId)
|
||||
.then(load)
|
||||
.then(onUpdated)
|
||||
.then(() => toast.success(t("settlementBills.confirmed", { defaultValue: "已确认" })))
|
||||
}
|
||||
>
|
||||
{t("settlementBills.confirm", { defaultValue: "确认账单" })}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{canManage && ["confirmed", "partial_paid", "overdue"].includes(bill.status) && bill.unpaid_amount > 0 ? (
|
||||
<div className="space-y-2 rounded-md border border-border/60 p-3">
|
||||
<p className="font-medium">{paymentTitle}</p>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{canManage && bill.status === "pending_confirm" ? (
|
||||
<div className="space-y-3 rounded-xl border border-border/70 bg-muted/15 p-4">
|
||||
<div className="space-y-1">
|
||||
<Label>{t("settlementBills.paymentAmount", { defaultValue: "收付金额" })}</Label>
|
||||
<Input value={payAmount} onChange={(e) => setPayAmount(e.target.value)} />
|
||||
<p className="font-medium">
|
||||
{t("settlementBills.confirm", { defaultValue: "确认账单" })}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settlementCenter:billDisplay.confirmHint", {
|
||||
defaultValue: "确认后才可以登记收款或付款。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
onClick={() =>
|
||||
void postSettlementBillConfirm(billId)
|
||||
.then(load)
|
||||
.then(onUpdated)
|
||||
.then(() => toast.success(t("settlementBills.confirmed", { defaultValue: "已确认" })))
|
||||
}
|
||||
>
|
||||
{t("settlementBills.confirm", { defaultValue: "确认账单" })}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canManage && ["confirmed", "partial_paid", "overdue"].includes(bill.status) && bill.unpaid_amount > 0 ? (
|
||||
<div className="space-y-3 rounded-xl border border-border/70 p-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="font-medium">{paymentTitle}</p>
|
||||
<span className="rounded-full bg-amber-50 px-2.5 py-1 text-xs font-medium text-amber-700 dark:bg-amber-950/30 dark:text-amber-300">
|
||||
{t("settlementCenter:columns.unpaid", { defaultValue: "未结" })}{" "}
|
||||
{formatDashboardMoneyMinor(bill.unpaid_amount, currencyCode)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1 text-xs text-muted-foreground">
|
||||
<span>{direction.payer}</span>
|
||||
<ArrowRight className="size-3.5 shrink-0" aria-hidden />
|
||||
<span>{direction.payee}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label>{t("settlementBills.paymentAmount", { defaultValue: "收付金额" })}</Label>
|
||||
<Input
|
||||
value={payAmount}
|
||||
onChange={(e) => setPayAmount(e.target.value)}
|
||||
placeholder={String(bill.unpaid_amount)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t("settlementBills.paymentMethod", { defaultValue: "收付方式" })}</Label>
|
||||
<Input
|
||||
value={payMethod}
|
||||
onChange={(e) => setPayMethod(e.target.value)}
|
||||
placeholder={t("settlementBills.paymentMethodPlaceholder", {
|
||||
defaultValue: "例如:现金 / 银行转账",
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t("settlementBills.paymentProof", { defaultValue: "凭证/备注" })}</Label>
|
||||
<Input
|
||||
value={payProof}
|
||||
onChange={(e) => setPayProof(e.target.value)}
|
||||
placeholder={t("settlementBills.paymentProofPlaceholder", {
|
||||
defaultValue: "可填写流水号、截图说明或备注",
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
onClick={() =>
|
||||
void postSettlementBillPayment(billId, {
|
||||
amount: Number(payAmount),
|
||||
method: payMethod.trim() || undefined,
|
||||
proof: payProof.trim() || undefined,
|
||||
})
|
||||
.then(load)
|
||||
.then(onUpdated)
|
||||
.then(() => toast.success(t("settlementBills.paid", { defaultValue: "已登记收付" })))
|
||||
}
|
||||
>
|
||||
{paymentSubmit}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canWriteOff ? (
|
||||
<div className="space-y-3 rounded-xl border border-border/70 p-4">
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">
|
||||
{t("settlementBills.badDebtWriteOff", { defaultValue: "坏账核销" })}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settlementBills.badDebtHint", {
|
||||
defaultValue: "仅在确认无法收回时使用,核销后会生成坏账记录。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t("settlementBills.paymentMethod", { defaultValue: "方式" })}</Label>
|
||||
<Input value={payMethod} onChange={(e) => setPayMethod(e.target.value)} placeholder="cash" />
|
||||
<Label>{t("settlementBills.badDebtReason", { defaultValue: "核销原因" })}</Label>
|
||||
<Input
|
||||
value={badDebtReason}
|
||||
onChange={(e) => setBadDebtReason(e.target.value)}
|
||||
placeholder={t("settlementBills.badDebtReasonPlaceholder", {
|
||||
defaultValue: "例如:客户失联、确认坏账",
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 sm:col-span-2">
|
||||
<Label>{t("settlementBills.paymentProof", { defaultValue: "凭证/备注" })}</Label>
|
||||
<Input value={payProof} onChange={(e) => setPayProof(e.target.value)} />
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
className="w-full"
|
||||
onClick={() =>
|
||||
void postSettlementBillBadDebtWriteOff(billId, {
|
||||
reason: badDebtReason.trim() || undefined,
|
||||
})
|
||||
.then(load)
|
||||
.then(onUpdated)
|
||||
.then(() =>
|
||||
toast.success(t("settlementBills.badDebtDone", { defaultValue: "已核销坏账" })),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("settlementBills.confirmBadDebt", { defaultValue: "确认核销" })}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canManage && locked ? (
|
||||
<div className="space-y-3 rounded-xl border border-dashed border-border/70 p-4">
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">
|
||||
{t("settlementBills.adjustment", { defaultValue: "补差/冲正单" })}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settlementBills.adjustmentHint", {
|
||||
defaultValue: "正数表示补收,负数表示冲减;提交后会生成一张独立调账单。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t("settlementBills.adjustmentAmount", { defaultValue: "调整金额(可负)" })}</Label>
|
||||
<Input
|
||||
value={adjustAmount}
|
||||
onChange={(e) => setAdjustAmount(e.target.value)}
|
||||
type="number"
|
||||
placeholder={t("settlementBills.adjustmentAmountPlaceholder", {
|
||||
defaultValue: "输入正数或负数",
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() =>
|
||||
void postSettlementBillAdjustment(billId, {
|
||||
amount: Number(adjustAmount),
|
||||
reason: "manual_adjustment",
|
||||
})
|
||||
.then(() =>
|
||||
toast.success(t("settlementBills.adjustmentCreated", { defaultValue: "已创建补差单" })),
|
||||
)
|
||||
.then(onUpdated)
|
||||
}
|
||||
>
|
||||
{t("settlementBills.createAdjustment", { defaultValue: "创建补差单" })}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void postSettlementBillPayment(billId, {
|
||||
amount: Number(payAmount),
|
||||
method: payMethod.trim() || undefined,
|
||||
proof: payProof.trim() || undefined,
|
||||
})
|
||||
.then(load)
|
||||
.then(onUpdated)
|
||||
.then(() => toast.success(t("settlementBills.paid", { defaultValue: "已登记收付" })))
|
||||
}
|
||||
>
|
||||
{paymentSubmit}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canWriteOff ? (
|
||||
<div className="space-y-2 rounded-md border border-border/60 p-3">
|
||||
<p className="font-medium">{t("settlementBills.badDebtWriteOff", { defaultValue: "坏账核销" })}</p>
|
||||
<div className="space-y-1">
|
||||
<Label>{t("settlementBills.badDebtReason", { defaultValue: "核销原因" })}</Label>
|
||||
<Input value={badDebtReason} onChange={(e) => setBadDebtReason(e.target.value)} />
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() =>
|
||||
void postSettlementBillBadDebtWriteOff(billId, {
|
||||
reason: badDebtReason.trim() || undefined,
|
||||
})
|
||||
.then(load)
|
||||
.then(onUpdated)
|
||||
.then(() =>
|
||||
toast.success(t("settlementBills.badDebtDone", { defaultValue: "已核销坏账" })),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("settlementBills.confirmBadDebt", { defaultValue: "确认核销" })}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canManage && locked ? (
|
||||
<div className="space-y-2 rounded-md border border-dashed border-border/60 p-3">
|
||||
<p className="font-medium">{t("settlementBills.adjustment", { defaultValue: "补差/冲正单" })}</p>
|
||||
<div className="space-y-1">
|
||||
<Label>{t("settlementBills.adjustmentAmount", { defaultValue: "调整金额(可负)" })}</Label>
|
||||
<Input value={adjustAmount} onChange={(e) => setAdjustAmount(e.target.value)} type="number" />
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
void postSettlementBillAdjustment(billId, {
|
||||
amount: Number(adjustAmount),
|
||||
reason: "manual_adjustment",
|
||||
})
|
||||
.then(() => toast.success(t("settlementBills.adjustmentCreated", { defaultValue: "已创建补差单" })))
|
||||
.then(onUpdated)
|
||||
}
|
||||
>
|
||||
{t("settlementBills.createAdjustment", { defaultValue: "创建补差单" })}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,307 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
getSettlementPeriods,
|
||||
postSettlementPeriod,
|
||||
postSettlementPeriodClose,
|
||||
type SettlementPeriodCloseResult,
|
||||
type SettlementPeriodRow,
|
||||
} from "@/api/admin-agent-settlement";
|
||||
import { settlementPeriodStatusLabel } from "@/modules/settlement/settlement-status-label";
|
||||
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||||
import {
|
||||
defaultSettlementPeriodPreset,
|
||||
formatSettlementPeriodSpan,
|
||||
settlementPeriodPresetRange,
|
||||
type SettlementPeriodPresetKey,
|
||||
} from "@/lib/agent-settlement-period-range";
|
||||
import { normalizeAgentSettlementCycle } from "@/lib/agent-settlement-cycle";
|
||||
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type AgentPeriodsConsoleProps = {
|
||||
adminSiteId: number;
|
||||
canManagePeriods: boolean;
|
||||
settlementCycle?: string | null;
|
||||
siteCurrencyCode?: string;
|
||||
/** 嵌入结算中心主区时不重复外层卡片标题 */
|
||||
embedded?: boolean;
|
||||
onPeriodsChange?: (periods: SettlementPeriodRow[]) => void;
|
||||
onPeriodClosed?: (result: SettlementPeriodCloseResult) => void;
|
||||
};
|
||||
|
||||
const PRESET_KEYS: SettlementPeriodPresetKey[] = ["this_week", "last_week", "this_month"];
|
||||
|
||||
export function AgentPeriodsConsole({
|
||||
adminSiteId,
|
||||
canManagePeriods,
|
||||
settlementCycle,
|
||||
siteCurrencyCode = "NPR",
|
||||
embedded = false,
|
||||
onPeriodsChange,
|
||||
onPeriodClosed,
|
||||
}: AgentPeriodsConsoleProps): React.ReactElement | null {
|
||||
const { t } = useTranslation(["agents", "settlementCenter", "common"]);
|
||||
const [rows, setRows] = useState<SettlementPeriodRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const [periodStart, setPeriodStart] = useState("");
|
||||
const [periodEnd, setPeriodEnd] = useState("");
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
|
||||
const cycle = normalizeAgentSettlementCycle(settlementCycle);
|
||||
|
||||
const applyPreset = useCallback(
|
||||
(key: SettlementPeriodPresetKey) => {
|
||||
const range = settlementPeriodPresetRange(key);
|
||||
setPeriodStart(range.period_start);
|
||||
setPeriodEnd(range.period_end);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const onPeriodsChangeRef = useRef(onPeriodsChange);
|
||||
const onPeriodClosedRef = useRef(onPeriodClosed);
|
||||
onPeriodsChangeRef.current = onPeriodsChange;
|
||||
onPeriodClosedRef.current = onPeriodClosed;
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setLoadError(false);
|
||||
try {
|
||||
const data = await getSettlementPeriods({ admin_site_id: adminSiteId });
|
||||
const items = data.items ?? [];
|
||||
setRows(items);
|
||||
onPeriodsChangeRef.current?.(items);
|
||||
} catch {
|
||||
setRows([]);
|
||||
setLoadError(true);
|
||||
onPeriodsChangeRef.current?.([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [adminSiteId]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canManagePeriods || periodStart !== "" || periodEnd !== "") {
|
||||
return;
|
||||
}
|
||||
applyPreset(defaultSettlementPeriodPreset(cycle));
|
||||
}, [applyPreset, canManagePeriods, cycle, periodEnd, periodStart]);
|
||||
|
||||
async function openPeriod(): Promise<void> {
|
||||
if (!periodStart || !periodEnd) {
|
||||
toast.error(t("settlementPeriods.datesRequired", { defaultValue: "请填写账期起止" }));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await postSettlementPeriod({
|
||||
admin_site_id: adminSiteId,
|
||||
period_start: periodStart,
|
||||
period_end: periodEnd,
|
||||
});
|
||||
toast.success(t("settlementPeriods.opened", { defaultValue: "账期已开启" }));
|
||||
await load();
|
||||
} catch {
|
||||
toast.error(t("settlementPeriods.openFailed", { defaultValue: "开期失败" }));
|
||||
}
|
||||
}
|
||||
|
||||
async function closePeriod(id: number): Promise<void> {
|
||||
try {
|
||||
const result = await postSettlementPeriodClose(id);
|
||||
await load();
|
||||
onPeriodClosedRef.current?.(result);
|
||||
} catch {
|
||||
toast.error(t("settlementPeriods.closeFailed", { defaultValue: "关账失败" }));
|
||||
}
|
||||
}
|
||||
|
||||
const presetLabel = (key: SettlementPeriodPresetKey): string => {
|
||||
switch (key) {
|
||||
case "this_week":
|
||||
return t("settlementPeriods.presetThisWeek", { defaultValue: "本周" });
|
||||
case "last_week":
|
||||
return t("settlementPeriods.presetLastWeek", { defaultValue: "上周" });
|
||||
case "this_month":
|
||||
return t("settlementPeriods.presetThisMonth", { defaultValue: "本月" });
|
||||
}
|
||||
};
|
||||
|
||||
const body = (
|
||||
<>
|
||||
{canManagePeriods ? (
|
||||
<div className={embedded ? "space-y-4" : "mb-4 space-y-3"}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{PRESET_KEYS.map((key) => (
|
||||
<Button
|
||||
key={key}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => applyPreset(key)}
|
||||
>
|
||||
{presetLabel(key)}
|
||||
</Button>
|
||||
))}
|
||||
<Button type="button" size="sm" onClick={() => void openPeriod()}>
|
||||
{t("settlementPeriods.openWithPreset", { defaultValue: "按上方时间开期" })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-primary underline"
|
||||
onClick={() => setAdvancedOpen((open) => !open)}
|
||||
>
|
||||
{advancedOpen
|
||||
? t("settlementPeriods.hideAdvanced", { defaultValue: "收起自定义时间" })
|
||||
: t("settlementPeriods.showAdvanced", { defaultValue: "自定义起止时间" })}
|
||||
</button>
|
||||
{advancedOpen ? (
|
||||
<div className="flex flex-wrap items-end gap-3 pt-1">
|
||||
<div className="space-y-1">
|
||||
<Label>{t("settlementPeriods.start", { defaultValue: "开始" })}</Label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={periodStart}
|
||||
onChange={(e) => setPeriodStart(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t("settlementPeriods.end", { defaultValue: "结束" })}</Label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={periodEnd}
|
||||
onChange={(e) => setPeriodEnd(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="button" variant="secondary" onClick={() => void openPeriod()}>
|
||||
{t("settlementPeriods.open", { defaultValue: "开期" })}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<AdminLoadingState />
|
||||
) : loadError ? (
|
||||
<p className="text-sm text-destructive">
|
||||
{t("settlementPeriods.loadFailed", { defaultValue: "账期列表加载失败,请稍后重试。" })}
|
||||
</p>
|
||||
) : rows.length === 0 ? (
|
||||
<AdminNoResourceState />
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("settlementPeriods.range", { defaultValue: "账期" })}</TableHead>
|
||||
<TableHead>{t("settlementPeriods.status", { defaultValue: "状态" })}</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("settlementPeriods.billCounts", { defaultValue: "账单笔数" })}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("settlementPeriods.pendingConfirm", { defaultValue: "待确认" })}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("settlementPeriods.awaitingPayment", { defaultValue: "待收付" })}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("settlementPeriods.totalUnpaid", { defaultValue: "未结合计" })}
|
||||
</TableHead>
|
||||
<TableHead className="text-right" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => {
|
||||
const summary = row.summary;
|
||||
const billCountLabel =
|
||||
summary != null
|
||||
? t("settlementPeriods.billCountsValue", {
|
||||
defaultValue: "玩家 {{player}} · 代理 {{agent}}",
|
||||
player: summary.player_bills,
|
||||
agent: summary.agent_bills,
|
||||
})
|
||||
: "—";
|
||||
|
||||
return (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="text-sm">
|
||||
{formatSettlementPeriodSpan(row.period_start, row.period_end)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-medium",
|
||||
row.status === "open"
|
||||
? "text-amber-700"
|
||||
: row.status === "completed"
|
||||
? "text-emerald-700"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{settlementPeriodStatusLabel(row.status, t)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-xs text-muted-foreground">
|
||||
{billCountLabel}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-xs tabular-nums">
|
||||
{summary?.pending_confirm ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-xs tabular-nums">
|
||||
{summary?.awaiting_payment ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-xs tabular-nums">
|
||||
{summary != null
|
||||
? formatDashboardMoneyMinor(summary.total_unpaid, siteCurrencyCode)
|
||||
: "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{row.status === "open" ? (
|
||||
<Button type="button" size="sm" onClick={() => void closePeriod(row.id)}>
|
||||
{t("settlementPeriods.close", { defaultValue: "关账并生成账单" })}
|
||||
</Button>
|
||||
) : null}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
if (embedded) {
|
||||
return body;
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminPageCard title={t("settlementPeriods.manageTitle", { defaultValue: "账期管理" })}>
|
||||
{body}
|
||||
</AdminPageCard>
|
||||
);
|
||||
}
|
||||
@@ -27,23 +27,43 @@ export function AgentSettlementPeriodSelect({
|
||||
onChange,
|
||||
className,
|
||||
}: AgentSettlementPeriodSelectProps): React.ReactElement {
|
||||
const { t } = useTranslation("agents");
|
||||
const { t } = useTranslation(["agents", "settlementCenter"]);
|
||||
|
||||
const sorted = [...periods].sort((a, b) => b.id - a.id);
|
||||
|
||||
const periodLabel = (filter: AgentSettlementPeriodFilter): string => {
|
||||
if (filter === "all") {
|
||||
return t("settlementCenter:filters.allPeriods", {
|
||||
defaultValue: t("agents:settlementBills.allPeriods", { defaultValue: "全部账期" }),
|
||||
});
|
||||
}
|
||||
|
||||
const row = periods.find((p) => p.id === filter);
|
||||
if (!row) {
|
||||
return t("agents:settlementBills.periodPlaceholder", { defaultValue: "选择账期" });
|
||||
}
|
||||
|
||||
return `${formatSettlementPeriodSpan(row.period_start, row.period_end)} · ${periodStatusLabel(row.status, t)}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Select
|
||||
modal={false}
|
||||
value={value === "all" ? "all" : String(value)}
|
||||
onValueChange={(next) => {
|
||||
onChange(next === "all" ? "all" : Number(next));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className={className ?? "h-9 w-full max-w-md"}>
|
||||
<SelectValue placeholder={t("settlementBills.periodPlaceholder", { defaultValue: "选择账期" })} />
|
||||
<SelectValue placeholder={t("agents:settlementBills.periodPlaceholder", { defaultValue: "选择账期" })}>
|
||||
{() => periodLabel(value)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{t("settlementBills.allPeriods", { defaultValue: "全部账期" })}
|
||||
{t("settlementCenter:filters.allPeriods", {
|
||||
defaultValue: t("agents:settlementBills.allPeriods", { defaultValue: "全部账期" }),
|
||||
})}
|
||||
</SelectItem>
|
||||
{sorted.map((row) => (
|
||||
<SelectItem key={row.id} value={String(row.id)}>
|
||||
@@ -62,10 +82,13 @@ function periodStatusLabel(
|
||||
t: (key: string, opts?: { defaultValue?: string }) => string,
|
||||
): string {
|
||||
if (status === "open") {
|
||||
return t("settlementPeriods.statusOpen", { defaultValue: "进行中" });
|
||||
return t("agents:settlementPeriods.statusOpen", { defaultValue: "进行中" });
|
||||
}
|
||||
if (status === "closed") {
|
||||
return t("settlementPeriods.statusClosed", { defaultValue: "已关账" });
|
||||
return t("agents:settlementPeriods.statusClosed", { defaultValue: "已关账" });
|
||||
}
|
||||
if (status === "completed") {
|
||||
return t("settlementCenter:filters.statusCompleted", { defaultValue: "已结清" });
|
||||
}
|
||||
|
||||
return status;
|
||||
|
||||
@@ -65,22 +65,26 @@ export function AgentSettlementReportsPanel({
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const reportTypeLabel = (type: AgentSettlementReportType): string =>
|
||||
t(`settlementReports.types.${type}`, { defaultValue: type });
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-lg border border-border/60 p-4">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>{t("settlementReports.type", { defaultValue: "报表类型" })}</Label>
|
||||
<Select
|
||||
modal={false}
|
||||
value={reportType}
|
||||
onValueChange={(v) => setReportType(v as AgentSettlementReportType)}
|
||||
>
|
||||
<SelectTrigger className="w-52">
|
||||
<SelectValue />
|
||||
<SelectValue>{() => reportTypeLabel(reportType)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{REPORT_TYPES.map((key) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{t(`settlementReports.types.${key}`, { defaultValue: key })}
|
||||
{reportTypeLabel(key)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
||||
196
src/modules/settlement/settlement-bill-breakdown.tsx
Normal file
196
src/modules/settlement/settlement-bill-breakdown.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { SettlementBillRow } from "@/api/admin-agent-settlement";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
|
||||
import {
|
||||
buildBillAmountBreakdown,
|
||||
describeBillPaymentDirection,
|
||||
resolveBillPartyName,
|
||||
} from "@/modules/settlement/settlement-bill-display";
|
||||
import {
|
||||
settlementBillStatusLabel,
|
||||
settlementBillTypeLabel,
|
||||
} from "@/modules/settlement/settlement-status-label";
|
||||
|
||||
type SettlementBillSummaryHeaderProps = {
|
||||
bill: SettlementBillRow;
|
||||
currencyCode: string;
|
||||
};
|
||||
|
||||
export function SettlementBillSummaryHeader({
|
||||
bill,
|
||||
currencyCode,
|
||||
}: SettlementBillSummaryHeaderProps): React.ReactElement {
|
||||
const { t } = useTranslation(["settlementCenter", "agents"]);
|
||||
const direction = describeBillPaymentDirection(bill, t);
|
||||
const unpaid = bill.unpaid_amount > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-xl border border-border/70 bg-muted/15 p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<AdminStatusBadge status={bill.status}>
|
||||
{settlementBillStatusLabel(bill.status, t)}
|
||||
</AdminStatusBadge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{settlementBillTypeLabel(bill.bill_type, t)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 text-base">
|
||||
<span className="font-semibold text-foreground">{direction.payer}</span>
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2.5 py-0.5 text-xs font-medium text-primary">
|
||||
{t("settlementCenter:billDisplay.pays", { defaultValue: "应付" })}
|
||||
<ArrowRight className="size-3.5" aria-hidden />
|
||||
</span>
|
||||
<span className="font-semibold text-foreground">{direction.payee}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settlementCenter:billDisplay.settlementAmount", { defaultValue: "本期结算金额" })}
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-bold tabular-nums tracking-tight text-foreground">
|
||||
{formatDashboardMoneyMinor(direction.amount, currencyCode)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div className="rounded-lg border border-border/50 bg-background/80 px-3 py-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settlementCenter:columns.paid", { defaultValue: "已收付" })}
|
||||
</p>
|
||||
<p className="mt-0.5 font-medium tabular-nums">
|
||||
{formatDashboardMoneyMinor(bill.paid_amount ?? 0, currencyCode)}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border px-3 py-2",
|
||||
unpaid
|
||||
? "border-amber-200/80 bg-amber-50/80 dark:border-amber-900/50 dark:bg-amber-950/20"
|
||||
: "border-border/50 bg-background/80",
|
||||
)}
|
||||
>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settlementCenter:columns.unpaid", { defaultValue: "未结" })}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-0.5 font-semibold tabular-nums",
|
||||
unpaid && "text-amber-900 dark:text-amber-200",
|
||||
)}
|
||||
>
|
||||
{formatDashboardMoneyMinor(bill.unpaid_amount, currencyCode)}
|
||||
</p>
|
||||
{unpaid ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{bill.status === "pending_confirm"
|
||||
? t("settlementCenter:billDisplay.unpaidPendingConfirm", {
|
||||
defaultValue: "确认账单后可登记收付",
|
||||
})
|
||||
: t("settlementCenter:billDisplay.unpaidAwaitingPayment", {
|
||||
defaultValue: "请登记线下收付",
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-1 text-xs text-emerald-700 dark:text-emerald-400">
|
||||
{t("settlementCenter:billDisplay.fullySettled", { defaultValue: "本期已结清" })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type SettlementBillAmountBreakdownProps = {
|
||||
bill: SettlementBillRow;
|
||||
currencyCode: string;
|
||||
};
|
||||
|
||||
export function SettlementBillAmountBreakdown({
|
||||
bill,
|
||||
currencyCode,
|
||||
}: SettlementBillAmountBreakdownProps): React.ReactElement | null {
|
||||
const { t } = useTranslation(["settlementCenter", "agents"]);
|
||||
const lines = buildBillAmountBreakdown(bill, t);
|
||||
|
||||
if (lines.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-xl border border-border/70 p-4">
|
||||
<p className="font-medium text-foreground">
|
||||
{t("settlementCenter:billDisplay.howAmountWorks", { defaultValue: "金额怎么来的" })}
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
{lines.map((line) => {
|
||||
const prefix =
|
||||
line.kind === "subtract"
|
||||
? "−"
|
||||
: line.kind === "add" && lines.indexOf(line) > 0
|
||||
? "+"
|
||||
: line.kind === "subtotal" || line.kind === "total"
|
||||
? "="
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={line.key}
|
||||
className={cn(
|
||||
"flex items-start justify-between gap-3 text-sm",
|
||||
(line.kind === "subtotal" || line.kind === "total") &&
|
||||
"border-t border-border/60 pt-2 font-medium",
|
||||
line.kind === "total" && "text-base",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<span className="text-muted-foreground">
|
||||
{prefix ? <span className="mr-1.5 tabular-nums">{prefix}</span> : null}
|
||||
{line.label}
|
||||
</span>
|
||||
</div>
|
||||
<span className="shrink-0 tabular-nums">
|
||||
{formatDashboardMoneyMinor(line.amount, currencyCode)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type SettlementBillPartiesRowProps = {
|
||||
bill: SettlementBillRow;
|
||||
};
|
||||
|
||||
export function SettlementBillPartiesRow({ bill }: SettlementBillPartiesRowProps): React.ReactElement {
|
||||
const { t } = useTranslation(["settlementCenter", "agents"]);
|
||||
const owner = resolveBillPartyName(bill, "owner", t);
|
||||
const counterparty = resolveBillPartyName(bill, "counterparty", t);
|
||||
|
||||
return (
|
||||
<div className="grid gap-2 text-sm sm:grid-cols-2">
|
||||
<div className="rounded-lg border border-border/60 bg-muted/15 px-3 py-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settlementCenter:billDisplay.billOwner", { defaultValue: "账单主体" })}
|
||||
</p>
|
||||
<p className="mt-1 font-medium text-foreground">{owner}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border/60 bg-muted/15 px-3 py-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settlementCenter:billDisplay.billCounterparty", { defaultValue: "结算对手" })}
|
||||
</p>
|
||||
<p className="mt-1 font-medium text-foreground">{counterparty}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
320
src/modules/settlement/settlement-bill-display.ts
Normal file
320
src/modules/settlement/settlement-bill-display.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
import type { SettlementBillRow } from "@/api/admin-agent-settlement";
|
||||
|
||||
export type BillPartyRole = "owner" | "counterparty";
|
||||
|
||||
export type BillPaymentDirection = {
|
||||
payer: string;
|
||||
payee: string;
|
||||
ownerOwes: boolean;
|
||||
amount: number;
|
||||
};
|
||||
|
||||
export function billLayerLabel(
|
||||
bill: SettlementBillRow,
|
||||
t: TFunction<["agents", "settlementCenter"]>,
|
||||
): string {
|
||||
if (bill.bill_type === "player") {
|
||||
return t("settlementCenter:billsPanel.layer.player", {
|
||||
defaultValue: "玩家与直属代理结算",
|
||||
});
|
||||
}
|
||||
if (bill.bill_type === "agent") {
|
||||
return t("settlementCenter:billsPanel.layer.agent", {
|
||||
defaultValue: "代理与上级 / 平台结算",
|
||||
});
|
||||
}
|
||||
if (bill.bill_type === "adjustment") {
|
||||
return t("settlementCenter:billsPanel.layer.adjustment", {
|
||||
defaultValue: "结算差异调账",
|
||||
});
|
||||
}
|
||||
if (bill.bill_type === "bad_debt") {
|
||||
return t("settlementCenter:billsPanel.layer.badDebt", {
|
||||
defaultValue: "坏账核销归档",
|
||||
});
|
||||
}
|
||||
if (bill.bill_type === "reversal") {
|
||||
return t("settlementCenter:billsPanel.layer.reversal", {
|
||||
defaultValue: "历史账单冲正",
|
||||
});
|
||||
}
|
||||
|
||||
return t("settlementCenter:billsPanel.layer.generic", {
|
||||
defaultValue: "结算辅助单据",
|
||||
});
|
||||
}
|
||||
|
||||
export function billDirectionHint(
|
||||
bill: SettlementBillRow,
|
||||
t: TFunction<["agents", "settlementCenter"]>,
|
||||
): string {
|
||||
if (bill.bill_type === "player") {
|
||||
return bill.net_amount > 0
|
||||
? t("settlementCenter:billDisplay.flowHint.playerPayAgent", {
|
||||
defaultValue: "玩家应向直属代理结算",
|
||||
})
|
||||
: t("settlementCenter:billDisplay.flowHint.agentPayPlayer", {
|
||||
defaultValue: "直属代理应向玩家结算",
|
||||
});
|
||||
}
|
||||
|
||||
if (bill.bill_type === "agent") {
|
||||
return bill.net_amount > 0
|
||||
? t("settlementCenter:billDisplay.flowHint.agentPayUpstream", {
|
||||
defaultValue: "本级代理应向上级 / 平台结算",
|
||||
})
|
||||
: t("settlementCenter:billDisplay.flowHint.upstreamPayAgent", {
|
||||
defaultValue: "上级 / 平台应向本级代理结算",
|
||||
});
|
||||
}
|
||||
|
||||
if (bill.bill_type === "adjustment") {
|
||||
return t("settlementCenter:billDisplay.flowHint.adjustment", {
|
||||
defaultValue: "补差单独结转,不改变原账单主体关系",
|
||||
});
|
||||
}
|
||||
|
||||
if (bill.bill_type === "bad_debt") {
|
||||
return t("settlementCenter:billDisplay.flowHint.badDebt", {
|
||||
defaultValue: "核销未结金额,并生成坏账归档记录",
|
||||
});
|
||||
}
|
||||
|
||||
if (bill.bill_type === "reversal") {
|
||||
return t("settlementCenter:billDisplay.flowHint.reversal", {
|
||||
defaultValue: "冲正原账单影响,按冲正规则回退",
|
||||
});
|
||||
}
|
||||
|
||||
return t("settlementCenter:billDisplay.flowHint.generic", {
|
||||
defaultValue: "按账单结算关系执行收付或调账",
|
||||
});
|
||||
}
|
||||
|
||||
export type BillBreakdownLine = {
|
||||
key: string;
|
||||
label: string;
|
||||
amount: number;
|
||||
kind: "add" | "subtract" | "subtotal" | "total";
|
||||
hint?: string;
|
||||
};
|
||||
|
||||
export function parseBillMeta(metaJson: SettlementBillRow["meta_json"]): {
|
||||
share_profit?: number;
|
||||
platform_share_profit?: number;
|
||||
} {
|
||||
if (metaJson == null || metaJson === "") {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const parsed =
|
||||
typeof metaJson === "string" ? (JSON.parse(metaJson) as Record<string, unknown>) : metaJson;
|
||||
return {
|
||||
share_profit: parsed.share_profit != null ? Number(parsed.share_profit) : undefined,
|
||||
platform_share_profit:
|
||||
parsed.platform_share_profit != null ? Number(parsed.platform_share_profit) : undefined,
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveBillPartyName(
|
||||
bill: SettlementBillRow,
|
||||
role: BillPartyRole,
|
||||
t: TFunction<["agents", "settlementCenter"]>,
|
||||
): string {
|
||||
const platformLabel = t("agents:settlementBills.platform", { defaultValue: "平台" });
|
||||
const fallbackPartyName = (type: string, id: number): string =>
|
||||
type === "platform" ? platformLabel : `${type}#${id}`;
|
||||
|
||||
if (role === "owner") {
|
||||
if (bill.owner_type === "platform") {
|
||||
return platformLabel;
|
||||
}
|
||||
if (bill.bill_type === "player") {
|
||||
return bill.player_username ?? bill.owner_label ?? fallbackPartyName(bill.owner_type, bill.owner_id);
|
||||
}
|
||||
return bill.owner_party_label ?? bill.owner_label ?? fallbackPartyName(bill.owner_type, bill.owner_id);
|
||||
}
|
||||
|
||||
if (
|
||||
bill.counterparty_type === "platform" ||
|
||||
bill.counterparty_label === "platform" ||
|
||||
bill.superior_agent_label === "platform"
|
||||
) {
|
||||
return platformLabel;
|
||||
}
|
||||
if (bill.bill_type === "player") {
|
||||
return (
|
||||
bill.direct_agent_label ??
|
||||
bill.counterparty_label ??
|
||||
fallbackPartyName(bill.counterparty_type, bill.counterparty_id)
|
||||
);
|
||||
}
|
||||
return (
|
||||
bill.superior_agent_label ??
|
||||
bill.counterparty_label ??
|
||||
fallbackPartyName(bill.counterparty_type, bill.counterparty_id)
|
||||
);
|
||||
}
|
||||
|
||||
export function describeBillPaymentDirection(
|
||||
bill: SettlementBillRow,
|
||||
t: TFunction<["agents", "settlementCenter"]>,
|
||||
): BillPaymentDirection {
|
||||
const owner = resolveBillPartyName(bill, "owner", t);
|
||||
const counterparty = resolveBillPartyName(bill, "counterparty", t);
|
||||
const ownerOwes = bill.net_amount > 0;
|
||||
const amount = Math.abs(bill.net_amount);
|
||||
|
||||
return {
|
||||
payer: ownerOwes ? owner : counterparty,
|
||||
payee: ownerOwes ? counterparty : owner,
|
||||
ownerOwes,
|
||||
amount,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildBillAmountBreakdown(
|
||||
bill: SettlementBillRow,
|
||||
t: TFunction<["agents", "settlementCenter"]>,
|
||||
): BillBreakdownLine[] {
|
||||
const meta = parseBillMeta(bill.meta_json);
|
||||
const gross = bill.gross_win_loss ?? 0;
|
||||
const rebate = bill.rebate_amount ?? 0;
|
||||
const shareProfit = meta.share_profit ?? 0;
|
||||
const rounding = bill.platform_rounding_adjustment ?? 0;
|
||||
const teamNet = gross - rebate;
|
||||
|
||||
if (bill.bill_type === "player") {
|
||||
const lines: BillBreakdownLine[] = [];
|
||||
if (bill.gross_win_loss != null) {
|
||||
lines.push({
|
||||
key: "gross",
|
||||
label: t("settlementCenter:billDisplay.playerGross", { defaultValue: "游戏输赢" }),
|
||||
amount: gross,
|
||||
kind: "add",
|
||||
hint:
|
||||
gross > 0
|
||||
? t("settlementCenter:billDisplay.playerLostHint", { defaultValue: "玩家输了,应付代理" })
|
||||
: gross < 0
|
||||
? t("settlementCenter:billDisplay.playerWonHint", { defaultValue: "玩家赢了,代理应付玩家" })
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
if (bill.rebate_amount != null && rebate !== 0) {
|
||||
lines.push({
|
||||
key: "rebate",
|
||||
label: t("settlementCenter:billDisplay.rebate", { defaultValue: "回水" }),
|
||||
amount: rebate,
|
||||
kind: "subtract",
|
||||
});
|
||||
}
|
||||
if (rounding !== 0) {
|
||||
lines.push({
|
||||
key: "rounding",
|
||||
label: t("agents:settlementBills.platformRounding", { defaultValue: "平台尾差" }),
|
||||
amount: rounding,
|
||||
kind: rounding > 0 ? "subtract" : "add",
|
||||
});
|
||||
}
|
||||
lines.push({
|
||||
key: "net",
|
||||
label:
|
||||
bill.net_amount > 0
|
||||
? t("settlementCenter:billDisplay.playerNet", { defaultValue: "玩家应付净额" })
|
||||
: t("settlementCenter:billDisplay.playerNetReceive", { defaultValue: "代理应付玩家" }),
|
||||
amount: Math.abs(bill.net_amount),
|
||||
kind: "total",
|
||||
});
|
||||
return lines;
|
||||
}
|
||||
|
||||
if (bill.bill_type === "agent") {
|
||||
const owner = resolveBillPartyName(bill, "owner", t);
|
||||
const lines: BillBreakdownLine[] = [];
|
||||
if (bill.gross_win_loss != null) {
|
||||
lines.push({
|
||||
key: "gross",
|
||||
label: t("settlementCenter:billDisplay.teamGross", { defaultValue: "团队游戏输赢" }),
|
||||
amount: gross,
|
||||
kind: "add",
|
||||
hint: t("settlementCenter:billDisplay.teamGrossHint", {
|
||||
defaultValue: "含本级及下级玩家的合计",
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (bill.rebate_amount != null && rebate !== 0) {
|
||||
lines.push({
|
||||
key: "rebate",
|
||||
label: t("settlementCenter:billDisplay.teamRebate", { defaultValue: "团队回水" }),
|
||||
amount: rebate,
|
||||
kind: "subtract",
|
||||
});
|
||||
}
|
||||
if (bill.gross_win_loss != null || bill.rebate_amount != null) {
|
||||
lines.push({
|
||||
key: "team-net",
|
||||
label: t("settlementCenter:billDisplay.teamNet", { defaultValue: "团队净额" }),
|
||||
amount: Math.abs(teamNet),
|
||||
kind: "subtotal",
|
||||
});
|
||||
}
|
||||
if (meta.share_profit != null) {
|
||||
lines.push({
|
||||
key: "share",
|
||||
label: t("settlementCenter:billDisplay.agentShareKeep", {
|
||||
defaultValue: "{{agent}} 本级占成",
|
||||
agent: owner,
|
||||
}),
|
||||
amount: shareProfit,
|
||||
kind: "subtract",
|
||||
hint: t("settlementCenter:billDisplay.agentShareKeepHint", {
|
||||
defaultValue: "本级按占成比例留下的利润",
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (rounding !== 0) {
|
||||
lines.push({
|
||||
key: "rounding",
|
||||
label: t("agents:settlementBills.platformRounding", { defaultValue: "平台尾差" }),
|
||||
amount: rounding,
|
||||
kind: rounding > 0 ? "subtract" : "add",
|
||||
});
|
||||
}
|
||||
lines.push({
|
||||
key: "net",
|
||||
label:
|
||||
bill.net_amount > 0
|
||||
? t("settlementCenter:billDisplay.agentNet", {
|
||||
defaultValue: "{{agent}} 应付上级",
|
||||
agent: owner,
|
||||
})
|
||||
: t("settlementCenter:billDisplay.agentNetReceive", {
|
||||
defaultValue: "上级应付 {{agent}}",
|
||||
agent: owner,
|
||||
}),
|
||||
amount: Math.abs(bill.net_amount),
|
||||
kind: "total",
|
||||
});
|
||||
return lines;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export function billGrossColumnHint(
|
||||
bill: SettlementBillRow,
|
||||
t: TFunction<["agents", "settlementCenter"]>,
|
||||
): string | undefined {
|
||||
if (bill.bill_type === "player") {
|
||||
return t("settlementCenter:billDisplay.playerGrossShort", { defaultValue: "玩家" });
|
||||
}
|
||||
if (bill.bill_type === "agent") {
|
||||
return t("settlementCenter:billDisplay.teamGrossShort", { defaultValue: "团队" });
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
getSettlementBills,
|
||||
type SettlementBillListScope,
|
||||
type SettlementBillRow,
|
||||
} from "@/api/admin-agent-settlement";
|
||||
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import type { AgentSettlementPeriodFilter } from "@/modules/settlement/agent-settlement-period-select";
|
||||
import { SettlementBillsTable } from "@/modules/settlement/settlement-bills-table";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
|
||||
export type BillCategory = "all" | "player" | "agent" | "pending_confirm" | "awaiting_payment";
|
||||
|
||||
const CATEGORY_OPTIONS: { value: BillCategory; labelKey: string }[] = [
|
||||
{ value: "all", labelKey: "billsPanel.category.all" },
|
||||
{ value: "player", labelKey: "billsPanel.category.player" },
|
||||
{ value: "agent", labelKey: "billsPanel.category.agent" },
|
||||
{ value: "pending_confirm", labelKey: "billsPanel.category.pendingConfirm" },
|
||||
{ value: "awaiting_payment", labelKey: "billsPanel.category.awaitingPayment" },
|
||||
];
|
||||
|
||||
function categoryQuery(category: BillCategory): {
|
||||
bill_type?: string;
|
||||
scope?: SettlementBillListScope;
|
||||
} {
|
||||
switch (category) {
|
||||
case "player":
|
||||
return { bill_type: "player" };
|
||||
case "agent":
|
||||
return { bill_type: "agent" };
|
||||
case "pending_confirm":
|
||||
return { scope: "pending_confirm" };
|
||||
case "awaiting_payment":
|
||||
return { scope: "awaiting_payment" };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
type SettlementBillsPanelProps = {
|
||||
adminSiteId: number;
|
||||
periodFilter: AgentSettlementPeriodFilter;
|
||||
currencyCode: string;
|
||||
onOpenDetail: (billId: number) => void;
|
||||
initialCategory?: BillCategory;
|
||||
refreshKey?: number;
|
||||
};
|
||||
|
||||
export function SettlementBillsPanel({
|
||||
adminSiteId,
|
||||
periodFilter,
|
||||
currencyCode,
|
||||
onOpenDetail,
|
||||
initialCategory = "all",
|
||||
refreshKey = 0,
|
||||
}: SettlementBillsPanelProps): React.ReactElement {
|
||||
const { t } = useTranslation("settlementCenter");
|
||||
const [category, setCategory] = useState<BillCategory>(initialCategory);
|
||||
|
||||
useEffect(() => {
|
||||
setCategory(initialCategory);
|
||||
}, [initialCategory]);
|
||||
const [rows, setRows] = useState<SettlementBillRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const periodId = periodFilter === "all" ? undefined : periodFilter;
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const q = categoryQuery(category);
|
||||
const data = await getSettlementBills({
|
||||
admin_site_id: adminSiteId,
|
||||
settlement_period_id: periodId,
|
||||
bill_type: q.bill_type,
|
||||
scope: q.scope,
|
||||
});
|
||||
setRows(data.items ?? []);
|
||||
} catch (err: unknown) {
|
||||
setRows([]);
|
||||
toast.error(
|
||||
err instanceof LotteryApiBizError
|
||||
? err.message
|
||||
: t("errors.loadBills", { defaultValue: "账单加载失败" }),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [adminSiteId, category, periodId, t]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [load, refreshKey]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("billsPanel.intro", {
|
||||
defaultValue: "关账后生成的占成账单;可按类型与状态筛选,行内打开详情进行确认与收付。",
|
||||
})}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5 border-b border-border/60 pb-3">
|
||||
{CATEGORY_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setCategory(opt.value)}
|
||||
className={cn(
|
||||
"rounded-full border px-3 py-1 text-xs font-medium transition-colors",
|
||||
category === opt.value
|
||||
? "border-primary/40 bg-primary/10 text-foreground"
|
||||
: "border-transparent bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t(opt.labelKey, { defaultValue: opt.value })}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading && rows.length === 0 ? (
|
||||
<AdminLoadingState />
|
||||
) : (
|
||||
<SettlementBillsTable
|
||||
rows={rows}
|
||||
loading={loading}
|
||||
currencyCode={currencyCode}
|
||||
onOpenDetail={onOpenDetail}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowRight, Eye } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { SettlementBillRow } from "@/api/admin-agent-settlement";
|
||||
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatSettlementPeriodSpan } from "@/lib/agent-settlement-period-range";
|
||||
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
|
||||
import { PlayerFundingModeBadge } from "@/components/admin/player-funding-badges";
|
||||
import {
|
||||
describeBillPaymentDirection,
|
||||
} from "@/modules/settlement/settlement-bill-display";
|
||||
import {
|
||||
formatPlatformPartyLabel,
|
||||
SettlementDashCell,
|
||||
} from "@/modules/settlement/settlement-party-cells";
|
||||
import {
|
||||
settlementBillStatusLabel,
|
||||
settlementBillTypeLabel,
|
||||
@@ -21,27 +31,125 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
type BillTypeFilter = "all" | "player" | "agent";
|
||||
|
||||
type SettlementBillsTableProps = {
|
||||
rows: SettlementBillRow[];
|
||||
loading: boolean;
|
||||
currencyCode: string;
|
||||
billTypeFilter?: BillTypeFilter;
|
||||
emptyMessage?: string;
|
||||
onOpenDetail: (billId: number) => void;
|
||||
};
|
||||
|
||||
function billRowTone(row: SettlementBillRow): string {
|
||||
if (row.bill_type === "player") {
|
||||
return "border-l-2 border-l-sky-300/80";
|
||||
}
|
||||
if (row.bill_type === "agent") {
|
||||
return "border-l-2 border-l-amber-300/80 bg-amber-50/20";
|
||||
}
|
||||
if (row.bill_type === "adjustment" || row.bill_type === "reversal") {
|
||||
return "border-l-2 border-l-emerald-300/80 bg-emerald-50/20";
|
||||
}
|
||||
if (row.bill_type === "bad_debt") {
|
||||
return "border-l-2 border-l-rose-300/80 bg-rose-50/20";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function billTypeTone(row: SettlementBillRow): string {
|
||||
if (row.bill_type === "player") {
|
||||
return "border-sky-200 bg-sky-50 text-sky-700";
|
||||
}
|
||||
if (row.bill_type === "agent") {
|
||||
return "border-amber-200 bg-amber-50 text-amber-800";
|
||||
}
|
||||
if (row.bill_type === "adjustment" || row.bill_type === "reversal") {
|
||||
return "border-emerald-200 bg-emerald-50 text-emerald-700";
|
||||
}
|
||||
if (row.bill_type === "bad_debt") {
|
||||
return "border-rose-200 bg-rose-50 text-rose-700";
|
||||
}
|
||||
|
||||
return "border-border/70 bg-muted/25 text-muted-foreground";
|
||||
}
|
||||
|
||||
function signedMoneyClass(amount: number, emphasize = false): string {
|
||||
if (amount < 0) {
|
||||
return cn("text-destructive", emphasize && "font-medium");
|
||||
}
|
||||
if (amount > 0) {
|
||||
return cn("text-emerald-700", emphasize && "font-medium");
|
||||
}
|
||||
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
|
||||
function formatSignedMoney(amount: number, currencyCode: string): string {
|
||||
if (amount === 0) {
|
||||
return formatDashboardMoneyMinor(0, currencyCode);
|
||||
}
|
||||
|
||||
const prefix = amount < 0 ? "−" : "+";
|
||||
return `${prefix}${formatDashboardMoneyMinor(Math.abs(amount), currencyCode)}`;
|
||||
}
|
||||
|
||||
function unpaidMoneyClass(row: SettlementBillRow): string {
|
||||
if (row.unpaid_amount <= 0) {
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
if (row.status === "overdue") {
|
||||
return "font-medium text-destructive";
|
||||
}
|
||||
|
||||
return "font-medium text-amber-800 dark:text-amber-300";
|
||||
}
|
||||
|
||||
function ownerPartyLabel(row: SettlementBillRow): string | null {
|
||||
if (row.bill_type === "player") {
|
||||
return row.player_username ?? row.owner_label ?? null;
|
||||
}
|
||||
if (row.bill_type === "agent") {
|
||||
return row.owner_party_label ?? row.owner_label ?? null;
|
||||
}
|
||||
|
||||
return row.owner_label ?? null;
|
||||
}
|
||||
|
||||
function fundingModeHint(row: SettlementBillRow, t: (key: string, options?: Record<string, unknown>) => string) {
|
||||
if (row.owner_funding_mode !== "credit") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="rounded-full border border-border/70 bg-muted/30 px-1.5 py-0.5 text-[11px] font-normal leading-none text-muted-foreground">
|
||||
{t("columns.creditMode", { defaultValue: "信用盘" })}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettlementBillsTable({
|
||||
rows,
|
||||
loading,
|
||||
currencyCode,
|
||||
billTypeFilter = "all",
|
||||
emptyMessage,
|
||||
onOpenDetail,
|
||||
}: SettlementBillsTableProps): React.ReactElement {
|
||||
const { t } = useTranslation(["settlementCenter", "agents", "common"]);
|
||||
|
||||
const agentView = billTypeFilter === "agent";
|
||||
const playerView = billTypeFilter === "player";
|
||||
const mixedView = billTypeFilter === "all";
|
||||
|
||||
if (loading) {
|
||||
return <AdminLoadingState />;
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return <AdminNoResourceState />;
|
||||
return <AdminNoResourceState message={emptyMessage} />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -49,69 +157,153 @@ export function SettlementBillsTable({
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("columns.billId", { defaultValue: "账单 ID" })}</TableHead>
|
||||
<TableHead>{t("columns.period", { defaultValue: "账期" })}</TableHead>
|
||||
<TableHead>{t("columns.type", { defaultValue: "类型" })}</TableHead>
|
||||
<TableHead>{t("columns.owner", { defaultValue: "本方" })}</TableHead>
|
||||
<TableHead>{t("columns.counterparty", { defaultValue: "对方" })}</TableHead>
|
||||
<TableHead className="text-right">{t("columns.gross", { defaultValue: "输赢" })}</TableHead>
|
||||
<TableHead className="text-right">{t("columns.net", { defaultValue: "净额" })}</TableHead>
|
||||
{playerView ? (
|
||||
<>
|
||||
<TableHead>{t("columns.playerAccount", { defaultValue: "玩家账号" })}</TableHead>
|
||||
<TableHead>{t("columns.playerId", { defaultValue: "玩家 ID" })}</TableHead>
|
||||
<TableHead>{t("columns.directAgent", { defaultValue: "直属代理" })}</TableHead>
|
||||
</>
|
||||
) : null}
|
||||
{agentView ? (
|
||||
<TableHead>{t("columns.owner", { defaultValue: "本方" })}</TableHead>
|
||||
) : null}
|
||||
{mixedView ? (
|
||||
<TableHead>{t("columns.owner", { defaultValue: "本方" })}</TableHead>
|
||||
) : null}
|
||||
<TableHead>{t("billDisplay.settlementFlow", { defaultValue: "谁付谁" })}</TableHead>
|
||||
<TableHead>{t("columns.superiorAgent", { defaultValue: "上级" })}</TableHead>
|
||||
{!playerView ? (
|
||||
<TableHead className="text-right">{t("columns.gross", { defaultValue: "输赢" })}</TableHead>
|
||||
) : null}
|
||||
<TableHead className="text-right">{t("billDisplay.settlementAmount", { defaultValue: "结算金额" })}</TableHead>
|
||||
<TableHead className="text-right">{t("columns.paid", { defaultValue: "已收付" })}</TableHead>
|
||||
<TableHead className="text-right">{t("columns.unpaid", { defaultValue: "未结" })}</TableHead>
|
||||
<TableHead>{t("columns.status", { defaultValue: "状态" })}</TableHead>
|
||||
<TableHead />
|
||||
<TableHead className="sticky right-0 z-10 w-14 bg-muted text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
{t("common:table.actions", { defaultValue: "操作" })}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
{formatSettlementPeriodSpan(row.period_start, row.period_end)}
|
||||
</TableCell>
|
||||
<TableCell>{settlementBillTypeLabel(row.bill_type, t)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span>{row.owner_label ?? `${row.owner_type}#${row.owner_id}`}</span>
|
||||
{row.owner_type === "player" && row.owner_funding_mode ? (
|
||||
<PlayerFundingModeBadge
|
||||
row={{
|
||||
funding_mode: row.owner_funding_mode,
|
||||
uses_credit: row.owner_funding_mode === "credit",
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{row.counterparty_label === "platform"
|
||||
? t("agents:settlementBills.platform", { defaultValue: "平台" })
|
||||
: row.counterparty_label ?? `${row.counterparty_type}#${row.counterparty_id}`}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-muted-foreground">
|
||||
{row.gross_win_loss != null
|
||||
? formatDashboardMoneyMinor(row.gross_win_loss, currencyCode)
|
||||
: "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{formatDashboardMoneyMinor(row.net_amount, currencyCode)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-muted-foreground">
|
||||
{formatDashboardMoneyMinor(row.paid_amount ?? 0, currencyCode)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{formatDashboardMoneyMinor(row.unpaid_amount, currencyCode)}
|
||||
</TableCell>
|
||||
<TableCell>{settlementBillStatusLabel(row.status, t)}</TableCell>
|
||||
<TableCell>
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-primary underline"
|
||||
onClick={() => onOpenDetail(row.id)}
|
||||
{rows.map((row) => {
|
||||
const isPlayerBill = row.bill_type === "player";
|
||||
const direction = describeBillPaymentDirection(row, t);
|
||||
|
||||
return (
|
||||
<TableRow key={row.id} className={billRowTone(row)}>
|
||||
<TableCell className="font-mono text-xs">{row.id}</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
{formatSettlementPeriodSpan(row.period_start, row.period_end)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex rounded-full border px-2 py-0.5 text-xs font-medium",
|
||||
billTypeTone(row),
|
||||
)}
|
||||
>
|
||||
{settlementBillTypeLabel(row.bill_type, t)}
|
||||
</span>
|
||||
</TableCell>
|
||||
{playerView ? (
|
||||
<>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<SettlementDashCell value={row.player_username ?? row.owner_label} />
|
||||
{fundingModeHint(row, t)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
<SettlementDashCell
|
||||
value={row.player_site_player_id ?? row.player_id_display ?? row.owner_id}
|
||||
mono
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
<SettlementDashCell value={row.direct_agent_label} />
|
||||
</TableCell>
|
||||
</>
|
||||
) : null}
|
||||
{agentView ? (
|
||||
<TableCell className="text-sm">
|
||||
<SettlementDashCell value={ownerPartyLabel(row)} />
|
||||
</TableCell>
|
||||
) : null}
|
||||
{mixedView ? (
|
||||
<TableCell className="text-sm">
|
||||
{isPlayerBill ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<SettlementDashCell value={ownerPartyLabel(row)} />
|
||||
{fundingModeHint(row, t)}
|
||||
</div>
|
||||
) : (
|
||||
<SettlementDashCell value={ownerPartyLabel(row)} />
|
||||
)}
|
||||
</TableCell>
|
||||
) : null}
|
||||
<TableCell className="min-w-[10rem] text-sm">
|
||||
<div className="flex flex-wrap items-center gap-1 text-foreground">
|
||||
<span className="font-medium">{direction.payer}</span>
|
||||
<ArrowRight className="size-3.5 shrink-0 text-muted-foreground" aria-hidden />
|
||||
<span className="font-medium">{direction.payee}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{formatPlatformPartyLabel(row.superior_agent_label, t)}
|
||||
</TableCell>
|
||||
{!playerView ? (
|
||||
<TableCell
|
||||
className={cn(
|
||||
"text-right tabular-nums",
|
||||
row.gross_win_loss != null
|
||||
? signedMoneyClass(row.gross_win_loss)
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{row.gross_win_loss != null ? (
|
||||
<div>{formatSignedMoney(row.gross_win_loss, currencyCode)}</div>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</TableCell>
|
||||
) : null}
|
||||
<TableCell className="text-right tabular-nums">
|
||||
<div className={cn("font-semibold", signedMoneyClass(row.net_amount, true))}>
|
||||
{formatDashboardMoneyMinor(direction.amount, currencyCode)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-muted-foreground">
|
||||
{formatDashboardMoneyMinor(row.paid_amount ?? 0, currencyCode)}
|
||||
</TableCell>
|
||||
<TableCell className={cn("text-right tabular-nums", unpaidMoneyClass(row))}>
|
||||
{formatDashboardMoneyMinor(row.unpaid_amount, currencyCode)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<AdminStatusBadge status={row.status}>
|
||||
{settlementBillStatusLabel(row.status, t)}
|
||||
</AdminStatusBadge>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{t("actions.detail", { defaultValue: "详情 / 收付" })}
|
||||
</button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
<AdminRowActionsMenu
|
||||
actions={[
|
||||
{
|
||||
key: "detail",
|
||||
label: t("actions.detail", { defaultValue: "详情" }),
|
||||
icon: Eye,
|
||||
onClick: () => onOpenDetail(row.id),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
35
src/modules/settlement/settlement-center-nav.ts
Normal file
35
src/modules/settlement/settlement-center-nav.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export type SettlementPeriodView = "bills" | "ledger";
|
||||
|
||||
const VALID_VIEWS: SettlementPeriodView[] = ["bills", "ledger"];
|
||||
|
||||
export function settlementCenterListHref(): string {
|
||||
return "/admin/settlement-center";
|
||||
}
|
||||
|
||||
export function settlementPeriodViewHref(
|
||||
periodId: number,
|
||||
view: SettlementPeriodView = "bills",
|
||||
): string {
|
||||
return `/admin/settlement-center?period=${periodId}&view=${view}`;
|
||||
}
|
||||
|
||||
export function parseSettlementCenterView(
|
||||
periodRaw: string | null,
|
||||
viewRaw: string | null,
|
||||
): { periodId: number | null; view: SettlementPeriodView } {
|
||||
const periodId = periodRaw !== null && periodRaw !== "" ? Number(periodRaw) : NaN;
|
||||
const normalizedView = viewRaw === "reports" ? "bills" : viewRaw;
|
||||
const view =
|
||||
normalizedView !== null && VALID_VIEWS.includes(normalizedView as SettlementPeriodView)
|
||||
? (normalizedView as SettlementPeriodView)
|
||||
: "bills";
|
||||
|
||||
return {
|
||||
periodId: Number.isInteger(periodId) && periodId > 0 ? periodId : null,
|
||||
view,
|
||||
};
|
||||
}
|
||||
|
||||
export function isSettlementPeriodView(value: string): value is SettlementPeriodView {
|
||||
return VALID_VIEWS.includes(value as SettlementPeriodView);
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type SettlementCenterSection =
|
||||
| "overview"
|
||||
| "periods"
|
||||
| "ledger"
|
||||
| "bills";
|
||||
|
||||
type TabDef = {
|
||||
key: SettlementCenterSection;
|
||||
labelKey: string;
|
||||
defaultLabel: string;
|
||||
group: "hub" | "finance";
|
||||
badge?: string;
|
||||
};
|
||||
|
||||
type SettlementCenterNavProps = {
|
||||
active: SettlementCenterSection;
|
||||
onChange: (section: SettlementCenterSection) => void;
|
||||
counts: {
|
||||
pendingConfirm: number;
|
||||
awaitingPayment: number;
|
||||
};
|
||||
siteSelector?: React.ReactNode;
|
||||
};
|
||||
|
||||
const TABS: TabDef[] = [
|
||||
{ key: "overview", labelKey: "nav.overview", defaultLabel: "概览", group: "hub" },
|
||||
{ key: "periods", labelKey: "nav.periods", defaultLabel: "账期管理", group: "hub" },
|
||||
{ key: "ledger", labelKey: "nav.ledger", defaultLabel: "账务流水", group: "finance" },
|
||||
{
|
||||
key: "bills",
|
||||
labelKey: "nav.bills",
|
||||
defaultLabel: "账单",
|
||||
group: "finance",
|
||||
},
|
||||
];
|
||||
|
||||
export function SettlementCenterNav({
|
||||
active,
|
||||
onChange,
|
||||
counts,
|
||||
siteSelector,
|
||||
}: SettlementCenterNavProps): React.ReactElement {
|
||||
const { t } = useTranslation("settlementCenter");
|
||||
|
||||
const billBadge =
|
||||
counts.pendingConfirm + counts.awaitingPayment > 0
|
||||
? String(counts.pendingConfirm + counts.awaitingPayment)
|
||||
: undefined;
|
||||
|
||||
const hubTabs = TABS.filter((tab) => tab.group === "hub");
|
||||
const financeTabs = TABS.filter((tab) => tab.group === "finance");
|
||||
|
||||
function renderTab(tab: TabDef, showSeparatorBefore: boolean): React.ReactElement {
|
||||
const isActive = active === tab.key;
|
||||
const badge = tab.key === "bills" ? billBadge : tab.badge;
|
||||
|
||||
return (
|
||||
<span key={tab.key} className="inline-flex items-center">
|
||||
{showSeparatorBefore ? (
|
||||
<span className="mx-1 hidden h-5 w-px bg-border/80 sm:inline-block" aria-hidden />
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(tab.key)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t(tab.labelKey, { defaultValue: tab.defaultLabel })}
|
||||
{badge ? (
|
||||
<span className="rounded-full bg-amber-100 px-1.5 py-0.5 text-xs font-semibold tabular-nums text-amber-900">
|
||||
{badge}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-wrap items-center justify-between gap-3 rounded-lg bg-muted/50 p-1">
|
||||
<nav
|
||||
aria-label={t("subnav.label", { defaultValue: "结算中心导航" })}
|
||||
className="inline-flex max-w-full flex-wrap items-center gap-1"
|
||||
>
|
||||
{hubTabs.map((tab) => renderTab(tab, false))}
|
||||
{financeTabs.map((tab, index) => renderTab(tab, index === 0))}
|
||||
</nav>
|
||||
{siteSelector ?? null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
src/modules/settlement/settlement-center-period-detail.tsx
Normal file
111
src/modules/settlement/settlement-center-period-detail.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { SettlementPeriodRow } from "@/api/admin-agent-settlement";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { SettlementCreditLedgerPanel } from "@/modules/settlement/settlement-credit-ledger-panel";
|
||||
import { SettlementMainPanel } from "@/modules/settlement/settlement-main-panel";
|
||||
import {
|
||||
settlementCenterListHref,
|
||||
settlementPeriodViewHref,
|
||||
type SettlementPeriodView,
|
||||
} from "@/modules/settlement/settlement-center-nav";
|
||||
import { formatSettlementPeriodSpan } from "@/lib/agent-settlement-period-range";
|
||||
import { settlementPeriodStatusLabel } from "@/modules/settlement/settlement-status-label";
|
||||
import { AdminSubnav, AdminSubnavLink } from "@/components/admin/admin-subnav";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type SettlementCenterPeriodDetailProps = {
|
||||
period: SettlementPeriodRow;
|
||||
view: SettlementPeriodView;
|
||||
adminSiteId: number;
|
||||
currencyCode: string;
|
||||
canOperateBills: boolean;
|
||||
refreshKey: number;
|
||||
onOpenBillDetail: (billId: number) => void;
|
||||
};
|
||||
|
||||
export function SettlementCenterPeriodDetail({
|
||||
period,
|
||||
view,
|
||||
adminSiteId,
|
||||
currencyCode,
|
||||
canOperateBills,
|
||||
refreshKey,
|
||||
onOpenBillDetail,
|
||||
}: SettlementCenterPeriodDetailProps): React.ReactElement {
|
||||
const { t } = useTranslation("settlementCenter");
|
||||
|
||||
const subViews: { key: SettlementPeriodView; label: string }[] = [
|
||||
{ key: "bills", label: t("nav.bills", { defaultValue: "账单" }) },
|
||||
{ key: "ledger", label: t("nav.ledger", { defaultValue: "账务流水" }) },
|
||||
];
|
||||
|
||||
const pendingConfirm = period.summary?.pending_confirm ?? 0;
|
||||
const awaitingPayment = period.summary?.awaiting_payment ?? 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
<Link
|
||||
href={settlementCenterListHref()}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "h-8 w-fit px-2")}
|
||||
>
|
||||
<ArrowLeft className="size-4" aria-hidden />
|
||||
{t("periodDetail.back", { defaultValue: "返回账期列表" })}
|
||||
</Link>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-base font-semibold tracking-tight">
|
||||
{formatSettlementPeriodSpan(period.period_start, period.period_end)}
|
||||
</h2>
|
||||
<AdminStatusBadge status={period.status}>
|
||||
{settlementPeriodStatusLabel(period.status, t)}
|
||||
</AdminStatusBadge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AdminSubnav aria-label={t("nav.aria", { defaultValue: "账期视图" })}>
|
||||
{subViews.map((item) => (
|
||||
<AdminSubnavLink
|
||||
key={item.key}
|
||||
href={settlementPeriodViewHref(period.id, item.key)}
|
||||
active={view === item.key}
|
||||
>
|
||||
{item.label}
|
||||
</AdminSubnavLink>
|
||||
))}
|
||||
</AdminSubnav>
|
||||
|
||||
{view === "bills" ? (
|
||||
<SettlementMainPanel
|
||||
key={`${adminSiteId}-${period.id}-${refreshKey}`}
|
||||
adminSiteId={adminSiteId}
|
||||
currencyCode={currencyCode}
|
||||
periodFilter={period.id}
|
||||
onOpenBillDetail={onOpenBillDetail}
|
||||
refreshKey={refreshKey}
|
||||
pendingConfirm={pendingConfirm}
|
||||
awaitingPayment={awaitingPayment}
|
||||
selectedPeriodStatus={period.status}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{view === "ledger" ? (
|
||||
<SettlementCreditLedgerPanel
|
||||
key={`${adminSiteId}-${period.id}-${refreshKey}`}
|
||||
adminSiteId={adminSiteId}
|
||||
settlementPeriodId={period.id}
|
||||
currencyCode={currencyCode}
|
||||
refreshKey={refreshKey}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CalendarClock, CircleDollarSign, ClipboardCheck, Landmark } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { getSettlementPeriods, type SettlementPeriodRow } from "@/api/admin-agent-settlement";
|
||||
import { getAdminIntegrationSites } from "@/api/admin-integration-sites";
|
||||
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { AgentBillDetail } from "@/modules/settlement/agent-bill-detail";
|
||||
import { AgentPeriodsConsole } from "@/modules/settlement/agent-periods-console";
|
||||
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
|
||||
import type { AgentSettlementPeriodFilter } from "@/modules/settlement/agent-settlement-period-select";
|
||||
import { SettlementCenterPeriodDetail } from "@/modules/settlement/settlement-center-period-detail";
|
||||
import {
|
||||
SettlementCenterNav,
|
||||
type SettlementCenterSection,
|
||||
parseSettlementCenterView,
|
||||
settlementPeriodViewHref,
|
||||
type SettlementPeriodView,
|
||||
} from "@/modules/settlement/settlement-center-nav";
|
||||
import {
|
||||
SettlementBillsPanel,
|
||||
type BillCategory,
|
||||
} from "@/modules/settlement/settlement-bills-panel";
|
||||
import { SettlementLedgerPanel } from "@/modules/settlement/settlement-ledger-panel";
|
||||
import { SettlementPeriodToolbar } from "@/modules/settlement/settlement-period-toolbar";
|
||||
import { formatSettlementPeriodSpan } from "@/lib/agent-settlement-period-range";
|
||||
import { SettlementPeriodWorkbench } from "@/modules/settlement/settlement-period-workbench";
|
||||
import { formatAdminSiteLabel } from "@/lib/admin-site-display";
|
||||
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
||||
import { PRD_SETTLEMENT_AGENT_MANAGE } from "@/lib/admin-prd";
|
||||
import {
|
||||
@@ -44,62 +35,33 @@ import { useAdminProfile } from "@/stores/admin-session";
|
||||
|
||||
type SiteOption = { id: number; label: string; currency_code: string };
|
||||
|
||||
function pickDefaultPeriodId(periods: SettlementPeriodRow[]): number | "all" {
|
||||
const closed = periods
|
||||
.filter((row) => row.status === "closed" || row.status === "completed")
|
||||
.sort((a, b) => b.id - a.id);
|
||||
if (closed[0]) {
|
||||
return closed[0].id;
|
||||
}
|
||||
const open = periods.filter((row) => row.status === "open").sort((a, b) => b.id - a.id);
|
||||
if (open[0]) {
|
||||
return open[0].id;
|
||||
}
|
||||
return "all";
|
||||
}
|
||||
|
||||
function sectionTitle(
|
||||
section: SettlementCenterSection,
|
||||
t: ReturnType<typeof useTranslation<["settlementCenter", "agents", "common"]>>["t"],
|
||||
): string {
|
||||
switch (section) {
|
||||
case "overview":
|
||||
return t("panels.overview.title", { defaultValue: "结算概览" });
|
||||
case "periods":
|
||||
return t("nav.periods", { defaultValue: "账期管理" });
|
||||
case "ledger":
|
||||
return t("panels.ledger.title", { defaultValue: "账务流水" });
|
||||
case "bills":
|
||||
return t("panels.bills.title", { defaultValue: "账单" });
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function SettlementCenterShell(): React.ReactElement {
|
||||
const { t } = useTranslation(["settlementCenter", "agents", "common"]);
|
||||
const { t } = useTranslation(["settlementCenter", "common"]);
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const profile = useAdminProfile();
|
||||
const boundAgent = profile?.agent ?? null;
|
||||
|
||||
const canManagePeriods =
|
||||
const { periodId: activePeriodId, view: activeView } = parseSettlementCenterView(
|
||||
searchParams.get("period"),
|
||||
searchParams.get("view"),
|
||||
);
|
||||
|
||||
const canOperateBills =
|
||||
profile?.is_super_admin === true ||
|
||||
adminHasAnyPermission(profile?.permissions, [PRD_SETTLEMENT_AGENT_MANAGE]);
|
||||
const canManagePeriods = canOperateBills && boundAgent === null;
|
||||
|
||||
const [activeSection, setActiveSection] = useState<SettlementCenterSection>("overview");
|
||||
const [siteOptions, setSiteOptions] = useState<SiteOption[]>([]);
|
||||
const [adminSiteId, setAdminSiteId] = useState<number | null>(null);
|
||||
const [periods, setPeriods] = useState<SettlementPeriodRow[]>([]);
|
||||
const [periodFilter, setPeriodFilter] = useState<AgentSettlementPeriodFilter>("all");
|
||||
const [periodFilterReady, setPeriodFilterReady] = useState(false);
|
||||
const [periodsReady, setPeriodsReady] = useState(false);
|
||||
const [detailBillId, setDetailBillId] = useState<number | null>(null);
|
||||
const [billsInitialCategory, setBillsInitialCategory] = useState<BillCategory>("all");
|
||||
const [listRevision, setListRevision] = useState(0);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (boundAgent?.admin_site_id) {
|
||||
const label = boundAgent.name
|
||||
? `${boundAgent.name} (${boundAgent.site_code || boundAgent.code})`
|
||||
: boundAgent.code;
|
||||
const label = formatAdminSiteLabel(boundAgent.name, boundAgent.site_code ?? boundAgent.code);
|
||||
setSiteOptions([{ id: boundAgent.admin_site_id, label, currency_code: "NPR" }]);
|
||||
setAdminSiteId(boundAgent.admin_site_id);
|
||||
return;
|
||||
@@ -108,7 +70,7 @@ export function SettlementCenterShell(): React.ReactElement {
|
||||
void getAdminIntegrationSites().then((sites) => {
|
||||
const options = (sites.items ?? []).map((site) => ({
|
||||
id: site.id,
|
||||
label: site.name ? `${site.name} (${site.code})` : site.code,
|
||||
label: formatAdminSiteLabel(site.name, site.code),
|
||||
currency_code: site.currency_code ?? "NPR",
|
||||
}));
|
||||
setSiteOptions(options);
|
||||
@@ -118,319 +80,140 @@ export function SettlementCenterShell(): React.ReactElement {
|
||||
});
|
||||
}, [adminSiteId, boundAgent]);
|
||||
|
||||
const loadPeriods = useCallback(async () => {
|
||||
if (adminSiteId === null) {
|
||||
setPeriods([]);
|
||||
return;
|
||||
const siteId = adminSiteId ?? siteOptions[0]?.id ?? null;
|
||||
const siteLabel = siteOptions.find((s) => s.id === siteId)?.label ?? null;
|
||||
const currency = siteOptions.find((s) => s.id === siteId)?.currency_code ?? "NPR";
|
||||
|
||||
const loadPeriods = useCallback(async (): Promise<SettlementPeriodRow[]> => {
|
||||
if (siteId === null) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const data = await getSettlementPeriods({ admin_site_id: adminSiteId });
|
||||
setPeriods(data.items ?? []);
|
||||
const data = await getSettlementPeriods({ admin_site_id: siteId });
|
||||
const items = data.items ?? [];
|
||||
setPeriods(items);
|
||||
setPeriodsReady(true);
|
||||
return items;
|
||||
} catch {
|
||||
setPeriods([]);
|
||||
toast.error(t("periods.loadFailed", { defaultValue: "账期列表加载失败" }));
|
||||
setPeriodsReady(true);
|
||||
toast.error(t("periods.loadFailed", { defaultValue: "账期加载失败" }));
|
||||
return [];
|
||||
}
|
||||
}, [adminSiteId, t]);
|
||||
}, [siteId, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (canManagePeriods || adminSiteId === null) {
|
||||
return;
|
||||
}
|
||||
setPeriodsReady(false);
|
||||
void loadPeriods();
|
||||
}, [adminSiteId, canManagePeriods, loadPeriods]);
|
||||
}, [loadPeriods]);
|
||||
|
||||
const handlePeriodsChange = useCallback((items: SettlementPeriodRow[]) => {
|
||||
setPeriods(items);
|
||||
}, []);
|
||||
const activePeriod =
|
||||
activePeriodId !== null ? (periods.find((row) => row.id === activePeriodId) ?? null) : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (periodFilterReady || adminSiteId === null) {
|
||||
return;
|
||||
}
|
||||
setPeriodFilter(periods.length === 0 ? "all" : pickDefaultPeriodId(periods));
|
||||
setPeriodFilterReady(true);
|
||||
}, [adminSiteId, periodFilterReady, periods]);
|
||||
|
||||
const activeCurrency =
|
||||
siteOptions.find((site) => site.id === adminSiteId)?.currency_code ?? "NPR";
|
||||
const openPeriod = useMemo(
|
||||
() => periods.filter((row) => row.status === "open").sort((a, b) => b.id - a.id)[0] ?? null,
|
||||
[periods],
|
||||
);
|
||||
const summaryTotals = useMemo(
|
||||
() =>
|
||||
periods.reduce(
|
||||
(acc, row) => {
|
||||
acc.pendingConfirm += row.summary?.pending_confirm ?? 0;
|
||||
acc.awaitingPayment += row.summary?.awaiting_payment ?? 0;
|
||||
acc.totalUnpaid += row.summary?.total_unpaid ?? 0;
|
||||
return acc;
|
||||
},
|
||||
{ pendingConfirm: 0, awaitingPayment: 0, totalUnpaid: 0 },
|
||||
),
|
||||
[periods],
|
||||
);
|
||||
|
||||
const handlePeriodClosed = useCallback(
|
||||
(result?: { unsettled_ticket_count?: number }) => {
|
||||
void loadPeriods();
|
||||
setActiveSection("bills");
|
||||
setBillsInitialCategory("pending_confirm");
|
||||
setListRevision((n) => n + 1);
|
||||
const unsettled = result?.unsettled_ticket_count ?? 0;
|
||||
if (unsettled > 0) {
|
||||
toast.warning(
|
||||
t("toast.periodClosedUnsettled", {
|
||||
defaultValue: "账期已关账;仍有 {{count}} 笔注单未结算,请尽快处理。",
|
||||
count: unsettled,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
toast.success(t("toast.periodClosed", { defaultValue: "账期已关账" }));
|
||||
}
|
||||
},
|
||||
[loadPeriods, t],
|
||||
);
|
||||
|
||||
const selectSiteId = adminSiteId ?? siteOptions[0]?.id ?? null;
|
||||
const selectedSiteLabel = siteOptions.find((site) => site.id === selectSiteId)?.label ?? null;
|
||||
const panelTitle = sectionTitle(activeSection, t);
|
||||
const allPeriodsCompleted =
|
||||
periods.length > 0 && periods.every((row) => row.status === "completed");
|
||||
const showPeriodToolbar =
|
||||
(activeSection === "ledger" || activeSection === "bills") && periods.length > 0;
|
||||
|
||||
const selectedPeriod =
|
||||
periodFilter !== "all" ? (periods.find((row) => row.id === periodFilter) ?? null) : openPeriod;
|
||||
const pipelineCounts = selectedPeriod?.pipeline ?? {
|
||||
credit_ledger_count: 0,
|
||||
share_ledger_count: 0,
|
||||
const openPeriodView = (periodId: number, view: SettlementPeriodView): void => {
|
||||
router.push(settlementPeriodViewHref(periodId, view));
|
||||
};
|
||||
|
||||
const overviewStats = [
|
||||
{
|
||||
label: t("overview.pendingConfirm", { defaultValue: "待确认" }),
|
||||
value: String(summaryTotals.pendingConfirm),
|
||||
icon: ClipboardCheck,
|
||||
},
|
||||
{
|
||||
label: t("overview.awaitingPayment", { defaultValue: "待收付" }),
|
||||
value: String(summaryTotals.awaitingPayment),
|
||||
icon: CircleDollarSign,
|
||||
},
|
||||
{
|
||||
label: t("overview.totalUnpaid", { defaultValue: "未结合计" }),
|
||||
value: formatDashboardMoneyMinor(summaryTotals.totalUnpaid, activeCurrency),
|
||||
icon: Landmark,
|
||||
},
|
||||
{
|
||||
label: t("overview.openPeriod", { defaultValue: "进行中账期" }),
|
||||
value: openPeriod
|
||||
? formatSettlementPeriodSpan(openPeriod.period_start, openPeriod.period_end)
|
||||
: "—",
|
||||
icon: CalendarClock,
|
||||
},
|
||||
{
|
||||
label: t("overview.creditLedger", { defaultValue: "信用流水(账期内)" }),
|
||||
value: String(pipelineCounts.credit_ledger_count),
|
||||
icon: CalendarClock,
|
||||
},
|
||||
{
|
||||
label: t("overview.shareLedger", { defaultValue: "占成流水(账期内)" }),
|
||||
value: String(pipelineCounts.share_ledger_count),
|
||||
icon: CalendarClock,
|
||||
},
|
||||
];
|
||||
|
||||
function renderMainPanel(): React.ReactElement {
|
||||
if (activeSection === "overview") {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("overview.pipelineHint", {
|
||||
defaultValue: "账单须关账后生成;下方为账期内实时流水笔数。",
|
||||
})}
|
||||
</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-6">
|
||||
{overviewStats.map((stat) => {
|
||||
const Icon = stat.icon;
|
||||
return (
|
||||
<button
|
||||
key={stat.label}
|
||||
type="button"
|
||||
className="rounded-xl border border-border/70 bg-card px-4 py-4 text-left transition-colors hover:border-primary/30 hover:bg-muted/30"
|
||||
onClick={() => {
|
||||
if (stat.label === t("overview.pendingConfirm", { defaultValue: "待确认" })) {
|
||||
setBillsInitialCategory("pending_confirm");
|
||||
setActiveSection("bills");
|
||||
} else if (
|
||||
stat.label === t("overview.awaitingPayment", { defaultValue: "待收付" })
|
||||
) {
|
||||
setBillsInitialCategory("awaiting_payment");
|
||||
setActiveSection("bills");
|
||||
} else if (
|
||||
stat.label === t("overview.creditLedger", { defaultValue: "信用流水(账期内)" })
|
||||
) {
|
||||
setActiveSection("ledger");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs text-muted-foreground">{stat.label}</p>
|
||||
<Icon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="mt-2 text-base font-semibold tabular-nums">{stat.value}</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeSection === "periods" && adminSiteId !== null) {
|
||||
return (
|
||||
<AgentPeriodsConsole
|
||||
adminSiteId={adminSiteId}
|
||||
canManagePeriods={canManagePeriods}
|
||||
settlementCycle="weekly"
|
||||
siteCurrencyCode={activeCurrency}
|
||||
embedded
|
||||
onPeriodsChange={handlePeriodsChange}
|
||||
onPeriodClosed={handlePeriodClosed}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeSection === "ledger" && adminSiteId !== null && periodFilterReady) {
|
||||
return (
|
||||
<SettlementLedgerPanel
|
||||
adminSiteId={adminSiteId}
|
||||
periodFilter={periodFilter}
|
||||
currencyCode={activeCurrency}
|
||||
canManage={canManagePeriods}
|
||||
onOpenBill={setDetailBillId}
|
||||
refreshKey={listRevision}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeSection === "bills" && adminSiteId !== null && periodFilterReady) {
|
||||
return (
|
||||
<SettlementBillsPanel
|
||||
adminSiteId={adminSiteId}
|
||||
periodFilter={periodFilter}
|
||||
currencyCode={activeCurrency}
|
||||
onOpenDetail={setDetailBillId}
|
||||
initialCategory={billsInitialCategory}
|
||||
refreshKey={listRevision}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <AdminNoResourceState />;
|
||||
}
|
||||
const isListMode = activePeriodId === null;
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
|
||||
<header className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="text-xl font-semibold tracking-tight">
|
||||
{t("title", { defaultValue: "结算中心" })}
|
||||
</h1>
|
||||
<AdminStatusBadge
|
||||
status={openPeriod ? "processing" : allPeriodsCompleted ? "completed" : "idle"}
|
||||
>
|
||||
{openPeriod
|
||||
? t("header.statusRunning", { defaultValue: "账期进行中" })
|
||||
: allPeriodsCompleted
|
||||
? t("header.statusCompleted", { defaultValue: "账期已结清" })
|
||||
: t("header.statusIdle", { defaultValue: "等待开期" })}
|
||||
</AdminStatusBadge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("header.subtitle", { defaultValue: "信用占成账务" })}
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">
|
||||
{t("title", { defaultValue: "结算中心" })}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{isListMode
|
||||
? t("subtitleList", { defaultValue: "账期列表:开账、关账,从行操作进入账单与报表。" })
|
||||
: t("subtitle", { defaultValue: "账期关账、账单确认与收付登记" })}
|
||||
</p>
|
||||
</div>
|
||||
{siteOptions.length <= 1 && selectedSiteLabel ? (
|
||||
<p className="text-sm text-muted-foreground">{selectedSiteLabel}</p>
|
||||
|
||||
{siteOptions.length >= 1 && siteId !== null ? (
|
||||
<Select
|
||||
value={String(siteId)}
|
||||
onValueChange={(v) => {
|
||||
setAdminSiteId(Number(v));
|
||||
setPeriodsReady(false);
|
||||
router.push("/admin/settlement-center");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-[220px]">
|
||||
<SelectValue>{siteLabel}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{siteOptions.map((site) => (
|
||||
<SelectItem key={site.id} value={String(site.id)}>
|
||||
{site.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : null}
|
||||
</header>
|
||||
</div>
|
||||
|
||||
{adminSiteId === null ? (
|
||||
<p className="text-sm text-muted-foreground">{t("empty.noSite", { defaultValue: "请选择接入站点。" })}</p>
|
||||
) : (
|
||||
<div className="min-w-0 space-y-4">
|
||||
<SettlementCenterNav
|
||||
active={activeSection}
|
||||
onChange={(section) => {
|
||||
if (section === "bills") {
|
||||
setBillsInitialCategory("all");
|
||||
}
|
||||
setActiveSection(section);
|
||||
}}
|
||||
counts={{
|
||||
pendingConfirm: summaryTotals.pendingConfirm,
|
||||
awaitingPayment: summaryTotals.awaitingPayment,
|
||||
}}
|
||||
siteSelector={
|
||||
siteOptions.length > 1 && selectSiteId !== null ? (
|
||||
<Select
|
||||
value={String(selectSiteId)}
|
||||
onValueChange={(value) => {
|
||||
setAdminSiteId(Number(value));
|
||||
setPeriodFilter("all");
|
||||
setPeriodFilterReady(false);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-[220px] bg-background">
|
||||
<SelectValue>{selectedSiteLabel}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{siteOptions.map((site) => (
|
||||
<SelectItem key={site.id} value={String(site.id)}>
|
||||
{site.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : null
|
||||
{siteId === null || !periodsReady ? (
|
||||
<p className="text-sm text-muted-foreground">{t("empty.noSite", { defaultValue: "请选择站点。" })}</p>
|
||||
) : isListMode ? (
|
||||
<SettlementPeriodWorkbench
|
||||
adminSiteId={siteId}
|
||||
currencyCode={currency}
|
||||
canManage={canManagePeriods}
|
||||
periods={periods}
|
||||
onViewDetail={(id) => openPeriodView(id, "bills")}
|
||||
onReloadPeriods={loadPeriods}
|
||||
onPeriodOpened={() => {
|
||||
setRefreshKey((n) => n + 1);
|
||||
}}
|
||||
onPeriodClosed={(result) => {
|
||||
setRefreshKey((n) => n + 1);
|
||||
const n = result?.unsettled_ticket_count ?? 0;
|
||||
if (n > 0) {
|
||||
toast.warning(
|
||||
t("toast.periodClosedUnsettled", {
|
||||
defaultValue: "已关账,仍有 {{count}} 笔注单未结算。",
|
||||
count: n,
|
||||
}),
|
||||
);
|
||||
}
|
||||
/>
|
||||
|
||||
{showPeriodToolbar && periodFilterReady ? (
|
||||
<SettlementPeriodToolbar
|
||||
periods={periods}
|
||||
value={periodFilter}
|
||||
onChange={(next) => {
|
||||
setPeriodFilter(next);
|
||||
setPeriodFilterReady(true);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<AdminPageCard title={panelTitle}>{renderMainPanel()}</AdminPageCard>
|
||||
</div>
|
||||
}}
|
||||
/>
|
||||
) : activePeriod === null ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("periodDetail.notFound", { defaultValue: "账期不存在或已切换站点,请返回列表。" })}
|
||||
</p>
|
||||
) : (
|
||||
<SettlementCenterPeriodDetail
|
||||
period={activePeriod}
|
||||
view={activeView}
|
||||
adminSiteId={siteId}
|
||||
currencyCode={currency}
|
||||
canOperateBills={canOperateBills}
|
||||
refreshKey={refreshKey}
|
||||
onOpenBillDetail={setDetailBillId}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Dialog open={detailBillId !== null} onOpenChange={(open) => !open && setDetailBillId(null)}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("actions.billDetail", { defaultValue: "账单详情 · 确认 / 收付" })}
|
||||
</DialogTitle>
|
||||
<DialogContent
|
||||
className="grid !h-[min(92vh,980px)] !w-[calc(100vw-2rem)] !max-w-none sm:!w-[min(1040px,calc(100vw-2rem))] sm:!max-w-[1040px] grid-rows-[auto,minmax(0,1fr)] overflow-hidden p-0"
|
||||
>
|
||||
<DialogHeader className="border-b px-6 py-4">
|
||||
<DialogTitle>{t("actions.billDetail", { defaultValue: "账单详情" })}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{detailBillId !== null ? (
|
||||
<AgentBillDetail
|
||||
billId={detailBillId}
|
||||
currencyCode={activeCurrency}
|
||||
canManage={canManagePeriods}
|
||||
onUpdated={() => {
|
||||
void loadPeriods();
|
||||
setListRevision((n) => n + 1);
|
||||
}}
|
||||
/>
|
||||
<div className="min-h-0 overflow-y-auto px-6 py-5">
|
||||
<AgentBillDetail
|
||||
billId={detailBillId}
|
||||
currencyCode={currency}
|
||||
canManage={canOperateBills}
|
||||
onUpdated={() => {
|
||||
void loadPeriods();
|
||||
setRefreshKey((n) => n + 1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
361
src/modules/settlement/settlement-credit-ledger-panel.tsx
Normal file
361
src/modules/settlement/settlement-credit-ledger-panel.tsx
Normal file
@@ -0,0 +1,361 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
getCreditLedger,
|
||||
type SettlementCreditLedgerRow,
|
||||
} from "@/api/admin-agent-settlement";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||||
import { AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import { AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminPlayerIdentityCells, AdminPlayerIdentityHeads } from "@/components/admin/admin-player-identity-columns";
|
||||
import { PlayerLedgerSourceBadge } from "@/components/admin/player-funding-badges";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { formatAdminMinorUnits } from "@/lib/money";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { creditLedgerReasonLabel, settlementBillStatusLabel } from "@/modules/settlement/settlement-status-label";
|
||||
|
||||
const REASON_FILTERS = [
|
||||
"all",
|
||||
"bet_hold",
|
||||
"game_settlement",
|
||||
"payment_record",
|
||||
"adjustment",
|
||||
"reversal",
|
||||
"bad_debt",
|
||||
"settlement_payout",
|
||||
"share_ledger",
|
||||
] as const;
|
||||
|
||||
type ReasonFilter = (typeof REASON_FILTERS)[number];
|
||||
|
||||
const COL_SPAN = 11;
|
||||
|
||||
function signedLedgerAmount(row: SettlementCreditLedgerRow): number {
|
||||
if (typeof row.signed_amount === "number") {
|
||||
return row.signed_amount;
|
||||
}
|
||||
|
||||
return row.direction === 1 ? row.amount : -row.amount;
|
||||
}
|
||||
|
||||
function signedLedgerAmountClass(signed: number): string {
|
||||
if (signed < 0) {
|
||||
return "font-medium text-destructive";
|
||||
}
|
||||
if (signed > 0) {
|
||||
return "font-medium text-emerald-700 dark:text-emerald-400";
|
||||
}
|
||||
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
|
||||
function formatSignedLedgerAmount(signed: number, currencyCode: string): string {
|
||||
if (signed === 0) {
|
||||
return formatAdminMinorUnits(0, currencyCode);
|
||||
}
|
||||
|
||||
const prefix = signed < 0 ? "−" : "+";
|
||||
return `${prefix}${formatAdminMinorUnits(Math.abs(signed), currencyCode)}`;
|
||||
}
|
||||
|
||||
function reasonLabel(
|
||||
value: ReasonFilter | string,
|
||||
t: ReturnType<typeof useTranslation<["settlementCenter", "wallet", "common"]>>["t"],
|
||||
): string {
|
||||
if (value === "all") {
|
||||
return t("filters.statusAll", { defaultValue: "全部" });
|
||||
}
|
||||
return creditLedgerReasonLabel(value, t);
|
||||
}
|
||||
|
||||
type SettlementCreditLedgerPanelProps = {
|
||||
adminSiteId: number;
|
||||
settlementPeriodId: number;
|
||||
currencyCode: string;
|
||||
refreshKey?: number;
|
||||
};
|
||||
|
||||
export function SettlementCreditLedgerPanel({
|
||||
adminSiteId,
|
||||
settlementPeriodId,
|
||||
currencyCode,
|
||||
refreshKey = 0,
|
||||
}: SettlementCreditLedgerPanelProps): React.ReactElement {
|
||||
const { t } = useTranslation(["settlementCenter", "wallet", "common"]);
|
||||
const formatTs = useAdminDateTimeFormatter();
|
||||
|
||||
const [draftAccount, setDraftAccount] = useState("");
|
||||
const [draftReason, setDraftReason] = useState<ReasonFilter>("all");
|
||||
const [appliedAccount, setAppliedAccount] = useState("");
|
||||
const [appliedReason, setAppliedReason] = useState<ReasonFilter>("all");
|
||||
const [rows, setRows] = useState<SettlementCreditLedgerRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(20);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getCreditLedger({
|
||||
admin_site_id: adminSiteId,
|
||||
settlement_period_id: settlementPeriodId,
|
||||
player_account: appliedAccount.trim() || undefined,
|
||||
reason: appliedReason === "all" ? undefined : appliedReason,
|
||||
page,
|
||||
per_page: perPage,
|
||||
});
|
||||
setRows(res.items ?? []);
|
||||
setTotal(res.total ?? 0);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [adminSiteId, appliedAccount, appliedReason, page, perPage, settlementPeriodId]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load, refreshKey]);
|
||||
|
||||
const lastPage = Math.max(1, Math.ceil(total / Math.max(1, perPage)));
|
||||
|
||||
const refLabel = (row: SettlementCreditLedgerRow): string => {
|
||||
const parts: string[] = [];
|
||||
if (row.biz_no) {
|
||||
parts.push(row.biz_no);
|
||||
}
|
||||
if (row.draw_no) {
|
||||
parts.push(row.draw_no);
|
||||
}
|
||||
if (row.play_code) {
|
||||
parts.push(row.play_code);
|
||||
}
|
||||
if (row.ticket_item_id) {
|
||||
parts.push(`#${row.ticket_item_id}`);
|
||||
}
|
||||
if (row.settlement_bill_id) {
|
||||
parts.push(`#${row.settlement_bill_id}`);
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts.join(" · ") : "—";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-border/70 bg-muted/20 p-4 text-sm text-muted-foreground">
|
||||
<p className="font-medium text-foreground">
|
||||
{t("panels.ledger.title", { defaultValue: "账务流水" })}
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
{t("ledger.groupIntro", {
|
||||
defaultValue:
|
||||
"账期内资金变动明细:信用占用、账单收付、调账与坏账。关账后生成的占成账单在「账单管理」。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-list-toolbar">
|
||||
<div className="admin-list-field">
|
||||
<Label htmlFor="scl-player" className="sm:shrink-0">
|
||||
{t("creditLedger.columns.player", { defaultValue: "玩家" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="scl-player"
|
||||
className="h-9"
|
||||
value={draftAccount}
|
||||
placeholder={t("ledgerPanel.playerAccountPh", { defaultValue: "用户名 / 站点玩家 ID" })}
|
||||
onChange={(e) => setDraftAccount(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="admin-list-field">
|
||||
<Label htmlFor="scl-reason" className="sm:shrink-0">
|
||||
{t("creditLedger.columns.reason", { defaultValue: "业务类型" })}
|
||||
</Label>
|
||||
<Select
|
||||
modal={false}
|
||||
value={draftReason}
|
||||
onValueChange={(v) => setDraftReason((v ?? "all") as ReasonFilter)}
|
||||
>
|
||||
<SelectTrigger id="scl-reason" className="h-9 w-full sm:w-52">
|
||||
<SelectValue>{() => reasonLabel(draftReason, t)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{REASON_FILTERS.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{reasonLabel(value, t)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="admin-list-actions">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setAppliedAccount(draftAccount);
|
||||
setAppliedReason(draftReason);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
{t("ledgerPanel.searchBtn", { defaultValue: "搜索" })}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setDraftAccount("");
|
||||
setDraftReason("all");
|
||||
setAppliedAccount("");
|
||||
setAppliedReason("all");
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
{t("ledgerPanel.reset", { defaultValue: "重置筛选" })}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="secondary" disabled={loading} onClick={() => void load()}>
|
||||
{t("ledgerPanel.refresh", { defaultValue: "刷新当前页" })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && rows.length === 0 ? <AdminLoadingState /> : null}
|
||||
|
||||
<div className="admin-table-shell overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="whitespace-nowrap">
|
||||
{t("creditLedger.columns.txn", { defaultValue: "流水号" })}
|
||||
</TableHead>
|
||||
<AdminPlayerIdentityHeads />
|
||||
<TableHead className="whitespace-nowrap">
|
||||
{t("columns.directAgent", { defaultValue: "直属代理" })}
|
||||
</TableHead>
|
||||
<TableHead className="whitespace-nowrap">
|
||||
{t("creditLedger.columns.channel", { defaultValue: "渠道" })}
|
||||
</TableHead>
|
||||
<TableHead className="whitespace-nowrap">
|
||||
{t("creditLedger.columns.reason", { defaultValue: "业务类型" })}
|
||||
</TableHead>
|
||||
<TableHead className="whitespace-nowrap">
|
||||
{t("columns.billId", { defaultValue: "账单 ID" })}
|
||||
</TableHead>
|
||||
<TableHead className="whitespace-nowrap">
|
||||
{t("columns.status", { defaultValue: "状态" })}
|
||||
</TableHead>
|
||||
<TableHead className="whitespace-nowrap text-right">
|
||||
{t("creditLedger.columns.amount", { defaultValue: "金额" })}
|
||||
</TableHead>
|
||||
<TableHead className="whitespace-nowrap">
|
||||
{t("creditLedger.columns.ref", { defaultValue: "关联" })}
|
||||
</TableHead>
|
||||
<TableHead className="whitespace-nowrap">
|
||||
{t("creditLedger.columns.time", { defaultValue: "时间" })}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading && rows.length === 0 ? (
|
||||
<AdminTableLoadingRow colSpan={COL_SPAN} />
|
||||
) : rows.length === 0 ? (
|
||||
<AdminTableNoResourceRow
|
||||
colSpan={COL_SPAN}
|
||||
message={t("creditLedger.emptyPeriod", {
|
||||
defaultValue: "本账期暂无账务流水。",
|
||||
})}
|
||||
/>
|
||||
) : (
|
||||
rows.map((row) => {
|
||||
const signed = signedLedgerAmount(row);
|
||||
return (
|
||||
<TableRow key={row.row_key}>
|
||||
<TableCell className="font-mono text-xs">{row.txn_no}</TableCell>
|
||||
<AdminPlayerIdentityCells row={row} />
|
||||
<TableCell className="text-xs">{row.direct_agent_label ?? "—"}</TableCell>
|
||||
<TableCell>
|
||||
<PlayerLedgerSourceBadge ledgerSource={row.ledger_source} />
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">{creditLedgerReasonLabel(row.biz_type, t)}</TableCell>
|
||||
<TableCell className="tabular-nums text-xs">
|
||||
{row.settlement_bill_id ? `#${row.settlement_bill_id}` : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{row.bill_status ? settlementBillStatusLabel(row.bill_status, t) : row.status}
|
||||
</TableCell>
|
||||
<TableCell className="tabular-nums text-right text-xs">
|
||||
<span className={cn(signedLedgerAmountClass(signed))}>
|
||||
{row.biz_type === "bet_hold"
|
||||
? t("creditLedger.reason.freezeAmount", {
|
||||
defaultValue: "冻结 {{amount}}",
|
||||
amount: formatAdminMinorUnits(row.amount, currencyCode),
|
||||
})
|
||||
: formatSignedLedgerAmount(signed, currencyCode)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{row.ticket_item_id ? (
|
||||
<Link
|
||||
href={`/admin/tickets?ticket_item_id=${row.ticket_item_id}`}
|
||||
className="text-primary underline-offset-2 hover:underline"
|
||||
>
|
||||
{refLabel(row)}
|
||||
</Link>
|
||||
) : row.settlement_bill_id ? (
|
||||
<span>{refLabel(row)}</span>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap font-mono text-[11px] text-muted-foreground">
|
||||
{formatTs(row.created_at)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<AdminListPaginationFooter
|
||||
selectId="settlement-credit-ledger-per-page"
|
||||
total={total}
|
||||
page={page}
|
||||
lastPage={lastPage}
|
||||
perPage={perPage}
|
||||
loading={loading}
|
||||
onPerPageChange={(next) => {
|
||||
setPerPage(next);
|
||||
setPage(1);
|
||||
}}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { SettlementLedgerRow } from "@/api/admin-agent-settlement";
|
||||
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { PlayerLedgerSourceBadge } from "@/components/admin/player-funding-badges";
|
||||
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
|
||||
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
|
||||
import { SettlementLedgerRowActions } from "@/modules/settlement/settlement-ledger-row-actions";
|
||||
import {
|
||||
creditLedgerReasonLabel,
|
||||
settlementAdjustmentTypeLabel,
|
||||
settlementBillStatusLabel,
|
||||
} from "@/modules/settlement/settlement-status-label";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
type SettlementCreditLedgerTableProps = {
|
||||
rows: SettlementLedgerRow[];
|
||||
loading: boolean;
|
||||
currencyCode: string;
|
||||
canManage: boolean;
|
||||
onOpenBill: (billId: number) => void;
|
||||
onRefresh: () => void;
|
||||
showStatusColumn?: boolean;
|
||||
};
|
||||
|
||||
function ledgerBizLabel(
|
||||
row: SettlementLedgerRow,
|
||||
t: ReturnType<typeof useTranslation<["settlementCenter", "agents"]>>["t"],
|
||||
): string {
|
||||
if (row.entry_kind === "payment") {
|
||||
return t("creditLedger.reason.payment_record", { defaultValue: "账单收付" });
|
||||
}
|
||||
if (row.entry_kind === "adjustment") {
|
||||
return settlementAdjustmentTypeLabel(row.biz_type, t);
|
||||
}
|
||||
|
||||
return creditLedgerReasonLabel(row.biz_type, t);
|
||||
}
|
||||
|
||||
function ledgerSourceForBadge(row: SettlementLedgerRow): string | null {
|
||||
if (row.entry_kind === "credit") {
|
||||
return "credit_ledger";
|
||||
}
|
||||
if (row.entry_kind === "payment") {
|
||||
return "wallet_txn";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function SettlementCreditLedgerTable({
|
||||
rows,
|
||||
loading,
|
||||
currencyCode,
|
||||
canManage,
|
||||
onOpenBill,
|
||||
onRefresh,
|
||||
showStatusColumn = false,
|
||||
}: SettlementCreditLedgerTableProps): React.ReactElement {
|
||||
const { t } = useTranslation(["settlementCenter", "agents", "common"]);
|
||||
const formatDt = useAdminDateTimeFormatter();
|
||||
|
||||
if (loading) {
|
||||
return <AdminLoadingState />;
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return <AdminNoResourceState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-table-shell overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("creditLedger.columns.txn", { defaultValue: "流水号" })}</TableHead>
|
||||
<TableHead>{t("creditLedger.columns.player", { defaultValue: "玩家" })}</TableHead>
|
||||
<TableHead>{t("creditLedger.columns.reason", { defaultValue: "业务类型" })}</TableHead>
|
||||
<TableHead>{t("creditLedger.columns.ref", { defaultValue: "关联" })}</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("creditLedger.columns.amount", { defaultValue: "金额" })}
|
||||
</TableHead>
|
||||
<TableHead>{t("creditLedger.columns.channel", { defaultValue: "渠道" })}</TableHead>
|
||||
{showStatusColumn ? (
|
||||
<TableHead>{t("creditLedger.columns.status", { defaultValue: "状态" })}</TableHead>
|
||||
) : null}
|
||||
<TableHead>{t("creditLedger.columns.time", { defaultValue: "时间" })}</TableHead>
|
||||
<TableHead className="sticky right-0 z-10 w-14 bg-muted text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
{t("common:table.actions", { defaultValue: "操作" })}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => {
|
||||
const signed = row.signed_amount ?? (row.direction === 1 ? row.amount : -row.amount);
|
||||
const playerLabel =
|
||||
row.username?.trim() ||
|
||||
row.nickname?.trim() ||
|
||||
row.site_player_id?.trim() ||
|
||||
`#${row.player_id}`;
|
||||
const badgeSource = ledgerSourceForBadge(row);
|
||||
|
||||
return (
|
||||
<TableRow key={row.row_key ?? `${row.entry_kind}-${row.id}`}>
|
||||
<TableCell className="font-mono text-xs">{row.txn_no}</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-medium">{playerLabel}</span>
|
||||
<span className="ml-1 text-xs text-muted-foreground">#{row.player_id}</span>
|
||||
</TableCell>
|
||||
<TableCell>{ledgerBizLabel(row, t)}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{row.biz_no ?? (row.settlement_bill_id ? `bill#${row.settlement_bill_id}` : "—")}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={`text-right tabular-nums font-medium ${signed < 0 ? "text-destructive" : "text-emerald-700"}`}
|
||||
>
|
||||
{signed < 0 ? "−" : "+"}
|
||||
{formatDashboardMoneyMinor(Math.abs(signed), row.currency_code || currencyCode)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{badgeSource ? (
|
||||
<PlayerLedgerSourceBadge ledgerSource={badgeSource} />
|
||||
) : (
|
||||
t("creditLedger.entryKind.adjustment", { defaultValue: "调账流水" })
|
||||
)}
|
||||
</TableCell>
|
||||
{showStatusColumn ? (
|
||||
<TableCell>
|
||||
{row.bill_status ? (
|
||||
<AdminStatusBadge status={row.bill_status}>
|
||||
{settlementBillStatusLabel(row.bill_status, t)}
|
||||
</AdminStatusBadge>
|
||||
) : (
|
||||
<AdminStatusBadge status="posted">
|
||||
{t("ledgerPanel.rowPosted", { defaultValue: "已记账" })}
|
||||
</AdminStatusBadge>
|
||||
)}
|
||||
</TableCell>
|
||||
) : null}
|
||||
<TableCell className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{row.created_at ? formatDt(row.created_at) : "—"}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<SettlementLedgerRowActions
|
||||
row={row}
|
||||
canManage={canManage}
|
||||
onOpenBill={onOpenBill}
|
||||
onRefresh={onRefresh}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,378 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { getCreditLedger, type SettlementLedgerRow } from "@/api/admin-agent-settlement";
|
||||
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import type { AgentSettlementPeriodFilter } from "@/modules/settlement/agent-settlement-period-select";
|
||||
import { SettlementCreditLedgerTable } from "@/modules/settlement/settlement-credit-ledger-table";
|
||||
import {
|
||||
creditLedgerReasonLabel,
|
||||
settlementAdjustmentTypeLabel,
|
||||
settlementBillStatusLabel,
|
||||
} from "@/modules/settlement/settlement-status-label";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
|
||||
export type LedgerCategory =
|
||||
| "all"
|
||||
| "credit"
|
||||
| "payment"
|
||||
| "adjustment"
|
||||
| "bad_debt"
|
||||
| "actionable";
|
||||
|
||||
type LedgerFilters = {
|
||||
txnNo: string;
|
||||
playerAccount: string;
|
||||
playerId: string;
|
||||
bizType: string;
|
||||
billStatus: string;
|
||||
createdFrom: string;
|
||||
createdTo: string;
|
||||
};
|
||||
|
||||
const emptyFilters: LedgerFilters = {
|
||||
txnNo: "",
|
||||
playerAccount: "",
|
||||
playerId: "",
|
||||
bizType: "",
|
||||
billStatus: "",
|
||||
createdFrom: "",
|
||||
createdTo: "",
|
||||
};
|
||||
|
||||
/** 下拉「不限」哨兵;请求时转为空串 */
|
||||
const FILTER_ALL = "__all__";
|
||||
|
||||
function ledgerFilterSelectLabel(
|
||||
raw: unknown,
|
||||
t: ReturnType<typeof useTranslation<"settlementCenter">>["t"],
|
||||
kind: "biz" | "billStatus",
|
||||
): string {
|
||||
const v = raw == null ? "" : String(raw);
|
||||
if (v === "" || v === FILTER_ALL) {
|
||||
return t("ledgerPanel.filterAll", { defaultValue: "不限" });
|
||||
}
|
||||
if (kind === "billStatus") {
|
||||
return settlementBillStatusLabel(v, t);
|
||||
}
|
||||
if (v === "adjustment" || v === "reversal" || v === "bad_debt") {
|
||||
return settlementAdjustmentTypeLabel(v, t);
|
||||
}
|
||||
return creditLedgerReasonLabel(v, t);
|
||||
}
|
||||
|
||||
/** 与流水 biz_type / adjustment_type 一致 */
|
||||
const CREDIT_BIZ_OPTIONS = [
|
||||
"bet_hold",
|
||||
"bet_hold_release",
|
||||
"game_settlement_loss",
|
||||
"settlement_confirm",
|
||||
"payment_record",
|
||||
"adjustment",
|
||||
"reversal",
|
||||
"bad_debt",
|
||||
] as const;
|
||||
|
||||
/** 与 settlement_bills.status 一致 */
|
||||
const BILL_STATUS_OPTIONS = [
|
||||
"pending_confirm",
|
||||
"confirmed",
|
||||
"partial_paid",
|
||||
"settled",
|
||||
"overdue",
|
||||
"reversed",
|
||||
] as const;
|
||||
|
||||
const CATEGORY_OPTIONS: { value: LedgerCategory; labelKey: string }[] = [
|
||||
{ value: "all", labelKey: "ledgerPanel.category.all" },
|
||||
{ value: "credit", labelKey: "ledgerPanel.category.credit" },
|
||||
{ value: "payment", labelKey: "ledgerPanel.category.payment" },
|
||||
{ value: "adjustment", labelKey: "ledgerPanel.category.adjustment" },
|
||||
{ value: "bad_debt", labelKey: "ledgerPanel.category.badDebt" },
|
||||
{ value: "actionable", labelKey: "ledgerPanel.category.actionable" },
|
||||
];
|
||||
|
||||
function categoryQueryParams(category: LedgerCategory): Record<string, string | boolean | undefined> {
|
||||
switch (category) {
|
||||
case "credit":
|
||||
return { entry_kind: "credit" };
|
||||
case "payment":
|
||||
return { entry_kind: "payment" };
|
||||
case "adjustment":
|
||||
return { entry_kind: "adjustment" };
|
||||
case "bad_debt":
|
||||
return { bad_debt_only: true };
|
||||
case "actionable":
|
||||
return { actionable_only: true };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
type SettlementLedgerPanelProps = {
|
||||
adminSiteId: number;
|
||||
periodFilter: AgentSettlementPeriodFilter;
|
||||
currencyCode: string;
|
||||
canManage: boolean;
|
||||
onOpenBill: (billId: number) => void;
|
||||
refreshKey?: number;
|
||||
};
|
||||
|
||||
export function SettlementLedgerPanel({
|
||||
adminSiteId,
|
||||
periodFilter,
|
||||
currencyCode,
|
||||
canManage,
|
||||
onOpenBill,
|
||||
refreshKey = 0,
|
||||
}: SettlementLedgerPanelProps): React.ReactElement {
|
||||
const { t } = useTranslation(["settlementCenter", "common"]);
|
||||
const [category, setCategory] = useState<LedgerCategory>("all");
|
||||
const [draft, setDraft] = useState<LedgerFilters>(emptyFilters);
|
||||
const [applied, setApplied] = useState<LedgerFilters>(emptyFilters);
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(20);
|
||||
const [rows, setRows] = useState<SettlementLedgerRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const periodId = periodFilter === "all" ? undefined : periodFilter;
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const player_id =
|
||||
applied.playerId.trim() === "" ? undefined : Number(applied.playerId);
|
||||
const data = await getCreditLedger({
|
||||
admin_site_id: adminSiteId,
|
||||
settlement_period_id: periodId,
|
||||
page,
|
||||
per_page: perPage,
|
||||
player_id:
|
||||
player_id !== undefined && !Number.isNaN(player_id) && player_id > 0
|
||||
? player_id
|
||||
: undefined,
|
||||
txn_no: applied.txnNo.trim() || undefined,
|
||||
player_account: applied.playerAccount.trim() || undefined,
|
||||
reason: applied.bizType.trim() || undefined,
|
||||
bill_status: applied.billStatus.trim() || undefined,
|
||||
created_from: applied.createdFrom.trim() || undefined,
|
||||
created_to: applied.createdTo.trim() || undefined,
|
||||
...categoryQueryParams(category),
|
||||
});
|
||||
setRows(data.items ?? []);
|
||||
setTotal(data.total ?? 0);
|
||||
} catch (err: unknown) {
|
||||
setRows([]);
|
||||
setTotal(0);
|
||||
toast.error(
|
||||
err instanceof LotteryApiBizError
|
||||
? err.message
|
||||
: t("errors.loadCreditLedger", { defaultValue: "账务流水加载失败" }),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [adminSiteId, applied, category, page, perPage, periodId, t]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [load, refreshKey]);
|
||||
|
||||
const runSearch = () => {
|
||||
setApplied({ ...draft });
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setDraft(emptyFilters);
|
||||
setApplied(emptyFilters);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">{t("creditLedger.intro")}</p>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="sl-txn">{t("creditLedger.columns.txn", { defaultValue: "流水号" })}</Label>
|
||||
<Input
|
||||
id="sl-txn"
|
||||
placeholder={t("ledgerPanel.search", { defaultValue: "搜索" })}
|
||||
value={draft.txnNo}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, txnNo: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="sl-account">{t("ledgerPanel.playerAccount", { defaultValue: "玩家账号" })}</Label>
|
||||
<Input
|
||||
id="sl-account"
|
||||
placeholder={t("ledgerPanel.playerAccountPh", { defaultValue: "用户名 / 站点玩家 ID" })}
|
||||
value={draft.playerAccount}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, playerAccount: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="sl-player">{t("ledgerPanel.playerId", { defaultValue: "玩家 ID" })}</Label>
|
||||
<Input
|
||||
id="sl-player"
|
||||
inputMode="numeric"
|
||||
placeholder={t("ledgerPanel.optional", { defaultValue: "可选" })}
|
||||
value={draft.playerId}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, playerId: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="sl-biz">{t("creditLedger.columns.reason", { defaultValue: "业务类型" })}</Label>
|
||||
<Select
|
||||
modal={false}
|
||||
value={draft.bizType === "" ? FILTER_ALL : draft.bizType}
|
||||
onValueChange={(v) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
bizType: v == null || v === FILTER_ALL ? "" : String(v),
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="sl-biz" className="h-9 w-full">
|
||||
<SelectValue>
|
||||
{(v) => ledgerFilterSelectLabel(v, t, "biz")}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={FILTER_ALL}>
|
||||
{t("ledgerPanel.filterAll", { defaultValue: "不限" })}
|
||||
</SelectItem>
|
||||
{CREDIT_BIZ_OPTIONS.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{ledgerFilterSelectLabel(value, t, "biz")}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="sl-bill-status">{t("ledgerPanel.billStatus", { defaultValue: "账单状态" })}</Label>
|
||||
<Select
|
||||
modal={false}
|
||||
value={draft.billStatus === "" ? FILTER_ALL : draft.billStatus}
|
||||
onValueChange={(v) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
billStatus: v == null || v === FILTER_ALL ? "" : String(v),
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="sl-bill-status" className="h-9 w-full">
|
||||
<SelectValue>
|
||||
{(v) => ledgerFilterSelectLabel(v, t, "billStatus")}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={FILTER_ALL}>
|
||||
{t("ledgerPanel.filterAll", { defaultValue: "不限" })}
|
||||
</SelectItem>
|
||||
{BILL_STATUS_OPTIONS.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{ledgerFilterSelectLabel(value, t, "billStatus")}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="sm:col-span-2 lg:col-span-2">
|
||||
<AdminDateRangeField
|
||||
id="sl-created-range"
|
||||
label={t("ledgerPanel.dateRange", { defaultValue: "时间范围" })}
|
||||
from={draft.createdFrom}
|
||||
to={draft.createdTo}
|
||||
onRangeChange={(r) =>
|
||||
setDraft((d) => ({ ...d, createdFrom: r.from, createdTo: r.to }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" size="sm" onClick={() => runSearch()}>
|
||||
{t("ledgerPanel.searchBtn", { defaultValue: "搜索" })}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => resetFilters()}>
|
||||
{t("ledgerPanel.reset", { defaultValue: "重置筛选" })}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => void load()}>
|
||||
{t("ledgerPanel.refresh", { defaultValue: "刷新当前页" })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5 border-b border-border/60 pb-3">
|
||||
{CATEGORY_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setCategory(opt.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className={cn(
|
||||
"rounded-full border px-3 py-1 text-xs font-medium transition-colors",
|
||||
category === opt.value
|
||||
? "border-primary/40 bg-primary/10 text-foreground"
|
||||
: "border-transparent bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t(opt.labelKey, { defaultValue: opt.value })}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading && rows.length === 0 ? (
|
||||
<AdminLoadingState />
|
||||
) : (
|
||||
<>
|
||||
<SettlementCreditLedgerTable
|
||||
rows={rows}
|
||||
loading={loading}
|
||||
currencyCode={currencyCode}
|
||||
canManage={canManage}
|
||||
onOpenBill={onOpenBill}
|
||||
onRefresh={() => void load()}
|
||||
showStatusColumn
|
||||
/>
|
||||
<AdminListPaginationFooter
|
||||
selectId="settlement-ledger-per-page"
|
||||
total={total}
|
||||
page={page}
|
||||
lastPage={Math.max(1, Math.ceil(total / Math.max(1, perPage)))}
|
||||
perPage={perPage}
|
||||
loading={loading}
|
||||
onPerPageChange={(next) => {
|
||||
setPerPage(next);
|
||||
setPage(1);
|
||||
}}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CircleDollarSign,
|
||||
ClipboardCheck,
|
||||
Eye,
|
||||
SlidersHorizontal,
|
||||
TriangleAlert,
|
||||
Undo2,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { postSettlementBillConfirm } from "@/api/admin-agent-settlement";
|
||||
import type { SettlementLedgerRow } from "@/api/admin-agent-settlement";
|
||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
||||
import { adminPlayerDetailPath } from "@/lib/admin-player-paths";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
|
||||
type SettlementLedgerRowActionsProps = {
|
||||
row: SettlementLedgerRow;
|
||||
canManage: boolean;
|
||||
onOpenBill: (billId: number) => void;
|
||||
onRefresh: () => void;
|
||||
};
|
||||
|
||||
export function SettlementLedgerRowActions({
|
||||
row,
|
||||
canManage,
|
||||
onOpenBill,
|
||||
onRefresh,
|
||||
}: SettlementLedgerRowActionsProps): React.ReactElement {
|
||||
const { t } = useTranslation(["settlementCenter", "agents", "common"]);
|
||||
const { request: requestConfirm, ConfirmDialog, busy } = useConfirmAction();
|
||||
|
||||
const billId = row.settlement_bill_id ?? null;
|
||||
const actions = row.available_actions ?? [];
|
||||
|
||||
const show = (code: string): boolean => actions.includes(code);
|
||||
|
||||
const billAction = (code: string): boolean =>
|
||||
canManage && billId !== null && show(code);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminRowActionsMenu
|
||||
busy={busy}
|
||||
actions={[
|
||||
{
|
||||
key: "view_player",
|
||||
label: t("creditLedger.actions.viewPlayer", { defaultValue: "玩家详情" }),
|
||||
icon: User,
|
||||
href: adminPlayerDetailPath(row.player_id),
|
||||
hidden: !show("view_player"),
|
||||
},
|
||||
{
|
||||
key: "view_bill",
|
||||
label: t("creditLedger.actions.viewBill", { defaultValue: "账单详情" }),
|
||||
icon: Eye,
|
||||
onClick: () => onOpenBill(billId!),
|
||||
hidden: !show("view_bill") || billId === null,
|
||||
},
|
||||
{
|
||||
key: "confirm",
|
||||
label: t("creditLedger.actions.confirm", { defaultValue: "确认账单" }),
|
||||
icon: ClipboardCheck,
|
||||
hidden: !billAction("confirm"),
|
||||
onClick: () =>
|
||||
requestConfirm({
|
||||
title: t("agents:settlementBills.confirm", { defaultValue: "确认账单" }),
|
||||
description: t("creditLedger.actions.confirmDesc", {
|
||||
defaultValue: "确认后账单进入待收付状态。",
|
||||
}),
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await postSettlementBillConfirm(billId!);
|
||||
toast.success(
|
||||
t("agents:settlementBills.confirmed", { defaultValue: "已确认" }),
|
||||
);
|
||||
onRefresh();
|
||||
} catch (err: unknown) {
|
||||
toast.error(
|
||||
err instanceof LotteryApiBizError
|
||||
? err.message
|
||||
: t("common:states.error", { defaultValue: "操作失败" }),
|
||||
);
|
||||
}
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: "payment",
|
||||
label: t("creditLedger.actions.payment", { defaultValue: "登记收付" }),
|
||||
icon: CircleDollarSign,
|
||||
hidden: !billAction("payment"),
|
||||
onClick: () => onOpenBill(billId!),
|
||||
},
|
||||
{
|
||||
key: "adjustment",
|
||||
label: t("creditLedger.actions.adjustment", { defaultValue: "调账" }),
|
||||
icon: SlidersHorizontal,
|
||||
hidden: !billAction("adjustment"),
|
||||
onClick: () => onOpenBill(billId!),
|
||||
},
|
||||
{
|
||||
key: "reversal",
|
||||
label: t("creditLedger.actions.reversal", { defaultValue: "冲正" }),
|
||||
icon: Undo2,
|
||||
hidden: !billAction("reversal"),
|
||||
onClick: () => onOpenBill(billId!),
|
||||
},
|
||||
{
|
||||
key: "bad_debt",
|
||||
label: t("creditLedger.actions.badDebt", { defaultValue: "坏账核销" }),
|
||||
icon: TriangleAlert,
|
||||
destructive: true,
|
||||
hidden: !billAction("bad_debt"),
|
||||
onClick: () => onOpenBill(billId!),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<ConfirmDialog />
|
||||
</>
|
||||
);
|
||||
}
|
||||
342
src/modules/settlement/settlement-main-panel.tsx
Normal file
342
src/modules/settlement/settlement-main-panel.tsx
Normal file
@@ -0,0 +1,342 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
getSettlementBills,
|
||||
type SettlementBillListScope,
|
||||
type SettlementBillRow,
|
||||
} from "@/api/admin-agent-settlement";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useAsyncEffect } from "@/hooks/use-async-effect";
|
||||
import type { AgentSettlementPeriodFilter } from "@/modules/settlement/agent-settlement-period-select";
|
||||
import { SettlementBillsTable } from "@/modules/settlement/settlement-bills-table";
|
||||
import { settlementBillStatusLabel } from "@/modules/settlement/settlement-status-label";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
|
||||
type BillTypeFilter = "all" | "player" | "agent";
|
||||
type BillStatusFilter = "all" | SettlementBillListScope;
|
||||
|
||||
type BillFilters = {
|
||||
billId: string;
|
||||
ownerKeyword: string;
|
||||
billType: BillTypeFilter;
|
||||
statusScope: BillStatusFilter;
|
||||
};
|
||||
|
||||
function filtersForPeriod(): BillFilters {
|
||||
return {
|
||||
billId: "",
|
||||
ownerKeyword: "",
|
||||
billType: "all",
|
||||
statusScope: "all",
|
||||
};
|
||||
}
|
||||
|
||||
function apiQueryFromFilters(filters: BillFilters): {
|
||||
bill_type?: string;
|
||||
scope?: SettlementBillListScope;
|
||||
bill_id?: number;
|
||||
keyword?: string;
|
||||
} {
|
||||
const out: {
|
||||
bill_type?: string;
|
||||
scope?: SettlementBillListScope;
|
||||
bill_id?: number;
|
||||
keyword?: string;
|
||||
} = {};
|
||||
|
||||
if (filters.billType === "player" || filters.billType === "agent") {
|
||||
out.bill_type = filters.billType;
|
||||
}
|
||||
if (filters.statusScope !== "all") {
|
||||
out.scope = filters.statusScope;
|
||||
}
|
||||
const id = Number(filters.billId.trim());
|
||||
if (filters.billId.trim() !== "" && !Number.isNaN(id) && id > 0) {
|
||||
out.bill_id = id;
|
||||
}
|
||||
const keyword = filters.ownerKeyword.trim();
|
||||
if (keyword !== "") {
|
||||
out.keyword = keyword;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export type SettlementMainPanelProps = {
|
||||
adminSiteId: number;
|
||||
currencyCode: string;
|
||||
periodFilter: AgentSettlementPeriodFilter;
|
||||
onOpenBillDetail: (billId: number) => void;
|
||||
refreshKey?: number;
|
||||
pendingConfirm: number;
|
||||
awaitingPayment: number;
|
||||
selectedPeriodStatus?: string | null;
|
||||
};
|
||||
|
||||
export function SettlementMainPanel({
|
||||
adminSiteId,
|
||||
currencyCode,
|
||||
periodFilter,
|
||||
onOpenBillDetail,
|
||||
refreshKey = 0,
|
||||
pendingConfirm,
|
||||
awaitingPayment,
|
||||
selectedPeriodStatus,
|
||||
}: SettlementMainPanelProps): React.ReactElement {
|
||||
const { t } = useTranslation("settlementCenter");
|
||||
const periodId = periodFilter === "all" ? undefined : periodFilter;
|
||||
const periodOpen = selectedPeriodStatus === "open";
|
||||
|
||||
const initialFilters = useMemo(() => filtersForPeriod(), []);
|
||||
|
||||
const [draft, setDraft] = useState<BillFilters>(initialFilters);
|
||||
const [applied, setApplied] = useState<BillFilters>(initialFilters);
|
||||
const [rows, setRows] = useState<SettlementBillRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(20);
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(initialFilters);
|
||||
setApplied(initialFilters);
|
||||
setPage(1);
|
||||
}, [initialFilters]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const q = apiQueryFromFilters(applied);
|
||||
const data = await getSettlementBills({
|
||||
admin_site_id: adminSiteId,
|
||||
settlement_period_id: periodId,
|
||||
bill_type: q.bill_type,
|
||||
scope: q.scope,
|
||||
bill_id: q.bill_id,
|
||||
keyword: q.keyword,
|
||||
page,
|
||||
per_page: perPage,
|
||||
});
|
||||
setRows(data.items ?? []);
|
||||
setTotal(data.total ?? data.items?.length ?? 0);
|
||||
} catch (err: unknown) {
|
||||
setRows([]);
|
||||
setTotal(0);
|
||||
toast.error(
|
||||
err instanceof LotteryApiBizError
|
||||
? err.message
|
||||
: t("errors.loadBills", { defaultValue: "账单加载失败" }),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [adminSiteId, applied, page, perPage, periodId, t]);
|
||||
|
||||
useAsyncEffect(() => {
|
||||
void load();
|
||||
}, [load, refreshKey]);
|
||||
|
||||
const runSearch = () => {
|
||||
setPage(1);
|
||||
setApplied({ ...draft });
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setDraft(initialFilters);
|
||||
setApplied(initialFilters);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const statusOptionLabel = (value: BillStatusFilter): string => {
|
||||
if (value === "all") {
|
||||
return t("billsPanel.filterAll", { defaultValue: "全部状态" });
|
||||
}
|
||||
if (value === "pending_confirm") {
|
||||
const label = t("billsPanel.category.pendingConfirm", { defaultValue: "待确认" });
|
||||
return pendingConfirm > 0 ? `${label} (${pendingConfirm})` : label;
|
||||
}
|
||||
if (value === "awaiting_payment") {
|
||||
const label = t("billsPanel.category.awaitingPayment", { defaultValue: "待收付" });
|
||||
return awaitingPayment > 0 ? `${label} (${awaitingPayment})` : label;
|
||||
}
|
||||
if (value === "settled") {
|
||||
return settlementBillStatusLabel("settled", t);
|
||||
}
|
||||
return t("billsPanel.filterAdjustment", { defaultValue: "调账 / 冲正" });
|
||||
};
|
||||
|
||||
const emptyBillMessage = useMemo((): string | undefined => {
|
||||
if (periodOpen) {
|
||||
return t("empty.billsNeedClose", {
|
||||
defaultValue: "账单在关账后生成。请返回账期列表,对本期执行「关账」后再查看。",
|
||||
});
|
||||
}
|
||||
if (applied.statusScope !== "all") {
|
||||
return t("billsPanel.emptyFiltered", {
|
||||
defaultValue: "当前筛选下暂无账单,请改为「全部状态」或重置筛选。",
|
||||
});
|
||||
}
|
||||
return t("billsPanel.emptyClosed", {
|
||||
defaultValue:
|
||||
"本期已关账但暂无账单。常见原因:账期内无信用盘玩家的已结算注单,或占成流水不在本账期时间范围内。",
|
||||
});
|
||||
}, [applied.statusScope, periodOpen, t]);
|
||||
|
||||
const billTypeLabel = (value: BillTypeFilter): string => {
|
||||
switch (value) {
|
||||
case "player":
|
||||
return t("billsPanel.category.player", { defaultValue: "玩家账单" });
|
||||
case "agent":
|
||||
return t("billsPanel.category.agent", { defaultValue: "代理账单" });
|
||||
default:
|
||||
return t("billsPanel.filterAllTypes", { defaultValue: "全部类型" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="sb-bill-id">{t("billsPanel.billId", { defaultValue: "账单 ID" })}</Label>
|
||||
<Input
|
||||
id="sb-bill-id"
|
||||
inputMode="numeric"
|
||||
placeholder={t("billsPanel.optional", { defaultValue: "可选" })}
|
||||
value={draft.billId}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, billId: e.target.value }))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
runSearch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="sb-owner">{t("billsPanel.ownerKeyword", { defaultValue: "本方 / 对方" })}</Label>
|
||||
<Input
|
||||
id="sb-owner"
|
||||
placeholder={t("billsPanel.ownerKeywordPh", { defaultValue: "玩家账号、代理名称" })}
|
||||
value={draft.ownerKeyword}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, ownerKeyword: e.target.value }))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
runSearch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="sb-status">{t("billsPanel.status", { defaultValue: "账单状态" })}</Label>
|
||||
<Select
|
||||
modal={false}
|
||||
value={draft.statusScope}
|
||||
onValueChange={(v) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
statusScope: (v ?? "all") as BillStatusFilter,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="sb-status" className="h-9 w-full">
|
||||
<SelectValue>{() => statusOptionLabel(draft.statusScope)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(
|
||||
[
|
||||
"all",
|
||||
"pending_confirm",
|
||||
"awaiting_payment",
|
||||
"settled",
|
||||
"adjustment",
|
||||
] as BillStatusFilter[]
|
||||
).map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{statusOptionLabel(value)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="sb-type">{t("billsPanel.billType", { defaultValue: "账单类型" })}</Label>
|
||||
<Select
|
||||
modal={false}
|
||||
value={draft.billType}
|
||||
onValueChange={(v) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
billType: (v ?? "all") as BillTypeFilter,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="sb-type" className="h-9 w-full">
|
||||
<SelectValue>{() => billTypeLabel(draft.billType)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(["all", "player", "agent"] as BillTypeFilter[]).map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{billTypeLabel(value)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button type="button" size="sm" onClick={() => runSearch()}>
|
||||
{t("billsPanel.searchBtn", { defaultValue: "搜索" })}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => resetFilters()}>
|
||||
{t("billsPanel.reset", { defaultValue: "重置" })}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => void load()}>
|
||||
{t("billsPanel.refresh", { defaultValue: "刷新" })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading && rows.length === 0 ? (
|
||||
<AdminLoadingState />
|
||||
) : (
|
||||
<>
|
||||
<SettlementBillsTable
|
||||
rows={rows}
|
||||
loading={loading}
|
||||
currencyCode={currencyCode}
|
||||
billTypeFilter={applied.billType}
|
||||
emptyMessage={emptyBillMessage}
|
||||
onOpenDetail={onOpenBillDetail}
|
||||
/>
|
||||
<AdminListPaginationFooter
|
||||
selectId="settlement-bills-per-page"
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
total={total}
|
||||
lastPage={Math.max(1, Math.ceil(total / Math.max(1, perPage)))}
|
||||
loading={loading}
|
||||
onPageChange={setPage}
|
||||
onPerPageChange={(next) => {
|
||||
setPerPage(next);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
src/modules/settlement/settlement-party-cells.tsx
Normal file
35
src/modules/settlement/settlement-party-cells.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
type DashCellProps = {
|
||||
value?: string | number | null;
|
||||
mono?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function SettlementDashCell({
|
||||
value,
|
||||
mono = false,
|
||||
className,
|
||||
}: DashCellProps): React.ReactElement {
|
||||
const text =
|
||||
value === null || value === undefined || String(value).trim() === ""
|
||||
? "—"
|
||||
: String(value);
|
||||
|
||||
return (
|
||||
<span className={[mono ? "font-mono text-xs" : "", className].filter(Boolean).join(" ") || undefined}>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function formatPlatformPartyLabel(
|
||||
label: string | null | undefined,
|
||||
t: (key: string, opts?: { defaultValue?: string }) => string,
|
||||
): string {
|
||||
if (label === "platform") {
|
||||
return t("agents:settlementBills.platform", { defaultValue: "平台" });
|
||||
}
|
||||
|
||||
return label?.trim() || "—";
|
||||
}
|
||||
508
src/modules/settlement/settlement-period-workbench.tsx
Normal file
508
src/modules/settlement/settlement-period-workbench.tsx
Normal file
@@ -0,0 +1,508 @@
|
||||
"use client";
|
||||
|
||||
import { Plus } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
postSettlementPeriod,
|
||||
postSettlementPeriodClose,
|
||||
type SettlementPeriodCloseResult,
|
||||
type SettlementPeriodRow,
|
||||
} from "@/api/admin-agent-settlement";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
import { AdminPageCard } from "@/components/admin/admin-page-card";
|
||||
import { SettlementPeriodsTable } from "@/modules/settlement/settlement-periods-table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
formatSettlementPeriodSpan,
|
||||
settlementPeriodPresetRange,
|
||||
type SettlementPeriodPresetKey,
|
||||
} from "@/lib/agent-settlement-period-range";
|
||||
import { settlementPeriodStatusLabel } from "@/modules/settlement/settlement-status-label";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
|
||||
const PRESET_KEYS: SettlementPeriodPresetKey[] = ["this_week", "last_week", "this_month"];
|
||||
|
||||
type PeriodStatusFilter = "all" | "open" | "closed" | "completed";
|
||||
|
||||
const STATUS_FILTER_OPTIONS: PeriodStatusFilter[] = ["all", "open", "closed", "completed"];
|
||||
|
||||
type SettlementPeriodWorkbenchProps = {
|
||||
adminSiteId: number;
|
||||
currencyCode: string;
|
||||
canManage: boolean;
|
||||
periods: SettlementPeriodRow[];
|
||||
onViewDetail: (periodId: number) => void;
|
||||
onReloadPeriods: () => Promise<SettlementPeriodRow[]>;
|
||||
onPeriodOpened?: (periodId: number) => void;
|
||||
onPeriodClosed?: (result: SettlementPeriodCloseResult) => void;
|
||||
};
|
||||
|
||||
export function SettlementPeriodWorkbench({
|
||||
adminSiteId,
|
||||
currencyCode,
|
||||
canManage,
|
||||
periods,
|
||||
onViewDetail,
|
||||
onReloadPeriods,
|
||||
onPeriodOpened,
|
||||
onPeriodClosed,
|
||||
}: SettlementPeriodWorkbenchProps): React.ReactElement {
|
||||
const { t } = useTranslation(["settlementCenter", "agents", "common"]);
|
||||
const [draftStatus, setDraftStatus] = useState<PeriodStatusFilter>("all");
|
||||
const [appliedStatus, setAppliedStatus] = useState<PeriodStatusFilter>("all");
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(10);
|
||||
const [openDialogOpen, setOpenDialogOpen] = useState(false);
|
||||
const [customStart, setCustomStart] = useState("");
|
||||
const [customEnd, setCustomEnd] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [reloading, setReloading] = useState(false);
|
||||
const [closeDialogOpen, setCloseDialogOpen] = useState(false);
|
||||
const [closeTarget, setCloseTarget] = useState<SettlementPeriodRow | null>(null);
|
||||
|
||||
const openPeriod = useMemo(
|
||||
() => periods.find((row) => row.status === "open") ?? null,
|
||||
[periods],
|
||||
);
|
||||
|
||||
const filteredPeriods = useMemo(() => {
|
||||
let list = [...periods];
|
||||
if (appliedStatus !== "all") {
|
||||
list = list.filter((row) => row.status === appliedStatus);
|
||||
}
|
||||
return list.sort((a, b) => b.id - a.id);
|
||||
}, [periods, appliedStatus]);
|
||||
|
||||
const lastPage = Math.max(1, Math.ceil(filteredPeriods.length / perPage));
|
||||
|
||||
const pagedPeriods = useMemo(() => {
|
||||
const start = (page - 1) * perPage;
|
||||
return filteredPeriods.slice(start, start + perPage);
|
||||
}, [filteredPeriods, page, perPage]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [appliedStatus, perPage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (page > lastPage) {
|
||||
setPage(lastPage);
|
||||
}
|
||||
}, [page, lastPage]);
|
||||
|
||||
const presetLabel = (key: SettlementPeriodPresetKey): string => {
|
||||
switch (key) {
|
||||
case "this_week":
|
||||
return t("agents:settlementPeriods.presetThisWeek", { defaultValue: "本周" });
|
||||
case "last_week":
|
||||
return t("agents:settlementPeriods.presetLastWeek", { defaultValue: "上周" });
|
||||
case "this_month":
|
||||
return t("agents:settlementPeriods.presetThisMonth", { defaultValue: "本月" });
|
||||
}
|
||||
};
|
||||
|
||||
const statusFilterLabel = (value: PeriodStatusFilter): string => {
|
||||
if (value === "all") {
|
||||
return t("filters.statusAll", { defaultValue: "全部" });
|
||||
}
|
||||
return settlementPeriodStatusLabel(value, t);
|
||||
};
|
||||
|
||||
async function openWithRange(periodStart: string, periodEnd: string): Promise<void> {
|
||||
if (!canManage) {
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const row = await postSettlementPeriod({
|
||||
admin_site_id: adminSiteId,
|
||||
period_start: periodStart,
|
||||
period_end: periodEnd,
|
||||
});
|
||||
await onReloadPeriods();
|
||||
onPeriodOpened?.(row.id);
|
||||
setOpenDialogOpen(false);
|
||||
setCustomStart("");
|
||||
setCustomEnd("");
|
||||
toast.success(t("agents:settlementPeriods.opened", { defaultValue: "账期已开启" }));
|
||||
} catch (err: unknown) {
|
||||
toast.error(
|
||||
err instanceof LotteryApiBizError
|
||||
? err.message
|
||||
: t("agents:settlementPeriods.openFailed", { defaultValue: "开期失败" }),
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function openWithPreset(key: SettlementPeriodPresetKey): Promise<void> {
|
||||
const range = settlementPeriodPresetRange(key);
|
||||
await openWithRange(range.period_start, range.period_end);
|
||||
}
|
||||
|
||||
async function openCustom(): Promise<void> {
|
||||
if (!customStart.trim() || !customEnd.trim()) {
|
||||
toast.error(t("agents:settlementPeriods.datesRequired", { defaultValue: "请填写账期起止" }));
|
||||
return;
|
||||
}
|
||||
await openWithRange(customStart, customEnd);
|
||||
}
|
||||
|
||||
function requestClose(row: SettlementPeriodRow): void {
|
||||
setCloseTarget(row);
|
||||
setCloseDialogOpen(true);
|
||||
}
|
||||
|
||||
async function confirmClose(): Promise<void> {
|
||||
if (!closeTarget) {
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await postSettlementPeriodClose(closeTarget.id);
|
||||
const items = await onReloadPeriods();
|
||||
setCloseDialogOpen(false);
|
||||
setCloseTarget(null);
|
||||
onPeriodClosed?.(result);
|
||||
const stillThere = items.find((row) => row.id === closeTarget.id);
|
||||
if (stillThere?.status === "closed") {
|
||||
toast.success(t("agents:settlementPeriods.closed", { defaultValue: "账期已关账,账单已生成" }));
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
toast.error(
|
||||
err instanceof LotteryApiBizError
|
||||
? err.message
|
||||
: t("agents:settlementPeriods.closeFailed", { defaultValue: "关账失败" }),
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRefresh(): Promise<void> {
|
||||
setReloading(true);
|
||||
try {
|
||||
await onReloadPeriods();
|
||||
} finally {
|
||||
setReloading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function applyFilters(): void {
|
||||
setAppliedStatus(draftStatus);
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
function resetFilters(): void {
|
||||
setDraftStatus("all");
|
||||
setAppliedStatus("all");
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
const shareCount = closeTarget?.pipeline?.share_ledger_count ?? 0;
|
||||
const unsettledCount = closeTarget?.pipeline?.unsettled_ticket_count ?? 0;
|
||||
|
||||
const cardDescription = canManage
|
||||
? t("subtitle", { defaultValue: "账期关账、账单确认与收付登记" })
|
||||
: t("periodTable.readOnlyHint", {
|
||||
defaultValue: "绑定代理账号不可开/关账期,仅可查看与收付。",
|
||||
});
|
||||
|
||||
const openPeriodHiddenByFilter =
|
||||
openPeriod !== null &&
|
||||
appliedStatus !== "all" &&
|
||||
appliedStatus !== "open" &&
|
||||
!filteredPeriods.some((row) => row.id === openPeriod.id);
|
||||
|
||||
const tableEmptyMessage = useMemo(() => {
|
||||
if (periods.length === 0) {
|
||||
if (!canManage) {
|
||||
return t("periodTable.emptyReadOnly", { defaultValue: "暂无账期记录。" });
|
||||
}
|
||||
return t("periodTable.emptyOpenHint", {
|
||||
defaultValue: "暂无账期,请点击工具栏「开账」创建。",
|
||||
});
|
||||
}
|
||||
if (openPeriodHiddenByFilter) {
|
||||
return t("periodTable.emptyFilteredOpen", {
|
||||
defaultValue: "当前筛选未包含进行中的账期,请选「全部」或「进行中」。",
|
||||
});
|
||||
}
|
||||
return t("periodTable.emptyFiltered", { defaultValue: "筛选结果为空,请重置筛选。" });
|
||||
}, [canManage, openPeriodHiddenByFilter, periods.length, t]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminPageCard
|
||||
title={t("periodTable.title", { defaultValue: "账期管理" })}
|
||||
description={cardDescription}
|
||||
>
|
||||
<div className="admin-list-toolbar">
|
||||
<div className="admin-list-field">
|
||||
<Label htmlFor="sp-status-filter" className="sm:shrink-0">
|
||||
{t("periodTable.statusFilter", { defaultValue: "状态" })}
|
||||
</Label>
|
||||
<Select
|
||||
modal={false}
|
||||
value={draftStatus}
|
||||
onValueChange={(v) => setDraftStatus((v ?? "all") as PeriodStatusFilter)}
|
||||
>
|
||||
<SelectTrigger id="sp-status-filter" className="h-9 w-full sm:w-40">
|
||||
<SelectValue>{() => statusFilterLabel(draftStatus)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_FILTER_OPTIONS.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{statusFilterLabel(value)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="admin-list-actions">
|
||||
{canManage && openPeriod ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() => requestClose(openPeriod)}
|
||||
>
|
||||
{t("periodTable.close", { defaultValue: "关账" })}
|
||||
</Button>
|
||||
) : null}
|
||||
{canManage && !openPeriod ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() => setOpenDialogOpen(true)}
|
||||
>
|
||||
<Plus className="size-4" aria-hidden />
|
||||
{t("period.openBtn", { defaultValue: "开账" })}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="button" size="sm" onClick={() => applyFilters()}>
|
||||
{t("ledgerPanel.searchBtn", { defaultValue: "搜索" })}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => resetFilters()}>
|
||||
{t("ledgerPanel.reset", { defaultValue: "重置筛选" })}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={reloading}
|
||||
onClick={() => void handleRefresh()}
|
||||
>
|
||||
{t("ledgerPanel.refresh", { defaultValue: "刷新当前页" })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canManage && openPeriod ? (
|
||||
<div className="flex flex-col gap-2 rounded-md border border-amber-200/80 bg-amber-50/60 px-3 py-2 text-sm text-amber-950 sm:flex-row sm:items-center sm:justify-between dark:border-amber-900/50 dark:bg-amber-950/30 dark:text-amber-100">
|
||||
<p>
|
||||
{t("periodTable.hasOpen", {
|
||||
defaultValue: "已有进行中账期 {{range}},须先关账才能开新期。",
|
||||
range: formatSettlementPeriodSpan(openPeriod.period_start, openPeriod.period_end),
|
||||
})}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="shrink-0"
|
||||
disabled={busy}
|
||||
onClick={() => requestClose(openPeriod)}
|
||||
>
|
||||
{t("periodTable.closeNow", { defaultValue: "立即关账" })}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<SettlementPeriodsTable
|
||||
periods={pagedPeriods}
|
||||
loading={reloading}
|
||||
canManage={canManage}
|
||||
busy={busy}
|
||||
currencyCode={currencyCode}
|
||||
emptyMessage={tableEmptyMessage}
|
||||
onViewDetail={onViewDetail}
|
||||
onRequestClose={requestClose}
|
||||
/>
|
||||
|
||||
<AdminListPaginationFooter
|
||||
selectId="settlement-periods-per-page"
|
||||
total={filteredPeriods.length}
|
||||
page={page}
|
||||
lastPage={lastPage}
|
||||
perPage={perPage}
|
||||
loading={reloading}
|
||||
onPerPageChange={(next) => {
|
||||
setPerPage(next);
|
||||
setPage(1);
|
||||
}}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</AdminPageCard>
|
||||
|
||||
<Dialog
|
||||
open={openDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setOpenDialogOpen(open);
|
||||
if (!open) {
|
||||
setCustomStart("");
|
||||
setCustomEnd("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("period.openTitle", { defaultValue: "开账" })}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("agents:settlementPeriods.openHint", {
|
||||
defaultValue: "选择快捷账期或自定义起止时间。",
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{PRESET_KEYS.map((key) => (
|
||||
<Button
|
||||
key={key}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={busy}
|
||||
onClick={() => void openWithPreset(key)}
|
||||
>
|
||||
{presetLabel(key)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="sp-dialog-start">
|
||||
{t("agents:settlementPeriods.start", { defaultValue: "开始" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="sp-dialog-start"
|
||||
type="datetime-local"
|
||||
value={customStart}
|
||||
onChange={(e) => setCustomStart(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="sp-dialog-end">
|
||||
{t("agents:settlementPeriods.end", { defaultValue: "结束" })}
|
||||
</Label>
|
||||
<Input
|
||||
id="sp-dialog-end"
|
||||
type="datetime-local"
|
||||
value={customEnd}
|
||||
onChange={(e) => setCustomEnd(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => setOpenDialogOpen(false)}
|
||||
>
|
||||
{t("common:cancel", { defaultValue: "取消" })}
|
||||
</Button>
|
||||
<Button type="button" disabled={busy} onClick={() => void openCustom()}>
|
||||
{t("agents:settlementPeriods.open", { defaultValue: "开期" })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={closeDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setCloseDialogOpen(open);
|
||||
if (!open) {
|
||||
setCloseTarget(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("period.closeDialogTitle", { defaultValue: "确认关账" })}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{closeTarget
|
||||
? t("period.closeDialogDesc", {
|
||||
defaultValue: "将汇总 {{range}} 内的流水并生成账单。",
|
||||
range: formatSettlementPeriodSpan(
|
||||
closeTarget.period_start,
|
||||
closeTarget.period_end,
|
||||
),
|
||||
})
|
||||
: null}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{closeTarget ? (
|
||||
<ul className="list-inside list-disc space-y-1 text-sm text-muted-foreground">
|
||||
<li>
|
||||
{shareCount > 0
|
||||
? t("period.closeDialogShare", {
|
||||
defaultValue: "流水 {{count}} 笔",
|
||||
count: shareCount,
|
||||
})
|
||||
: t("period.closeDialogEmpty", {
|
||||
defaultValue: "本期暂无占成流水,关账后不会生成账单。",
|
||||
})}
|
||||
</li>
|
||||
{unsettledCount > 0 ? (
|
||||
<li className="text-amber-800">
|
||||
{t("period.closeDialogUnsettled", {
|
||||
defaultValue: "仍有 {{count}} 笔注单未结算",
|
||||
count: unsettledCount,
|
||||
})}
|
||||
</li>
|
||||
) : null}
|
||||
<li>
|
||||
{t("period.closeDialogIrreversible", {
|
||||
defaultValue: "关账后不可撤销,差错请通过调账或冲正处理。",
|
||||
})}
|
||||
</li>
|
||||
</ul>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" disabled={busy} onClick={() => setCloseDialogOpen(false)}>
|
||||
{t("common:cancel", { defaultValue: "取消" })}
|
||||
</Button>
|
||||
<Button type="button" disabled={busy} onClick={() => void confirmClose()}>
|
||||
{t("period.closeDialogConfirm", { defaultValue: "确认关账" })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
169
src/modules/settlement/settlement-periods-table.tsx
Normal file
169
src/modules/settlement/settlement-periods-table.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
"use client";
|
||||
|
||||
import { Eye, Lock } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { SettlementPeriodRow } from "@/api/admin-agent-settlement";
|
||||
import { AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
|
||||
import { AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
|
||||
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
||||
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
||||
import { formatSettlementPeriodSpan } from "@/lib/agent-settlement-period-range";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
|
||||
import { signedSettlementMoneyClass } from "@/modules/settlement/settlement-signed-money";
|
||||
import { settlementPeriodStatusLabel } from "@/modules/settlement/settlement-status-label";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
const COL_SPAN = 9;
|
||||
|
||||
type SettlementPeriodsTableProps = {
|
||||
periods: SettlementPeriodRow[];
|
||||
loading?: boolean;
|
||||
canManage: boolean;
|
||||
busy?: boolean;
|
||||
currencyCode?: string;
|
||||
emptyMessage?: string;
|
||||
onViewDetail: (periodId: number) => void;
|
||||
onRequestClose: (row: SettlementPeriodRow) => void;
|
||||
};
|
||||
|
||||
export function SettlementPeriodsTable({
|
||||
periods,
|
||||
loading = false,
|
||||
canManage,
|
||||
busy = false,
|
||||
currencyCode = "NPR",
|
||||
emptyMessage,
|
||||
onViewDetail,
|
||||
onRequestClose,
|
||||
}: SettlementPeriodsTableProps): React.ReactElement {
|
||||
const { t } = useTranslation(["settlementCenter", "agents", "common"]);
|
||||
|
||||
const billCount = (row: SettlementPeriodRow): number =>
|
||||
(row.summary?.player_bills ?? 0)
|
||||
+ (row.summary?.agent_bills ?? 0)
|
||||
+ (row.summary?.adjustment_bills ?? 0);
|
||||
|
||||
const winLossScope = periods.find((row) => row.pipeline?.win_loss_scope)?.pipeline?.win_loss_scope
|
||||
?? "platform";
|
||||
const winLossLabel =
|
||||
winLossScope === "agent"
|
||||
? t("periodTable.agentWinLoss", { defaultValue: "代理输赢" })
|
||||
: t("periodTable.platformWinLoss", { defaultValue: "平台输赢" });
|
||||
|
||||
return (
|
||||
<div className="admin-table-shell overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("periodTable.range", { defaultValue: "账期" })}</TableHead>
|
||||
<TableHead>{t("columns.status", { defaultValue: "状态" })}</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("periodTable.shareLedger", { defaultValue: "占成流水" })}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">{winLossLabel}</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("agents:settlementReports.summary.billCount", { defaultValue: "账单数" })}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("periodTable.pending", { defaultValue: "待确认" })}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("periodTable.awaiting", { defaultValue: "待收付" })}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("agents:settlementReports.summary.totalUnpaid", { defaultValue: "未结合计" })}
|
||||
</TableHead>
|
||||
<TableHead className="sticky right-0 z-10 w-36 bg-muted text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]">
|
||||
{t("common:table.actions", { defaultValue: "操作" })}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<AdminTableLoadingRow colSpan={COL_SPAN} />
|
||||
) : periods.length === 0 ? (
|
||||
<AdminTableNoResourceRow colSpan={COL_SPAN} message={emptyMessage} />
|
||||
) : (
|
||||
periods.map((row) => {
|
||||
const canCloseRow = canManage && row.status === "open";
|
||||
|
||||
return (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="font-medium whitespace-nowrap">
|
||||
{formatSettlementPeriodSpan(row.period_start, row.period_end)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<AdminStatusBadge status={row.status}>
|
||||
{settlementPeriodStatusLabel(row.status, t)}
|
||||
</AdminStatusBadge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{row.pipeline?.share_ledger_count ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={cn(
|
||||
"text-right tabular-nums whitespace-nowrap",
|
||||
row.pipeline?.game_win_loss_total != null
|
||||
? signedSettlementMoneyClass(row.pipeline.game_win_loss_total, true)
|
||||
: undefined,
|
||||
)}
|
||||
>
|
||||
{row.pipeline?.game_win_loss_total != null
|
||||
? formatDashboardMoneyMinor(row.pipeline.game_win_loss_total, currencyCode)
|
||||
: "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{billCount(row) > 0 ? billCount(row) : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{row.summary?.pending_confirm ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{row.summary?.awaiting_payment ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums whitespace-nowrap">
|
||||
{(row.summary?.total_unpaid ?? 0) > 0
|
||||
? formatDashboardMoneyMinor(row.summary?.total_unpaid ?? 0, currencyCode)
|
||||
: "—"}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="sticky right-0 z-10 bg-card text-center shadow-[-1px_0_0_rgba(203,213,225,0.7)]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<AdminRowActionsMenu
|
||||
busy={busy}
|
||||
actions={[
|
||||
{
|
||||
key: "detail",
|
||||
label: t("periodTable.viewDetail", { defaultValue: "查看详情" }),
|
||||
icon: Eye,
|
||||
onClick: () => onViewDetail(row.id),
|
||||
},
|
||||
{
|
||||
key: "close",
|
||||
label: t("periodTable.close", { defaultValue: "关账" }),
|
||||
icon: Lock,
|
||||
hidden: !canCloseRow,
|
||||
onClick: () => onRequestClose(row),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
src/modules/settlement/settlement-signed-money.ts
Normal file
13
src/modules/settlement/settlement-signed-money.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** 结算金额正负着色:负红、正绿、零灰 */
|
||||
export function signedSettlementMoneyClass(amount: number, emphasize = false): string {
|
||||
if (amount < 0) {
|
||||
return cn("text-destructive", emphasize && "font-medium");
|
||||
}
|
||||
if (amount > 0) {
|
||||
return cn("text-emerald-700 dark:text-emerald-400", emphasize && "font-medium");
|
||||
}
|
||||
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
@@ -52,6 +52,13 @@ export function creditLedgerReasonLabel(
|
||||
reason: string,
|
||||
t: TFunction<"settlementCenter">,
|
||||
): string {
|
||||
if (reason === "game_settlement") {
|
||||
return t("creditLedger.reason.game_settlement", { defaultValue: "开奖结算" });
|
||||
}
|
||||
if (reason === "bet_hold") {
|
||||
return t("creditLedger.reason.bet_hold", { defaultValue: "下注冻结" });
|
||||
}
|
||||
|
||||
const key = `creditLedger.reason.${reason}` as const;
|
||||
return t(key, { defaultValue: reason });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user