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

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