feat(settlement, admin): introduce new types and functions for downline share and settlement period hints

Added new types for downline share breakdown and settlement period open hints to enhance the agent settlement API. Updated the admin console components to support these new features, improving the user experience with better data presentation and interaction. Additionally, refined the date range field to accommodate new calendar markers and hints, ensuring a more intuitive interface for managing settlement periods.
This commit is contained in:
2026-06-12 16:01:42 +08:00
parent 1eb6702c51
commit 24fd7c10bd
50 changed files with 1821 additions and 618 deletions

View File

@@ -14,15 +14,17 @@ import {
type RebateAllocationRow,
type SettlementBillRow,
type SettlementBillPaymentRow,
type DownlineShareBreakdown,
} from "@/api/admin-agent-settlement";
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { formatAdminMinorDecimal, parseAdminMajorToMinor, parseSignedAdminMajorToMinor } from "@/lib/money";
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 { settlementBillOperableByBoundAgent } from "@/modules/settlement/settlement-bill-operable";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -33,6 +35,8 @@ type AgentBillDetailProps = {
billId: number;
currencyCode: string;
canManage?: boolean;
canFinanceAdjustments?: boolean;
boundAgent?: { id: number } | null;
onUpdated?: () => void;
};
@@ -40,12 +44,15 @@ export function AgentBillDetail({
billId,
currencyCode,
canManage = true,
canFinanceAdjustments = false,
boundAgent = null,
onUpdated,
}: AgentBillDetailProps): React.ReactElement {
const { t } = useTranslation(["agents", "settlementCenter", "common"]);
const [bill, setBill] = useState<SettlementBillRow | null>(null);
const [payments, setPayments] = useState<SettlementBillPaymentRow[]>([]);
const [rebateAllocations, setRebateAllocations] = useState<RebateAllocationRow[]>([]);
const [downlineShares, setDownlineShares] = useState<DownlineShareBreakdown | null>(null);
const [loading, setLoading] = useState(true);
const [payAmount, setPayAmount] = useState("");
const [payMethod, setPayMethod] = useState("");
@@ -63,11 +70,12 @@ export function AgentBillDetail({
setBill(data.bill);
setPayments(data.payments ?? []);
setRebateAllocations(data.rebate_allocations ?? []);
setPayAmount(String(data.bill.unpaid_amount ?? 0));
setDownlineShares(data.downline_shares ?? null);
setPayAmount(formatAdminMinorDecimal(data.bill.unpaid_amount ?? 0, currencyCode));
} finally {
setLoading(false);
}
}, [billId]);
}, [billId, currencyCode]);
useEffect(() => {
void load();
@@ -78,6 +86,7 @@ export function AgentBillDetail({
}
const direction = describeBillPaymentDirection(bill, t);
const canOperateBill = canManage && settlementBillOperableByBoundAgent(bill, boundAgent);
const locked = ["confirmed", "partial_paid", "settled", "overdue"].includes(bill.status);
const paymentTitle = direction.ownerOwes
? t("settlementBills.submitReceipt", { defaultValue: "登记收款" })
@@ -86,7 +95,7 @@ export function AgentBillDetail({
? t("settlementBills.submitReceipt", { defaultValue: "确认收款" })
: t("settlementBills.submitPayout", { defaultValue: "确认付款" });
const canWriteOff =
canManage &&
canFinanceAdjustments &&
bill.unpaid_amount > 0 &&
["confirmed", "partial_paid", "overdue"].includes(bill.status) &&
!["adjustment", "reversal", "bad_debt"].includes(bill.bill_type);
@@ -119,14 +128,6 @@ export function AgentBillDetail({
toast.error(err instanceof LotteryApiBizError ? err.message : fallback);
};
const parseWholeAmount = (raw: string): number | null => {
const value = Number(raw);
if (!Number.isFinite(value) || !Number.isInteger(value)) {
return null;
}
return value;
};
const requestConfirmBill = (): void => {
requestConfirm({
title: t("settlementBills.confirmBillTitle", { defaultValue: "确认账单?" }),
@@ -152,9 +153,9 @@ export function AgentBillDetail({
};
const requestPayment = (): void => {
const amount = parseWholeAmount(payAmount);
const amount = parseAdminMajorToMinor(payAmount, currencyCode);
if (amount === null || amount <= 0) {
toast.error(t("settlementBills.paymentAmountInvalid", { defaultValue: "请输入大于 0 的整数金额" }));
toast.error(t("settlementBills.paymentAmountInvalid", { defaultValue: "请输入大于 0 的有效金额" }));
return;
}
if (amount > bill.unpaid_amount) {
@@ -220,10 +221,10 @@ export function AgentBillDetail({
};
const requestAdjustment = (): void => {
const amount = parseWholeAmount(adjustAmount);
const amount = parseSignedAdminMajorToMinor(adjustAmount, currencyCode);
const reason = adjustReason.trim();
if (amount === null || amount === 0) {
toast.error(t("settlementBills.adjustmentAmountInvalid", { defaultValue: "请输入非 0 的整数调整金额" }));
toast.error(t("settlementBills.adjustmentAmountInvalid", { defaultValue: "请输入非 0 的有效金额" }));
return;
}
if (!reason) {
@@ -262,38 +263,38 @@ export function AgentBillDetail({
return (
<>
<ConfirmDialog />
<div className="grid gap-6 md: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} />
<div className="flex flex-col gap-5 text-sm">
<SettlementBillSummaryHeader bill={bill} currencyCode={currencyCode} />
<SettlementBillAmountBreakdown
bill={bill}
currencyCode={currencyCode}
downlineShares={downlineShares}
/>
{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>
{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 className="space-y-5 text-sm">
{rebateAllocations.length > 0 ? (
{rebateAllocations.length > 0 ? (
<div className="space-y-2 rounded-xl border border-border/70 bg-card p-5 shadow-sm">
<p className="font-semibold tracking-tight">
{t("settlementBills.rebateAllocations", { defaultValue: "回水分摊" })}
@@ -353,30 +354,30 @@ export function AgentBillDetail({
</div>
) : null}
{canManage && bill.status === "pending_confirm" ? (
<div className="space-y-4 rounded-xl border border-border/70 bg-primary/5 p-5 shadow-sm">
<div className="space-y-1">
<p className="font-semibold tracking-tight text-primary">
{t("settlementBills.confirm", { defaultValue: "确认账单" })}
</p>
<p className="text-xs text-muted-foreground/80">
{t("settlementCenter:billDisplay.confirmHint", {
defaultValue: "确认后才可以登记收款或付款。",
})}
</p>
</div>
<Button
type="button"
className="w-full"
disabled={confirmBusy}
onClick={requestConfirmBill}
>
{canOperateBill && bill.status === "pending_confirm" ? (
<div className="space-y-3 rounded-xl border border-primary/20 bg-primary/5 p-5 shadow-sm">
<div className="space-y-1">
<p className="font-semibold tracking-tight text-primary">
{t("settlementBills.confirm", { defaultValue: "确认账单" })}
</Button>
</p>
<p className="text-xs text-muted-foreground/80">
{t("settlementCenter:billDisplay.confirmHint", {
defaultValue: "确认后才可以登记收款或付款。",
})}
</p>
</div>
) : null}
<Button
type="button"
className="w-full sm:w-auto sm:min-w-[10rem]"
disabled={confirmBusy}
onClick={requestConfirmBill}
>
{t("settlementBills.confirm", { defaultValue: "确认账单" })}
</Button>
</div>
) : null}
{canManage && ["confirmed", "partial_paid", "overdue"].includes(bill.status) && bill.unpaid_amount > 0 ? (
{canOperateBill && ["confirmed", "partial_paid", "overdue"].includes(bill.status) && bill.unpaid_amount > 0 ? (
<div className="space-y-4 rounded-xl border border-border/70 bg-card p-5 shadow-sm">
<div className="space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
@@ -398,7 +399,8 @@ export function AgentBillDetail({
<Input
value={payAmount}
onChange={(e) => setPayAmount(e.target.value)}
placeholder={String(bill.unpaid_amount)}
inputMode="decimal"
placeholder={formatAdminMinorDecimal(bill.unpaid_amount, currencyCode)}
className="bg-background/50 transition-colors focus:bg-background"
/>
</div>
@@ -436,7 +438,7 @@ export function AgentBillDetail({
</div>
) : null}
{canWriteOff ? (
{canWriteOff ? (
<div className="space-y-4 rounded-xl border border-destructive/20 bg-destructive/5 p-5 shadow-sm">
<div className="space-y-1">
<p className="font-semibold tracking-tight text-destructive">
@@ -471,7 +473,7 @@ export function AgentBillDetail({
</div>
) : null}
{canManage && locked ? (
{canFinanceAdjustments && locked ? (
<div className="space-y-4 rounded-xl border border-dashed border-border/70 bg-card p-5 shadow-sm">
<div className="space-y-1">
<p className="font-semibold tracking-tight">
@@ -488,9 +490,9 @@ export function AgentBillDetail({
<Input
value={adjustAmount}
onChange={(e) => setAdjustAmount(e.target.value)}
type="number"
inputMode="decimal"
placeholder={t("settlementBills.adjustmentAmountPlaceholder", {
defaultValue: "输入正数或负数",
defaultValue: "例如35.20 或 -10.00",
})}
className="bg-background/50 transition-colors focus:bg-background"
/>
@@ -517,7 +519,6 @@ export function AgentBillDetail({
</Button>
</div>
) : null}
</div>
</div>
</>
);

View File

@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
@@ -18,8 +18,9 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useAdminProfile } from "@/stores/admin-session";
const REPORT_TYPES: AgentSettlementReportType[] = [
const ALL_REPORT_TYPES: AgentSettlementReportType[] = [
"summary",
"player_win_loss",
"agent_share",
@@ -43,6 +44,14 @@ export function AgentSettlementReportsPanel({
currencyCode,
}: AgentSettlementReportsPanelProps): React.ReactElement {
const { t } = useTranslation(["agents", "common"]);
const profile = useAdminProfile();
const reportTypes = useMemo(
() =>
profile?.agent != null
? ALL_REPORT_TYPES.filter((type) => type !== "platform_pnl")
: ALL_REPORT_TYPES,
[profile?.agent],
);
const [reportType, setReportType] = useState<AgentSettlementReportType>("summary");
const [response, setResponse] = useState<AgentSettlementReportResponse | null>(null);
const [loading, setLoading] = useState(false);
@@ -82,7 +91,7 @@ export function AgentSettlementReportsPanel({
<SelectValue>{() => reportTypeLabel(reportType)}</SelectValue>
</SelectTrigger>
<SelectContent>
{REPORT_TYPES.map((key) => (
{reportTypes.map((key) => (
<SelectItem key={key} value={key}>
{reportTypeLabel(key)}
</SelectItem>

View File

@@ -8,6 +8,7 @@ import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { formatSettlementPeriodSpan } from "@/lib/agent-settlement-period-range";
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
import { settlementAdjustmentTypeLabel } from "@/modules/settlement/settlement-status-label";
import {
Table,
TableBody,
@@ -62,9 +63,7 @@ export function SettlementAdjustmentsTable({
{formatSettlementPeriodSpan(row.period_start, row.period_end)}
</TableCell>
<TableCell>
{t(`adjustmentType.${row.adjustment_type}`, {
defaultValue: row.adjustment_type,
})}
{settlementAdjustmentTypeLabel(row.adjustment_type, t)}
</TableCell>
<TableCell className="tabular-nums">
{row.original_bill_id != null ? `#${row.original_bill_id}` : "—"}

View File

@@ -10,8 +10,13 @@ import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-ana
import {
buildBillAmountBreakdown,
describeBillPaymentDirection,
resolveBillPartyName,
type BillBreakdownLine,
type DownlineShareBreakdown,
} from "@/modules/settlement/settlement-bill-display";
import {
formatSignedSettlementMoney,
signedSettlementMoneyClass,
} from "@/modules/settlement/settlement-signed-money";
import {
settlementBillStatusLabel,
settlementBillTypeLabel,
@@ -41,67 +46,74 @@ export function SettlementBillSummaryHeader({
</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 className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div className="min-w-0 space-y-3">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm">
<span className="text-muted-foreground">
{t("settlementCenter:billDisplay.payerLabel", { defaultValue: "付款方" })}
</span>
<span className="font-semibold text-foreground">{direction.payer}</span>
<ArrowRight className="size-4 shrink-0 text-muted-foreground" aria-hidden />
<span className="text-muted-foreground">
{t("settlementCenter:billDisplay.payeeLabel", { defaultValue: "收款方" })}
</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-xl border border-border/50 bg-background/50 px-4 py-3">
<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>
<p className="text-xs text-muted-foreground">
{t("settlementCenter:billDisplay.settlementAmount", { defaultValue: "结算金额" })}
</p>
<p className="mt-0.5 text-2xl font-bold tabular-nums tracking-tight text-foreground">
{formatDashboardMoneyMinor(direction.amount, currencyCode)}
</p>
</div>
</div>
<div
className={cn(
"rounded-xl border px-4 py-3",
unpaid
? "border-amber-200/80 bg-amber-50/80 dark:border-amber-900/50 dark:bg-amber-950/20"
: "border-border/50 bg-background/50",
)}
>
<p className="text-xs text-muted-foreground">
{t("settlementCenter:columns.unpaid", { defaultValue: "未结" })}
</p>
<p
<div className="grid w-full gap-2 sm:grid-cols-2 lg:w-auto lg:min-w-[15rem]">
<div className="rounded-lg border border-border/50 bg-background/50 px-4 py-3">
<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(
"mt-0.5 font-semibold tabular-nums",
unpaid && "text-amber-900 dark:text-amber-200",
"rounded-lg border px-4 py-3",
unpaid
? "border-amber-200/80 bg-amber-50/80 dark:border-amber-900/50 dark:bg-amber-950/20"
: "border-border/50 bg-background/50",
)}
>
{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 className="text-xs text-muted-foreground">
{t("settlementCenter:columns.unpaid", { defaultValue: "未结" })}
</p>
) : (
<p className="mt-1 text-xs text-emerald-700 dark:text-emerald-400">
{t("settlementCenter:billDisplay.fullySettled", { defaultValue: "本期已结清" })}
<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>
</div>
@@ -111,85 +123,105 @@ export function SettlementBillSummaryHeader({
type SettlementBillAmountBreakdownProps = {
bill: SettlementBillRow;
currencyCode: string;
downlineShares?: DownlineShareBreakdown | null;
};
function BreakdownAmountLine({
line,
currencyCode,
nested = false,
}: {
line: BillBreakdownLine;
currencyCode: string;
nested?: boolean;
}): React.ReactElement {
const prefix =
line.kind === "subtract"
? ""
: line.kind === "add" && line.key !== "gross"
? "+"
: line.kind === "subtotal" || line.kind === "total"
? "="
: "";
return (
<>
<div
className={cn(
"grid grid-cols-[minmax(0,1fr)_7.5rem] items-start gap-x-4 py-1.5 text-sm",
nested && "py-1 pl-4",
!nested && (line.kind === "subtotal" || line.kind === "total") &&
"border-t border-border/60 pt-2.5 font-medium",
!nested && line.kind === "total" && "text-base",
)}
>
<div className="min-w-0">
<span
className={cn(
nested ? "text-muted-foreground/90" : line.kind === "total" ? "text-foreground" : "text-muted-foreground",
)}
>
{prefix ? <span className="mr-1.5 tabular-nums">{prefix}</span> : null}
{line.label}
</span>
{line.hint ? (
<p className="mt-0.5 text-xs leading-relaxed text-muted-foreground/80">{line.hint}</p>
) : null}
</div>
<span
className={cn(
"text-right tabular-nums",
signedSettlementMoneyClass(line.signedAmount, line.kind === "total"),
)}
>
{formatSignedSettlementMoney(line.signedAmount, currencyCode)}
</span>
</div>
{line.children?.map((child) => (
<BreakdownAmountLine key={child.key} line={child} currencyCode={currencyCode} nested />
))}
</>
);
}
export function SettlementBillAmountBreakdown({
bill,
currencyCode,
downlineShares = null,
}: SettlementBillAmountBreakdownProps): React.ReactElement | null {
const { t } = useTranslation(["settlementCenter", "agents"]);
const lines = buildBillAmountBreakdown(bill, t);
const lines = buildBillAmountBreakdown(bill, t, downlineShares);
if (lines.length === 0) {
return null;
}
const breakdownIntro =
bill.bill_type === "player"
? t("settlementCenter:billDisplay.playerBreakdownIntro", {
defaultValue: "玩家只与直属代理结算,净额 = 输赢 回水。",
})
: bill.bill_type === "agent"
? t("settlementCenter:billDisplay.agentBreakdownIntro", {
defaultValue: "代理只与直属上级结算,净额 = 团队净额 下级占成 本级占成。",
})
: null;
return (
<div className="space-y-4 rounded-xl border border-border/70 bg-card p-5 shadow-sm">
<p className="font-semibold tracking-tight text-foreground">
{t("settlementCenter:billDisplay.howAmountWorks", { defaultValue: "金额怎么来的" })}
</p>
<div className="space-y-1">
<p className="font-semibold tracking-tight text-foreground">
{t("settlementCenter:billDisplay.howAmountWorks", { defaultValue: "结算明细" })}
</p>
{breakdownIntro ? (
<p className="text-xs leading-relaxed text-muted-foreground">{breakdownIntro}</p>
) : null}
</div>
<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-4 text-sm sm:grid-cols-2">
<div className="rounded-xl border border-border/60 bg-card px-4 py-3.5 shadow-sm">
<p className="text-xs text-muted-foreground">
{t("settlementCenter:billDisplay.billOwner", { defaultValue: "账单主体" })}
</p>
<p className="mt-1 font-semibold text-foreground">{owner}</p>
</div>
<div className="rounded-xl border border-border/60 bg-card px-4 py-3.5 shadow-sm">
<p className="text-xs text-muted-foreground">
{t("settlementCenter:billDisplay.billCounterparty", { defaultValue: "结算对手" })}
</p>
<p className="mt-1 font-semibold text-foreground">{counterparty}</p>
<div className="rounded-lg border border-border/50 bg-muted/15 p-4">
{lines.map((line) => (
<BreakdownAmountLine key={line.key} line={line} currencyCode={currencyCode} />
))}
</div>
</div>
);

View File

@@ -2,6 +2,20 @@ import type { TFunction } from "i18next";
import type { SettlementBillRow } from "@/api/admin-agent-settlement";
function billDisplayLabel(
t: TFunction<["agents", "settlementCenter"]>,
key: string,
defaultValue: string,
vars: Record<string, string> = {},
): string {
const rendered = t(`settlementCenter:billDisplay.${key}`, { defaultValue, ...vars });
if (!/\{\{\w+\}\}/.test(rendered)) {
return rendered;
}
return defaultValue.replace(/\{\{(\w+)\}\}/g, (_, name: string) => vars[name] ?? "");
}
export type BillPartyRole = "owner" | "counterparty";
export type BillPaymentDirection = {
@@ -93,12 +107,25 @@ export function billDirectionHint(
});
}
export type DownlineShareItem = {
owner_id: number;
owner_label: string;
share_profit: number;
};
export type DownlineShareBreakdown = {
total: number;
items: DownlineShareItem[];
};
export type BillBreakdownLine = {
key: string;
label: string;
amount: number;
signedAmount: number;
kind: "add" | "subtract" | "subtotal" | "total";
hint?: string;
children?: BillBreakdownLine[];
};
export function parseBillMeta(metaJson: SettlementBillRow["meta_json"]): {
@@ -181,6 +208,7 @@ export function describeBillPaymentDirection(
export function buildBillAmountBreakdown(
bill: SettlementBillRow,
t: TFunction<["agents", "settlementCenter"]>,
downlineShares?: DownlineShareBreakdown | null,
): BillBreakdownLine[] {
const meta = parseBillMeta(bill.meta_json);
const gross = bill.gross_win_loss ?? 0;
@@ -195,7 +223,8 @@ export function buildBillAmountBreakdown(
lines.push({
key: "gross",
label: t("settlementCenter:billDisplay.playerGross", { defaultValue: "游戏输赢" }),
amount: gross,
amount: Math.abs(gross),
signedAmount: gross,
kind: "add",
hint:
gross > 0
@@ -209,7 +238,8 @@ export function buildBillAmountBreakdown(
lines.push({
key: "rebate",
label: t("settlementCenter:billDisplay.rebate", { defaultValue: "回水" }),
amount: rebate,
amount: Math.abs(rebate),
signedAmount: -Math.abs(rebate),
kind: "subtract",
});
}
@@ -217,7 +247,8 @@ export function buildBillAmountBreakdown(
lines.push({
key: "rounding",
label: t("agents:settlementBills.platformRounding", { defaultValue: "平台尾差" }),
amount: rounding,
amount: Math.abs(rounding),
signedAmount: rounding > 0 ? -Math.abs(rounding) : Math.abs(rounding),
kind: rounding > 0 ? "subtract" : "add",
});
}
@@ -228,19 +259,21 @@ export function buildBillAmountBreakdown(
? t("settlementCenter:billDisplay.playerNet", { defaultValue: "玩家应付净额" })
: t("settlementCenter:billDisplay.playerNetReceive", { defaultValue: "代理应付玩家" }),
amount: Math.abs(bill.net_amount),
signedAmount: bill.net_amount,
kind: "total",
});
return lines;
}
if (bill.bill_type === "agent") {
const owner = resolveBillPartyName(bill, "owner", t);
const counterparty = resolveBillPartyName(bill, "counterparty", t);
const lines: BillBreakdownLine[] = [];
if (bill.gross_win_loss != null) {
lines.push({
key: "gross",
label: t("settlementCenter:billDisplay.teamGross", { defaultValue: "团队游戏输赢" }),
amount: gross,
amount: Math.abs(gross),
signedAmount: gross,
kind: "add",
hint: t("settlementCenter:billDisplay.teamGrossHint", {
defaultValue: "含本级及下级玩家的合计",
@@ -251,7 +284,8 @@ export function buildBillAmountBreakdown(
lines.push({
key: "rebate",
label: t("settlementCenter:billDisplay.teamRebate", { defaultValue: "团队回水" }),
amount: rebate,
amount: Math.abs(rebate),
signedAmount: -Math.abs(rebate),
kind: "subtract",
});
}
@@ -260,28 +294,49 @@ export function buildBillAmountBreakdown(
key: "team-net",
label: t("settlementCenter:billDisplay.teamNet", { defaultValue: "团队净额" }),
amount: Math.abs(teamNet),
signedAmount: teamNet,
kind: "subtotal",
});
}
if (downlineShares && downlineShares.total > 0) {
lines.push({
key: "downline-share",
label: billDisplayLabel(t, "agentDownlineShare", "下级占成"),
amount: Math.abs(downlineShares.total),
signedAmount: -Math.abs(downlineShares.total),
kind: "subtract",
hint: billDisplayLabel(
t,
"agentDownlineShareHint",
"下级代理按占成比例保留的利润(明细见下行)",
),
children: downlineShares.items.map((item) => ({
key: `downline-share-${item.owner_id}`,
label: billDisplayLabel(t, "agentDownlineShareItem", "{{agent}} 保留", {
agent: item.owner_label,
}),
amount: Math.abs(item.share_profit),
signedAmount: -Math.abs(item.share_profit),
kind: "subtract" as const,
})),
});
}
if (meta.share_profit != null) {
lines.push({
key: "share",
label: t("settlementCenter:billDisplay.agentShareKeep", {
defaultValue: "{{agent}} 本级占成",
agent: owner,
}),
amount: shareProfit,
label: billDisplayLabel(t, "agentShareKeep", "本级占成"),
amount: Math.abs(shareProfit),
signedAmount: -Math.abs(shareProfit),
kind: "subtract",
hint: t("settlementCenter:billDisplay.agentShareKeepHint", {
defaultValue: "本级按占成比例留下的利润",
}),
hint: billDisplayLabel(t, "agentShareKeepHint", "本级按占成比例留下的利润"),
});
}
if (rounding !== 0) {
lines.push({
key: "rounding",
label: t("agents:settlementBills.platformRounding", { defaultValue: "平台尾差" }),
amount: rounding,
amount: Math.abs(rounding),
signedAmount: rounding > 0 ? -Math.abs(rounding) : Math.abs(rounding),
kind: rounding > 0 ? "subtract" : "add",
});
}
@@ -289,15 +344,10 @@ export function buildBillAmountBreakdown(
key: "net",
label:
bill.net_amount > 0
? t("settlementCenter:billDisplay.agentNet", {
defaultValue: "{{agent}} 应付级",
agent: owner,
})
: t("settlementCenter:billDisplay.agentNetReceive", {
defaultValue: "上级应付 {{agent}}",
agent: owner,
}),
? billDisplayLabel(t, "agentNet", "应付 {{counterparty}}", { counterparty })
: billDisplayLabel(t, "agentNetReceive", "{{counterparty}} 应付级", { counterparty }),
amount: Math.abs(bill.net_amount),
signedAmount: bill.net_amount,
kind: "total",
});
return lines;

View File

@@ -0,0 +1,59 @@
import type { SettlementBillRow } from "@/api/admin-agent-settlement";
type BoundAgentRef = { id: number } | null | undefined;
function billPayeeParty(
bill: Pick<SettlementBillRow, "owner_type" | "owner_id" | "counterparty_type" | "counterparty_id" | "net_amount">,
): { type: string; id: number } {
if ((bill.net_amount ?? 0) < 0) {
return { type: bill.owner_type, id: bill.owner_id };
}
return { type: bill.counterparty_type, id: bill.counterparty_id };
}
function agentBillOnDirectEdge(
actorId: number,
bill: Pick<SettlementBillRow, "owner_type" | "owner_id" | "counterparty_type" | "counterparty_id">,
): boolean {
if (bill.owner_id === actorId) {
return true;
}
if (bill.counterparty_type === "agent" && bill.counterparty_id === actorId) {
return true;
}
if (bill.counterparty_type === "platform" && bill.owner_id === actorId) {
return true;
}
return false;
}
/** 绑定代理仅可操作直属边账单,且代理账单仅收款方可登记(与后端 AdminAgentSettlementScope 一致)。 */
export function settlementBillOperableByBoundAgent(
bill: Pick<
SettlementBillRow,
"bill_type" | "owner_type" | "owner_id" | "counterparty_type" | "counterparty_id" | "net_amount"
>,
boundAgent: BoundAgentRef,
): boolean {
if (boundAgent == null) {
return true;
}
if (bill.owner_type === "player" || bill.bill_type === "player") {
return bill.counterparty_type === "agent" && bill.counterparty_id === boundAgent.id;
}
if (bill.owner_type === "agent" || bill.bill_type === "agent") {
if (!agentBillOnDirectEdge(boundAgent.id, bill)) {
return false;
}
const payee = billPayeeParty(bill);
return payee.type === "agent" && payee.id === boundAgent.id;
}
return false;
}

View File

@@ -1,23 +1,42 @@
export type SettlementPeriodView = "bills" | "ledger";
export type SettlementPeriodView = "bills" | "operations" | "ledger";
const VALID_VIEWS: SettlementPeriodView[] = ["bills", "ledger"];
const VALID_VIEWS: SettlementPeriodView[] = ["bills", "operations", "ledger"];
export function settlementCenterListHref(): string {
function parsePositiveInt(raw: string | null): number | null {
if (raw === null || raw === "") {
return null;
}
const value = Number(raw);
return Number.isInteger(value) && value > 0 ? value : null;
}
export function settlementCenterListHref(adminSiteId?: number | null): string {
if (adminSiteId != null && adminSiteId > 0) {
return `/admin/settlement-center?site=${adminSiteId}`;
}
return "/admin/settlement-center";
}
export function settlementPeriodViewHref(
periodId: number,
view: SettlementPeriodView = "bills",
adminSiteId?: number | null,
): string {
return `/admin/settlement-center?period=${periodId}&view=${view}`;
const params = new URLSearchParams({
period: String(periodId),
view,
});
if (adminSiteId != null && adminSiteId > 0) {
params.set("site", String(adminSiteId));
}
return `/admin/settlement-center?${params.toString()}`;
}
export function parseSettlementCenterView(
siteRaw: string | null,
periodRaw: string | null,
viewRaw: string | null,
): { periodId: number | null; view: SettlementPeriodView } {
const periodId = periodRaw !== null && periodRaw !== "" ? Number(periodRaw) : NaN;
): { siteId: number | null; periodId: number | null; view: SettlementPeriodView } {
const normalizedView = viewRaw === "reports" ? "bills" : viewRaw;
const view =
normalizedView !== null && VALID_VIEWS.includes(normalizedView as SettlementPeriodView)
@@ -25,7 +44,8 @@ export function parseSettlementCenterView(
: "bills";
return {
periodId: Number.isInteger(periodId) && periodId > 0 ? periodId : null,
siteId: parsePositiveInt(siteRaw),
periodId: parsePositiveInt(periodRaw),
view,
};
}

View File

@@ -8,6 +8,7 @@ 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 { SettlementOperationsPanel } from "@/modules/settlement/settlement-operations-panel";
import {
settlementCenterListHref,
settlementPeriodViewHref,
@@ -25,6 +26,7 @@ type SettlementCenterPeriodDetailProps = {
adminSiteId: number;
currencyCode: string;
canOperateBills: boolean;
boundAgentId?: number | null;
refreshKey: number;
onOpenBillDetail: (billId: number) => void;
};
@@ -35,6 +37,7 @@ export function SettlementCenterPeriodDetail({
adminSiteId,
currencyCode,
canOperateBills,
boundAgentId = null,
refreshKey,
onOpenBillDetail,
}: SettlementCenterPeriodDetailProps): React.ReactElement {
@@ -42,6 +45,7 @@ export function SettlementCenterPeriodDetail({
const subViews: { key: SettlementPeriodView; label: string }[] = [
{ key: "bills", label: t("nav.bills", { defaultValue: "账单" }) },
{ key: "operations", label: t("nav.operations", { defaultValue: "收付与调账" }) },
{ key: "ledger", label: t("nav.ledger", { defaultValue: "账务流水" }) },
];
@@ -53,7 +57,7 @@ export function SettlementCenterPeriodDetail({
<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()}
href={settlementCenterListHref(adminSiteId)}
className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "h-8 w-fit px-2")}
>
<ArrowLeft className="size-4" aria-hidden />
@@ -74,7 +78,7 @@ export function SettlementCenterPeriodDetail({
{subViews.map((item) => (
<AdminSubnavLink
key={item.key}
href={settlementPeriodViewHref(period.id, item.key)}
href={settlementPeriodViewHref(period.id, item.key, adminSiteId)}
active={view === item.key}
>
{item.label}
@@ -93,6 +97,18 @@ export function SettlementCenterPeriodDetail({
pendingConfirm={pendingConfirm}
awaitingPayment={awaitingPayment}
selectedPeriodStatus={period.status}
boundAgentId={boundAgentId}
/>
) : null}
{view === "operations" ? (
<SettlementOperationsPanel
key={`${adminSiteId}-${period.id}-${refreshKey}-ops`}
adminSiteId={adminSiteId}
settlementPeriodId={period.id}
currencyCode={currencyCode}
refreshKey={refreshKey}
onOpenBill={onOpenBillDetail}
/>
) : null}

View File

@@ -1,6 +1,7 @@
"use client";
import { Check, ChevronDown, Search } from "lucide-react";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslation } from "react-i18next";
@@ -8,10 +9,12 @@ import { toast } from "sonner";
import { getSettlementPeriods, type SettlementPeriodRow } from "@/api/admin-agent-settlement";
import { getAdminIntegrationSites } from "@/api/admin-integration-sites";
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { AgentBillDetail } from "@/modules/settlement/agent-bill-detail";
import { SettlementCenterPeriodDetail } from "@/modules/settlement/settlement-center-period-detail";
import {
parseSettlementCenterView,
settlementCenterListHref,
settlementPeriodViewHref,
type SettlementPeriodView,
} from "@/modules/settlement/settlement-center-nav";
@@ -25,7 +28,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Button, buttonVariants } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
@@ -41,7 +44,8 @@ export function SettlementCenterShell(): React.ReactElement {
const profile = useAdminProfile();
const boundAgent = profile?.agent ?? null;
const { periodId: activePeriodId, view: activeView } = parseSettlementCenterView(
const { siteId: siteFromUrl, periodId: activePeriodId, view: activeView } = parseSettlementCenterView(
searchParams.get("site"),
searchParams.get("period"),
searchParams.get("view"),
);
@@ -50,6 +54,7 @@ export function SettlementCenterShell(): React.ReactElement {
profile?.is_super_admin === true ||
adminHasAnyPermission(profile?.permissions, [PRD_SETTLEMENT_AGENT_MANAGE]);
const canManagePeriods = canOperateBills && boundAgent === null;
const canFinanceAdjustments = canOperateBills && boundAgent === null;
const [siteOptions, setSiteOptions] = useState<SiteOption[]>([]);
const [adminSiteId, setAdminSiteId] = useState<number | null>(null);
@@ -59,10 +64,14 @@ export function SettlementCenterShell(): React.ReactElement {
const [periodsReady, setPeriodsReady] = useState(false);
const [detailBillId, setDetailBillId] = useState<number | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
const [periodLookupDone, setPeriodLookupDone] = useState(false);
useEffect(() => {
if (boundAgent?.admin_site_id) {
const label = formatAdminSiteLabel(boundAgent.name, boundAgent.site_code ?? boundAgent.code);
const label = formatAdminSiteLabel(
boundAgent.admin_site_name,
boundAgent.site_code ?? boundAgent.code,
);
setSiteOptions([{
id: boundAgent.admin_site_id,
label,
@@ -81,11 +90,17 @@ export function SettlementCenterShell(): React.ReactElement {
currency_code: site.currency_code ?? "NPR",
}));
setSiteOptions(options);
if (adminSiteId === null && options[0]) {
setAdminSiteId(options[0].id);
}
setAdminSiteId((current) => {
if (siteFromUrl !== null && options.some((site) => site.id === siteFromUrl)) {
return siteFromUrl;
}
if (current !== null && options.some((site) => site.id === current)) {
return current;
}
return options[0]?.id ?? null;
});
});
}, [adminSiteId, boundAgent]);
}, [boundAgent, siteFromUrl]);
const siteId = adminSiteId ?? siteOptions[0]?.id ?? null;
const selectedSite = siteOptions.find((s) => s.id === siteId) ?? null;
@@ -95,6 +110,17 @@ export function SettlementCenterShell(): React.ReactElement {
? siteOptions.filter((site) => site.label.toLowerCase().includes(siteKeyword.trim().toLowerCase()))
: siteOptions;
const boundAgentIdentity =
boundAgent !== null ? (
<p className="text-xs text-muted-foreground">
{t("boundAgentIdentity", {
defaultValue: "经营身份:{{agent}} · 账号 {{username}}",
agent: boundAgent.name || boundAgent.code,
username: profile?.username ?? "—",
})}
</p>
) : null;
const siteSelector =
siteOptions.length > 0 && siteId !== null ? (
<Popover open={sitePickerOpen} onOpenChange={setSitePickerOpen}>
@@ -143,6 +169,7 @@ export function SettlementCenterShell(): React.ReactElement {
setAdminSiteId(site.id);
setSitePickerOpen(false);
setSiteKeyword("");
router.replace(settlementCenterListHref(site.id));
}}
>
<div className="min-w-0 flex-1">
@@ -164,6 +191,14 @@ export function SettlementCenterShell(): React.ReactElement {
</Popover>
) : null;
const headerActions =
siteSelector !== null || boundAgentIdentity !== null ? (
<div className="flex flex-col items-end gap-1">
{siteSelector}
{boundAgentIdentity}
</div>
) : null;
const loadPeriods = useCallback(async (): Promise<SettlementPeriodRow[]> => {
if (siteId === null) {
return [];
@@ -191,22 +226,75 @@ export function SettlementCenterShell(): React.ReactElement {
activePeriodId !== null ? (periods.find((row) => row.id === activePeriodId) ?? null) : null;
const openPeriodView = (periodId: number, view: SettlementPeriodView): void => {
router.push(settlementPeriodViewHref(periodId, view));
router.push(settlementPeriodViewHref(periodId, view, siteId));
};
const isListMode = activePeriodId === null;
useEffect(() => {
if (boundAgent !== null || siteId === null) {
return;
}
if (isListMode) {
if (siteFromUrl === siteId) {
return;
}
router.replace(settlementCenterListHref(siteId));
return;
}
if (activePeriodId !== null && siteFromUrl !== siteId) {
router.replace(settlementPeriodViewHref(activePeriodId, activeView, siteId));
}
}, [activePeriodId, activeView, boundAgent, isListMode, router, siteFromUrl, siteId]);
useEffect(() => {
setPeriodLookupDone(false);
}, [activePeriodId, siteId]);
useEffect(() => {
if (!periodsReady || activePeriodId === null || siteId === null) {
return;
}
if (activePeriod !== null) {
setPeriodLookupDone(true);
return;
}
let cancelled = false;
void getSettlementPeriods().then((data) => {
if (cancelled) {
return;
}
const match = (data.items ?? []).find((row) => row.id === activePeriodId);
if (match?.admin_site_id && match.admin_site_id !== siteId) {
setAdminSiteId(match.admin_site_id);
router.replace(settlementPeriodViewHref(activePeriodId, activeView, match.admin_site_id));
return;
}
setPeriodLookupDone(true);
});
return () => {
cancelled = true;
};
}, [activePeriod, activePeriodId, activeView, periodsReady, router, siteId]);
return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4">
{siteId === null || !periodsReady ? (
{siteId === null ? (
<p className="text-sm text-muted-foreground">{t("empty.noSite", { defaultValue: "请选择站点。" })}</p>
) : !periodsReady ? (
<AdminLoadingState />
) : isListMode ? (
<SettlementPeriodWorkbench
adminSiteId={siteId}
currencyCode={currency}
canManage={canManagePeriods}
periods={periods}
headerActions={siteSelector}
headerActions={headerActions}
onViewDetail={(id) => openPeriodView(id, "bills")}
onReloadPeriods={loadPeriods}
onPeriodOpened={() => {
@@ -226,9 +314,21 @@ export function SettlementCenterShell(): React.ReactElement {
}}
/>
) : activePeriod === null ? (
<p className="text-sm text-muted-foreground">
{t("periodDetail.notFound", { defaultValue: "账期不存在或已切换站点,请返回列表。" })}
</p>
!periodLookupDone ? (
<AdminLoadingState />
) : (
<div className="flex flex-col gap-3">
<p className="text-sm text-muted-foreground">
{t("periodDetail.notFound", { defaultValue: "账期不存在或已切换站点,请返回列表。" })}
</p>
<Link
href={settlementCenterListHref(siteId)}
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "w-fit")}
>
{t("periodDetail.back", { defaultValue: "返回账期列表" })}
</Link>
</div>
)
) : (
<SettlementCenterPeriodDetail
period={activePeriod}
@@ -236,6 +336,7 @@ export function SettlementCenterShell(): React.ReactElement {
adminSiteId={siteId}
currencyCode={currency}
canOperateBills={canOperateBills}
boundAgentId={boundAgent?.id ?? null}
refreshKey={refreshKey}
onOpenBillDetail={setDetailBillId}
/>
@@ -243,7 +344,7 @@ export function SettlementCenterShell(): React.ReactElement {
<Dialog open={detailBillId !== null} onOpenChange={(open) => !open && setDetailBillId(null)}>
<DialogContent
className="grid !h-[min(92vh,980px)] !w-[calc(100vw-2rem)] !max-w-none sm:!w-[min(860px,calc(100vw-2rem))] sm:!max-w-[860px] grid-rows-[auto,minmax(0,1fr)] overflow-hidden p-0"
className="grid !h-[min(92vh,980px)] !w-[calc(100vw-2rem)] !max-w-none sm:!w-[min(640px,calc(100vw-2rem))] sm:!max-w-[640px] grid-rows-[auto,minmax(0,1fr)] overflow-hidden p-0"
>
<DialogHeader className="border-b px-6 py-4">
<DialogTitle>{t("actions.billDetail", { defaultValue: "账单详情" })}</DialogTitle>
@@ -254,6 +355,8 @@ export function SettlementCenterShell(): React.ReactElement {
billId={detailBillId}
currencyCode={currency}
canManage={canOperateBills}
boundAgent={boundAgent}
canFinanceAdjustments={canFinanceAdjustments}
onUpdated={() => {
void loadPeriods();
setRefreshKey((n) => n + 1);

View File

@@ -37,11 +37,11 @@ type BillFilters = {
statusScope: BillStatusFilter;
};
function filtersForPeriod(): BillFilters {
function filtersForPeriod(boundAgentId: number | null): BillFilters {
return {
billId: "",
ownerKeyword: "",
billType: "all",
billType: boundAgentId !== null ? "agent" : "all",
statusScope: "all",
};
}
@@ -86,6 +86,7 @@ export type SettlementMainPanelProps = {
pendingConfirm: number;
awaitingPayment: number;
selectedPeriodStatus?: string | null;
boundAgentId?: number | null;
};
export function SettlementMainPanel({
@@ -97,12 +98,13 @@ export function SettlementMainPanel({
pendingConfirm,
awaitingPayment,
selectedPeriodStatus,
boundAgentId = null,
}: SettlementMainPanelProps): React.ReactElement {
const { t } = useTranslation("settlementCenter");
const periodId = periodFilter === "all" ? undefined : periodFilter;
const periodOpen = selectedPeriodStatus === "open";
const initialFilters = useMemo(() => filtersForPeriod(), []);
const initialFilters = useMemo(() => filtersForPeriod(boundAgentId), [boundAgentId]);
const [draft, setDraft] = useState<BillFilters>(initialFilters);
const [applied, setApplied] = useState<BillFilters>(initialFilters);
@@ -197,6 +199,9 @@ export function SettlementMainPanel({
});
}, [applied.statusScope, periodOpen, t]);
const billTypeOptions: BillTypeFilter[] =
boundAgentId !== null ? ["agent"] : ["all", "player", "agent"];
const billTypeLabel = (value: BillTypeFilter): string => {
switch (value) {
case "player":
@@ -291,7 +296,7 @@ export function SettlementMainPanel({
<SelectValue>{() => billTypeLabel(draft.billType)}</SelectValue>
</SelectTrigger>
<SelectContent>
{(["all", "player", "agent"] as BillTypeFilter[]).map((value) => (
{billTypeOptions.map((value) => (
<SelectItem key={value} value={value}>
{billTypeLabel(value)}
</SelectItem>

View File

@@ -0,0 +1,417 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Eye } from "lucide-react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
getSettlementAdjustments,
getSettlementPayments,
type SettlementAdjustmentRow,
type SettlementPaymentRow,
} from "@/api/admin-agent-settlement";
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
import { AdminTableLoadingRow } from "@/components/admin/admin-loading-state";
import { AdminTableNoResourceRow } from "@/components/admin/admin-no-resource-state";
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
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 { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
import { LotteryApiBizError } from "@/types/api/errors";
import { settlementAdjustmentTypeLabel } from "@/modules/settlement/settlement-status-label";
import { cn } from "@/lib/utils";
const OPERATION_TYPES = ["all", "payment", "adjustment", "reversal", "bad_debt"] as const;
type OperationTypeFilter = (typeof OPERATION_TYPES)[number];
type OperationFilters = {
billId: string;
keyword: string;
operationType: OperationTypeFilter;
};
type SettlementOperationRow = {
key: string;
kind: Exclude<OperationTypeFilter, "all">;
recordId: number;
billId: number;
amount: number;
summary: string;
detail: string | null;
sortAt: string;
};
function defaultFilters(): OperationFilters {
return {
billId: "",
keyword: "",
operationType: "all",
};
}
function paymentRow(row: SettlementPaymentRow): SettlementOperationRow {
const payer =
row.payer_type === "platform"
? "platform"
: `${row.payer_type}#${row.payer_id}`;
const payee =
row.payee_type === "platform"
? "platform"
: `${row.payee_type}#${row.payee_id}`;
return {
key: `payment:${row.id}`,
kind: "payment",
recordId: row.id,
billId: row.settlement_bill_id,
amount: row.amount,
summary: row.method?.trim() || "—",
detail: `${payer}${payee}${row.proof ? ` · ${row.proof}` : ""}${row.remark ? ` · ${row.remark}` : ""}`,
sortAt: row.confirmed_at ?? row.created_at ?? "",
};
}
function adjustmentRow(row: SettlementAdjustmentRow): SettlementOperationRow {
const kind =
row.adjustment_type === "bad_debt"
? "bad_debt"
: row.adjustment_type === "reversal"
? "reversal"
: "adjustment";
return {
key: `adjustment:${row.id}`,
kind,
recordId: row.id,
billId: row.original_bill_id ?? 0,
amount: row.amount,
summary: row.reason?.trim() || "—",
detail: null,
sortAt: row.created_at ?? "",
};
}
function matchesFilters(row: SettlementOperationRow, filters: OperationFilters): boolean {
if (filters.operationType !== "all" && row.kind !== filters.operationType) {
return false;
}
const billId = Number(filters.billId.trim());
if (filters.billId.trim() !== "" && (!Number.isInteger(billId) || billId <= 0 || row.billId !== billId)) {
return false;
}
const keyword = filters.keyword.trim().toLowerCase();
if (keyword === "") {
return true;
}
const haystack = [
String(row.billId),
String(row.recordId),
row.summary,
row.detail ?? "",
row.kind,
]
.join(" ")
.toLowerCase();
return haystack.includes(keyword);
}
type SettlementOperationsPanelProps = {
adminSiteId: number;
settlementPeriodId: number;
currencyCode: string;
refreshKey?: number;
onOpenBill: (billId: number) => void;
};
export function SettlementOperationsPanel({
adminSiteId,
settlementPeriodId,
currencyCode,
refreshKey = 0,
onOpenBill,
}: SettlementOperationsPanelProps): React.ReactElement {
const { t } = useTranslation(["settlementCenter", "agents", "common"]);
const formatTs = useAdminDateTimeFormatter();
const initialFilters = useMemo(() => defaultFilters(), []);
const [draft, setDraft] = useState<OperationFilters>(initialFilters);
const [applied, setApplied] = useState<OperationFilters>(initialFilters);
const [allRows, setAllRows] = useState<SettlementOperationRow[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(20);
const load = useCallback(async () => {
setLoading(true);
try {
const [paymentData, adjustmentData] = await Promise.all([
getSettlementPayments({
admin_site_id: adminSiteId,
settlement_period_id: settlementPeriodId,
}),
getSettlementAdjustments({
admin_site_id: adminSiteId,
settlement_period_id: settlementPeriodId,
}),
]);
const merged = [
...(paymentData.items ?? []).map(paymentRow),
...(adjustmentData.items ?? []).map(adjustmentRow),
].sort((a, b) => b.sortAt.localeCompare(a.sortAt) || b.key.localeCompare(a.key));
setAllRows(merged);
} catch (err: unknown) {
setAllRows([]);
toast.error(
err instanceof LotteryApiBizError
? err.message
: t("settlementCenter:operations.loadFailed", { defaultValue: "收付与调账记录加载失败" }),
);
} finally {
setLoading(false);
}
}, [adminSiteId, settlementPeriodId, t]);
useEffect(() => {
void load();
}, [load, refreshKey]);
useEffect(() => {
setDraft(initialFilters);
setApplied(initialFilters);
setPage(1);
}, [initialFilters, settlementPeriodId]);
const filteredRows = useMemo(
() => allRows.filter((row) => matchesFilters(row, applied)),
[allRows, applied],
);
const total = filteredRows.length;
const pageRows = filteredRows.slice((page - 1) * perPage, page * perPage);
const operationTypeLabel = (value: OperationTypeFilter): string => {
if (value === "all") {
return t("operations.filterAllTypes", { defaultValue: "全部类型" });
}
if (value === "payment") {
return t("operations.typePayment", { defaultValue: "登记收付" });
}
return settlementAdjustmentTypeLabel(value, t);
};
const kindBadgeClass = (kind: SettlementOperationRow["kind"]): string => {
if (kind === "payment") {
return "border-emerald-200/60 bg-emerald-50 text-emerald-700 dark:border-emerald-800/60 dark:bg-emerald-950/30 dark:text-emerald-400";
}
if (kind === "bad_debt") {
return "border-rose-200/60 bg-rose-50 text-rose-700 dark:border-rose-800/60 dark:bg-rose-950/30 dark:text-rose-400";
}
if (kind === "reversal") {
return "border-amber-200/60 bg-amber-50 text-amber-700 dark:border-amber-800/60 dark:bg-amber-950/30 dark:text-amber-400";
}
return "border-blue-200/60 bg-blue-50 text-blue-700 dark:border-blue-800/60 dark:bg-blue-950/30 dark:text-blue-400";
};
const runSearch = (): void => {
setPage(1);
setApplied({ ...draft });
};
const resetFilters = (): void => {
setDraft(initialFilters);
setApplied(initialFilters);
setPage(1);
};
const colSpan = 7;
return (
<div className="space-y-5">
<div className="rounded-xl border border-border/70 bg-card p-5 shadow-sm">
<div className="grid gap-x-5 gap-y-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="grid gap-2">
<Label htmlFor="ops-bill-id" className="text-muted-foreground">
{t("columns.billId", { defaultValue: "账单 ID" })}
</Label>
<Input
id="ops-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();
}
}}
className="bg-background/50 transition-colors focus:bg-background"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="ops-keyword" className="text-muted-foreground">
{t("operations.keyword", { defaultValue: "关键词" })}
</Label>
<Input
id="ops-keyword"
placeholder={t("operations.keywordPh", {
defaultValue: "方式、原因、凭证、收付方向",
})}
value={draft.keyword}
onChange={(e) => setDraft((d) => ({ ...d, keyword: e.target.value }))}
onKeyDown={(e) => {
if (e.key === "Enter") {
runSearch();
}
}}
className="bg-background/50 transition-colors focus:bg-background"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="ops-type" className="text-muted-foreground">
{t("operations.operationType", { defaultValue: "操作类型" })}
</Label>
<Select
modal={false}
value={draft.operationType}
onValueChange={(v) =>
setDraft((d) => ({
...d,
operationType: (v ?? "all") as OperationTypeFilter,
}))
}
>
<SelectTrigger id="ops-type" className="w-full bg-background/50 transition-colors focus:bg-background">
<SelectValue>{() => operationTypeLabel(draft.operationType)}</SelectValue>
</SelectTrigger>
<SelectContent>
{OPERATION_TYPES.map((value) => (
<SelectItem key={value} value={value}>
{operationTypeLabel(value)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="mt-5 flex flex-wrap items-center gap-3">
<Button type="button" onClick={runSearch}>
{t("billsPanel.searchBtn", { defaultValue: "搜索" })}
</Button>
<Button type="button" variant="outline" onClick={resetFilters}>
{t("billsPanel.reset", { defaultValue: "重置" })}
</Button>
</div>
</div>
<div className="admin-table-shell overflow-x-auto rounded-xl border border-border/70 bg-card shadow-sm">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("columns.time", { defaultValue: "时间" })}</TableHead>
<TableHead>{t("operations.operationType", { defaultValue: "操作类型" })}</TableHead>
<TableHead>{t("columns.billId", { defaultValue: "账单 ID" })}</TableHead>
<TableHead className="text-right">{t("columns.amount", { defaultValue: "金额" })}</TableHead>
<TableHead>{t("columns.summary", { defaultValue: "摘要" })}</TableHead>
<TableHead>{t("columns.detail", { defaultValue: "说明" })}</TableHead>
<TableHead className="sticky right-0 z-20 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>
{loading ? <AdminTableLoadingRow colSpan={colSpan} /> : null}
{!loading && pageRows.length === 0 ? (
<AdminTableNoResourceRow colSpan={colSpan} cellClassName="py-12 text-center" />
) : null}
{!loading
? pageRows.map((row) => (
<TableRow key={row.key}>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{formatTs(row.sortAt)}
</TableCell>
<TableCell>
<span
className={cn(
"inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium",
kindBadgeClass(row.kind),
)}
>
{operationTypeLabel(row.kind)}
</span>
</TableCell>
<TableCell className="tabular-nums">
{row.billId > 0 ? `#${row.billId}` : "—"}
</TableCell>
<TableCell className="text-right tabular-nums font-medium">
{formatDashboardMoneyMinor(row.amount, currencyCode)}
</TableCell>
<TableCell className="max-w-[160px] truncate text-sm">{row.summary}</TableCell>
<TableCell className="max-w-[240px] truncate text-sm text-muted-foreground">
{row.detail ?? "—"}
</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()}
>
{row.billId > 0 ? (
<AdminRowActionsMenu
actions={[
{
key: "detail",
label: t("actions.detail", { defaultValue: "详情" }),
icon: Eye,
onClick: () => onOpenBill(row.billId),
},
]}
/>
) : (
"—"
)}
</TableCell>
</TableRow>
))
: null}
</TableBody>
</Table>
</div>
{!loading && total > 0 ? (
<AdminListPaginationFooter
page={page}
perPage={perPage}
total={total}
onPageChange={setPage}
onPerPageChange={(value) => {
setPerPage(value);
setPage(1);
}}
/>
) : null}
</div>
);
}

View File

@@ -8,7 +8,10 @@ import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { useAdminDateTimeFormatter } from "@/hooks/use-admin-datetime-formatter";
import { formatSettlementPeriodSpan } from "@/lib/agent-settlement-period-range";
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
import { settlementBillTypeLabel } from "@/modules/settlement/settlement-status-label";
import {
settlementBillTypeLabel,
settlementPaymentStatusLabel,
} from "@/modules/settlement/settlement-status-label";
import {
Table,
TableBody,
@@ -80,9 +83,7 @@ export function SettlementPaymentsTable({
</TableCell>
<TableCell>{row.method ?? "—"}</TableCell>
<TableCell>
{t(`paymentStatus.${row.status}`, {
defaultValue: row.status === "confirmed" ? "已确认" : row.status,
})}
{settlementPaymentStatusLabel(row.status, t)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatTs(row.confirmed_at ?? row.created_at)}

View File

@@ -6,11 +6,14 @@ import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
getSettlementPeriodOpenHints,
postSettlementPeriod,
postSettlementPeriodClose,
type SettlementPeriodCloseResult,
type SettlementPeriodOpenHints,
type SettlementPeriodRow,
} from "@/api/admin-agent-settlement";
import { AdminDateRangeField } from "@/components/admin/admin-date-range-field";
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";
@@ -23,7 +26,6 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
@@ -34,14 +36,15 @@ import {
} from "@/components/ui/select";
import {
formatSettlementPeriodSpan,
settlementPeriodPresetRange,
type SettlementPeriodPresetKey,
isSettlementLocalDateRangeValid,
localDateRangeToUtcPeriodBounds,
settlementRangeOverlapsOccupiedDates,
utcStorageDateToLocalFormYmd,
utcStorageDatesToLocalMarks,
} 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"];
@@ -77,6 +80,8 @@ export function SettlementPeriodWorkbench({
const [openDialogOpen, setOpenDialogOpen] = useState(false);
const [customStart, setCustomStart] = useState("");
const [customEnd, setCustomEnd] = useState("");
const [openHints, setOpenHints] = useState<SettlementPeriodOpenHints | null>(null);
const [hintsLoading, setHintsLoading] = useState(false);
const [busy, setBusy] = useState(false);
const [reloading, setReloading] = useState(false);
const [closeDialogOpen, setCloseDialogOpen] = useState(false);
@@ -112,16 +117,32 @@ export function SettlementPeriodWorkbench({
}
}, [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 calendarMarkers = useMemo(() => {
if (openHints === null) {
return undefined;
}
};
return {
occupiedPeriod: utcStorageDatesToLocalMarks(openHints.occupied_period_dates),
pendingActivity: utcStorageDatesToLocalMarks(openHints.pending_activity_dates),
unpaidBill: utcStorageDatesToLocalMarks(openHints.unpaid_bill_dates),
};
}, [openHints]);
const occupiedLocalDates = useMemo(
() => utcStorageDatesToLocalMarks(openHints?.occupied_period_dates ?? []),
[openHints],
);
const selectedRangeOverlapsOccupied = useMemo(() => {
if (!customStart.trim() || !customEnd.trim()) {
return false;
}
return settlementRangeOverlapsOccupiedDates(
customStart.trim(),
customEnd.trim(),
occupiedLocalDates,
);
}, [customEnd, customStart, occupiedLocalDates]);
const statusFilterLabel = (value: PeriodStatusFilter): string => {
if (value === "all") {
@@ -130,45 +151,97 @@ export function SettlementPeriodWorkbench({
return settlementPeriodStatusLabel(value, t);
};
async function openWithRange(periodStart: string, periodEnd: string): Promise<void> {
async function openWithRange(startYmd: string, endYmd: string): Promise<void> {
if (!canManage) {
return;
}
if (!isSettlementLocalDateRangeValid(startYmd, endYmd)) {
toast.error(
t("agents:settlementPeriods.invalidRange", {
defaultValue: "结束日期不能早于开始日期",
}),
);
return;
}
if (settlementRangeOverlapsOccupiedDates(startYmd, endYmd, occupiedLocalDates)) {
toast.error(
t("agents:settlementPeriods.overlapsOccupied", {
defaultValue: "所选范围与已有账期重叠,请避开灰色删除线的日期。",
}),
);
return;
}
const bounds = localDateRangeToUtcPeriodBounds(startYmd, endYmd);
setBusy(true);
try {
const row = await postSettlementPeriod({
admin_site_id: adminSiteId,
period_start: periodStart,
period_end: periodEnd,
period_start: bounds.period_start,
period_end: bounds.period_end,
});
await onReloadPeriods();
onPeriodOpened?.(row.id);
setOpenDialogOpen(false);
setCustomStart("");
setCustomEnd("");
setOpenHints(null);
toast.success(t("agents:settlementPeriods.opened", { defaultValue: "账期已开启" }));
} catch (err: unknown) {
toast.error(
err instanceof LotteryApiBizError
? err.message
: t("agents:settlementPeriods.openFailed", { defaultValue: "开失败" }),
: 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);
await openWithRange(customStart.trim(), customEnd.trim());
}
async function loadOpenHints(): Promise<void> {
setHintsLoading(true);
try {
const hints = await getSettlementPeriodOpenHints({ admin_site_id: adminSiteId });
setOpenHints(hints);
setCustomStart("");
setCustomEnd("");
if (hints.suggested_start && hints.suggested_end) {
const from = utcStorageDateToLocalFormYmd(hints.suggested_start);
const to = utcStorageDateToLocalFormYmd(hints.suggested_end);
const occupied = utcStorageDatesToLocalMarks(hints.occupied_period_dates);
if (!settlementRangeOverlapsOccupiedDates(from, to, occupied)) {
setCustomStart(from);
setCustomEnd(to);
}
}
} catch (err: unknown) {
setOpenHints(null);
toast.error(
err instanceof LotteryApiBizError
? err.message
: t("agents:settlementPeriods.hintsFailed", { defaultValue: "无法加载开账建议" }),
);
} finally {
setHintsLoading(false);
}
}
function handleOpenDialog(open: boolean): void {
setOpenDialogOpen(open);
if (open) {
void loadOpenHints();
return;
}
setCustomStart("");
setCustomEnd("");
setOpenHints(null);
}
function requestClose(row: SettlementPeriodRow): void {
@@ -299,7 +372,7 @@ export function SettlementPeriodWorkbench({
type="button"
size="sm"
disabled={busy}
onClick={() => setOpenDialogOpen(true)}
onClick={() => handleOpenDialog(true)}
>
<Plus className="size-4" aria-hidden />
{t("period.openBtn", { defaultValue: "开账" })}
@@ -370,76 +443,81 @@ export function SettlementPeriodWorkbench({
/>
</AdminPageCard>
<Dialog
open={openDialogOpen}
onOpenChange={(open) => {
setOpenDialogOpen(open);
if (!open) {
setCustomStart("");
setCustomEnd("");
}
}}
>
<Dialog open={openDialogOpen} onOpenChange={handleOpenDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("period.openTitle", { defaultValue: "开账" })}</DialogTitle>
<DialogDescription>
{t("agents:settlementPeriods.openHint", {
defaultValue: "选择快捷账期或自定义起止时间。",
{t("agents:settlementPeriods.openDesc", {
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)}
<div className="space-y-3">
<AdminDateRangeField
id="sp-dialog-range"
label={t("agents:settlementPeriods.rangeLabel", { defaultValue: "账期范围" })}
from={customStart}
to={customEnd}
disabled={hintsLoading || busy}
calendarMarkers={calendarMarkers}
rangeHint={t("agents:settlementPeriods.rangeHint", {
defaultValue: "先选开始日期,再选结束日期;单日账期可点同一天两次。",
})}
onRangeChange={({ from, to }) => {
setCustomStart(from);
setCustomEnd(to);
}}
/>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<span
className="inline-block size-3 rounded bg-muted ring-1 ring-border line-through"
aria-hidden
/>
{t("agents:settlementPeriods.markerOccupied", { defaultValue: "已有账期" })}
</span>
<span className="inline-flex items-center gap-1.5">
<span
className="inline-block size-1.5 rounded-full bg-amber-500"
aria-hidden
/>
{t("agents:settlementPeriods.markerPending", { defaultValue: "待入账流水" })}
</span>
<span className="inline-flex items-center gap-1.5">
<span
className="relative inline-block size-3 rounded ring-1 ring-border"
aria-hidden
>
{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>
<span className="absolute top-0 right-0 size-1.5 rounded-full bg-rose-500" />
</span>
{t("agents:settlementPeriods.markerUnpaid", { defaultValue: "未结清账期" })}
</span>
</div>
{selectedRangeOverlapsOccupied ? (
<p className="text-destructive text-xs">
{t("agents:settlementPeriods.overlapsOccupied", {
defaultValue: "所选范围与已有账期重叠,请避开灰色删除线的日期。",
})}
</p>
) : null}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
disabled={busy}
onClick={() => setOpenDialogOpen(false)}
disabled={busy || hintsLoading}
onClick={() => handleOpenDialog(false)}
>
{t("common:cancel", { defaultValue: "取消" })}
</Button>
<Button type="button" disabled={busy} onClick={() => void openCustom()}>
{t("agents:settlementPeriods.open", { defaultValue: "开期" })}
<Button
type="button"
disabled={busy || hintsLoading || selectedRangeOverlapsOccupied}
onClick={() => void openCustom()}
>
{t("period.openBtn", { defaultValue: "开账" })}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -1,3 +1,4 @@
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
import { cn } from "@/lib/utils";
/** 结算金额正负着色:负红、正绿、零灰 */
@@ -11,3 +12,12 @@ export function signedSettlementMoneyClass(amount: number, emphasize = false): s
return "text-muted-foreground";
}
export function formatSignedSettlementMoney(amount: number, currencyCode: string): string {
if (amount === 0) {
return formatDashboardMoneyMinor(0, currencyCode);
}
const prefix = amount < 0 ? "" : "+";
return `${prefix}${formatDashboardMoneyMinor(Math.abs(amount), currencyCode)}`;
}

View File

@@ -74,3 +74,11 @@ export function settlementAdjustmentTypeLabel(
const key = `adjustmentType.${type}` as const;
return t(key, { defaultValue: type });
}
export function settlementPaymentStatusLabel(
status: string,
t: TFunction<"settlementCenter">,
): string {
const key = `paymentStatus.${status}` as const;
return t(key, { defaultValue: status });
}