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:
417
src/modules/settlement/settlement-operations-panel.tsx
Normal file
417
src/modules/settlement/settlement-operations-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user