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,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>
|
||||
|
||||
Reference in New Issue
Block a user