feat(settlement): 优化回水分摊明细展示及分组逻辑
Some checks failed
lotteryadmin CI / build (push) Has been cancelled
Some checks failed
lotteryadmin CI / build (push) Has been cancelled
- 新增多语言支持,补充回水分摊相关字段翻译 - 优化回水分摊数据汇总逻辑,合并相同参与方的数据 - 增加分摊占比显示,提升明细信息直观性 - 实现按注单分组的回水明细展开与收起功能 - 使用表格展示汇总数据,提升界面可读性和美观度 - 调整样式和交互细节,增强用户体验
This commit is contained in:
@@ -270,6 +270,13 @@
|
||||
"recordReceiptFrom": "Record receipt ({{payer}} → {{payee}})",
|
||||
"recordPayoutTo": "Record payout ({{payer}} → {{payee}})",
|
||||
"rebateAllocationsHint": "How rebate is allocated across agent tiers.",
|
||||
"rebateAllocParticipant": "Participant",
|
||||
"rebateAllocRate": "Share",
|
||||
"rebateAllocAmount": "Allocated",
|
||||
"rebateAllocTotal": "Total",
|
||||
"rebateAllocTicket": "Ticket",
|
||||
"showRawRebateAllocations": "Show per-ticket detail ({{count}})",
|
||||
"hideRawRebateAllocations": "Hide per-ticket detail",
|
||||
"payment": "Payment",
|
||||
"flowHint": {
|
||||
"playerPayAgent": "Player should settle with the direct agent",
|
||||
|
||||
@@ -272,6 +272,13 @@
|
||||
"recordReceiptFrom": "रसिद दर्ता ({{payer}} → {{payee}})",
|
||||
"recordPayoutTo": "भुक्तानी दर्ता ({{payer}} → {{payee}})",
|
||||
"rebateAllocationsHint": "एजेन्ट तहहरूमा रिबेट कसरी बाँडिन्छ।",
|
||||
"rebateAllocParticipant": "सहभागी",
|
||||
"rebateAllocRate": "शेयर",
|
||||
"rebateAllocAmount": "बाँडफाँड रकम",
|
||||
"rebateAllocTotal": "कुल",
|
||||
"rebateAllocTicket": "टिकट",
|
||||
"showRawRebateAllocations": "टिकट अनुसार विवरण देखाउनुहोस् ({{count}})",
|
||||
"hideRawRebateAllocations": "टिकट विवरण लुकाउनुहोस्",
|
||||
"payment": "भुक्तानी",
|
||||
"flowHint": {
|
||||
"playerPayAgent": "खेलाडीले प्रत्यक्ष एजेन्टसँग सेटल गर्नुपर्छ",
|
||||
|
||||
@@ -255,6 +255,13 @@
|
||||
"recordReceiptFrom": "登记收款({{payer}} 付给 {{payee}})",
|
||||
"recordPayoutTo": "登记付款({{payer}} 付给 {{payee}})",
|
||||
"rebateAllocationsHint": "各层级代理对回水的承担明细。",
|
||||
"rebateAllocParticipant": "参与方",
|
||||
"rebateAllocRate": "占成",
|
||||
"rebateAllocAmount": "分摊金额",
|
||||
"rebateAllocTotal": "合计",
|
||||
"rebateAllocTicket": "注单",
|
||||
"showRawRebateAllocations": "展开按注单明细 ({{count}})",
|
||||
"hideRawRebateAllocations": "收起按注单明细",
|
||||
"payment": "收付",
|
||||
"flowHint": {
|
||||
"playerPayAgent": "玩家应向直属代理结算",
|
||||
|
||||
@@ -199,30 +199,40 @@ export function AgentBillDetail({
|
||||
button: null,
|
||||
onClick: 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;
|
||||
}
|
||||
const rebateAllocationSummary = (() => {
|
||||
const groups: Record<string, { key: string; label: string; shareRate: number; amount: number; rows: number; rule: string }> = {};
|
||||
rebateAllocations.forEach((row) => {
|
||||
const label = row.participant_label ?? (row.participant_type === 'platform' ? 'Platform' : `${row.participant_type}#${row.participant_id}`);
|
||||
const key = `${row.participant_type}:${row.participant_id}`;
|
||||
const current = groups[key];
|
||||
if (current) {
|
||||
current.amount += row.allocated_amount;
|
||||
current.rows += 1;
|
||||
return;
|
||||
}
|
||||
groups[key] = {
|
||||
key,
|
||||
label,
|
||||
shareRate: row.actual_share_rate,
|
||||
amount: row.allocated_amount,
|
||||
rows: 1,
|
||||
rule: row.allocation_rule,
|
||||
};
|
||||
});
|
||||
return Object.values(groups).sort((a, b) => b.amount - a.amount || a.label.localeCompare(b.label, 'zh-CN'));
|
||||
})();
|
||||
|
||||
acc[key] = {
|
||||
key,
|
||||
label: `${label} · ${row.allocation_rule}`,
|
||||
amount: row.allocated_amount,
|
||||
rows: 1,
|
||||
};
|
||||
const rebateTotalAmount = rebateAllocationSummary.reduce((s, r) => s + r.amount, 0);
|
||||
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
),
|
||||
).sort((a, b) => b.amount - a.amount || a.label.localeCompare(b.label, "zh-CN"));
|
||||
const rebateDetailByTicket = (() => {
|
||||
const groups: Record<number, { rebateRecordId: number; rows: typeof rebateAllocations }> = {};
|
||||
rebateAllocations.forEach((row) => {
|
||||
const rid = row.rebate_record_id;
|
||||
if (!groups[rid]) groups[rid] = { rebateRecordId: rid, rows: [] };
|
||||
groups[rid].rows.push(row);
|
||||
});
|
||||
return Object.values(groups);
|
||||
})();
|
||||
|
||||
const showActionError = (err: unknown, fallback: string): void => {
|
||||
toast.error(err instanceof LotteryApiBizError ? err.message : fallback);
|
||||
@@ -428,62 +438,111 @@ export function AgentBillDetail({
|
||||
) : null}
|
||||
|
||||
{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: "回水分摊" })}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/80">
|
||||
{t("settlementCenter:billDisplay.rebateAllocationsHint", {
|
||||
defaultValue: "各层级代理对回水的承担明细。",
|
||||
})}
|
||||
</p>
|
||||
<div className="mt-3 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/60">
|
||||
{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 className="space-y-3 rounded-xl border border-border/70 bg-card p-5 shadow-sm">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<p className="font-semibold tracking-tight">
|
||||
{t("settlementBills.rebateAllocations", { defaultValue: "回水分摊" })}
|
||||
</p>
|
||||
<span className="text-xs text-muted-foreground/70">
|
||||
{t("settlementCenter:billDisplay.rebateAllocationsHint", {
|
||||
defaultValue: "各层级承担明细",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 汇总表格 */}
|
||||
<div className="rounded-lg border border-border/50 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-muted/30 text-xs text-muted-foreground">
|
||||
<th className="px-3 py-1.5 text-left font-medium">
|
||||
{t("settlementCenter:billDisplay.rebateAllocParticipant", { defaultValue: "参与方" })}
|
||||
</th>
|
||||
<th className="px-3 py-1.5 text-right font-medium">
|
||||
{t("settlementCenter:billDisplay.rebateAllocRate", { defaultValue: "占成" })}
|
||||
</th>
|
||||
<th className="px-3 py-1.5 text-right font-medium">
|
||||
{t("settlementCenter:billDisplay.rebateAllocAmount", { defaultValue: "分摊金额" })}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rebateAllocationSummary.map((row) => {
|
||||
const pct = rebateTotalAmount > 0
|
||||
? Math.round((row.amount / rebateTotalAmount) * 100)
|
||||
: 0;
|
||||
return (
|
||||
<tr key={row.key} className="border-t border-border/40">
|
||||
<td className="px-3 py-2 font-medium text-foreground">{row.label}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-muted-foreground">
|
||||
{row.shareRate > 0 ? `${row.shareRate}%` : "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-foreground">
|
||||
{formatDashboardMoneyMinor(row.amount, currencyCode)}
|
||||
<span className="ml-1 text-xs text-muted-foreground/60">({pct}%)</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{rebateAllocationSummary.length > 1 ? (
|
||||
<tr className="border-t border-border/60 bg-muted/20">
|
||||
<td className="px-3 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
{t("settlementCenter:billDisplay.rebateAllocTotal", { defaultValue: "合计" })}
|
||||
</td>
|
||||
<td className="px-3 py-1.5" />
|
||||
<td className="px-3 py-1.5 text-right text-xs font-semibold tabular-nums text-foreground">
|
||||
{formatDashboardMoneyMinor(rebateTotalAmount, currencyCode)}
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 按注单分组展开 */}
|
||||
{rebateDetailByTicket.length > 1 ? (
|
||||
<>
|
||||
<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: "展开按注单明细 ({{count}})",
|
||||
count: rebateDetailByTicket.length,
|
||||
})}
|
||||
</button>
|
||||
|
||||
{rebateDetailsOpen ? (
|
||||
<div className="max-h-[320px] space-y-2 overflow-y-auto rounded-lg border border-dashed border-border/70 p-3">
|
||||
{rebateDetailByTicket.map((group, idx) => (
|
||||
<div key={group.rebateRecordId} className="space-y-1 rounded-md bg-muted/10 p-2">
|
||||
<p className="text-[11px] font-medium text-muted-foreground/70">
|
||||
{t("settlementCenter:billDisplay.rebateAllocTicket", { defaultValue: "注单" })} #{idx + 1}
|
||||
</p>
|
||||
{group.rows.map((row) => (
|
||||
<div key={row.id} className="flex justify-between gap-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{row.participant_label ?? (row.participant_type === 'platform' ? 'Platform' : `#${row.participant_id}`)}
|
||||
{row.actual_share_rate > 0 ? (
|
||||
<span className="ml-1 text-muted-foreground/50">({row.actual_share_rate}%)</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums text-foreground">
|
||||
{formatDashboardMoneyMinor(row.allocated_amount, currencyCode)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user