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

@@ -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>
);