Files
lotteryAdmin/src/modules/settlement/settlement-operations-panel.tsx
kang a020e34a7d refactor(admin-reports, i18n): remove rebate commission report and enhance localization
Removed the `getAdminReportRebateCommission` function and its references from the admin reports API and localization files. Updated CSS for improved money display handling in admin components. Enhanced localization support by adding new finance and support workspace entries in English, Nepali, and Chinese, improving user experience across the application.
2026-06-16 16:04:03 +08:00

425 lines
15 KiB
TypeScript

"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 { AdminTableMoney, adminMoneyCellClassName } from "@/components/admin/admin-table-money";
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 lastPage = Math.max(1, Math.ceil(total / perPage));
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={adminMoneyCellClassName("text-right font-medium")}>
<AdminTableMoney>
{formatDashboardMoneyMinor(row.amount, currencyCode)}
</AdminTableMoney>
</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
selectId="settlement-operations-per-page"
page={page}
perPage={perPage}
total={total}
lastPage={lastPage}
loading={loading}
onPageChange={setPage}
onPerPageChange={(value) => {
setPerPage(value);
setPage(1);
}}
/>
) : null}
</div>
);
}