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:
2026-06-05 18:00:59 +08:00
parent 65eaeecf8c
commit af982bb9f7
73 changed files with 4307 additions and 2494 deletions

View File

@@ -1,30 +1,21 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslation } from "react-i18next";
import { CalendarClock, CircleDollarSign, ClipboardCheck, Landmark } from "lucide-react";
import { toast } from "sonner";
import { getSettlementPeriods, type SettlementPeriodRow } from "@/api/admin-agent-settlement";
import { getAdminIntegrationSites } from "@/api/admin-integration-sites";
import { AdminNoResourceState } from "@/components/admin/admin-no-resource-state";
import { AdminPageCard } from "@/components/admin/admin-page-card";
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
import { AgentBillDetail } from "@/modules/settlement/agent-bill-detail";
import { AgentPeriodsConsole } from "@/modules/settlement/agent-periods-console";
import { formatDashboardMoneyMinor } from "@/modules/dashboard/use-dashboard-analytics";
import type { AgentSettlementPeriodFilter } from "@/modules/settlement/agent-settlement-period-select";
import { SettlementCenterPeriodDetail } from "@/modules/settlement/settlement-center-period-detail";
import {
SettlementCenterNav,
type SettlementCenterSection,
parseSettlementCenterView,
settlementPeriodViewHref,
type SettlementPeriodView,
} from "@/modules/settlement/settlement-center-nav";
import {
SettlementBillsPanel,
type BillCategory,
} from "@/modules/settlement/settlement-bills-panel";
import { SettlementLedgerPanel } from "@/modules/settlement/settlement-ledger-panel";
import { SettlementPeriodToolbar } from "@/modules/settlement/settlement-period-toolbar";
import { formatSettlementPeriodSpan } from "@/lib/agent-settlement-period-range";
import { SettlementPeriodWorkbench } from "@/modules/settlement/settlement-period-workbench";
import { formatAdminSiteLabel } from "@/lib/admin-site-display";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import { PRD_SETTLEMENT_AGENT_MANAGE } from "@/lib/admin-prd";
import {
@@ -44,62 +35,33 @@ import { useAdminProfile } from "@/stores/admin-session";
type SiteOption = { id: number; label: string; currency_code: string };
function pickDefaultPeriodId(periods: SettlementPeriodRow[]): number | "all" {
const closed = periods
.filter((row) => row.status === "closed" || row.status === "completed")
.sort((a, b) => b.id - a.id);
if (closed[0]) {
return closed[0].id;
}
const open = periods.filter((row) => row.status === "open").sort((a, b) => b.id - a.id);
if (open[0]) {
return open[0].id;
}
return "all";
}
function sectionTitle(
section: SettlementCenterSection,
t: ReturnType<typeof useTranslation<["settlementCenter", "agents", "common"]>>["t"],
): string {
switch (section) {
case "overview":
return t("panels.overview.title", { defaultValue: "结算概览" });
case "periods":
return t("nav.periods", { defaultValue: "账期管理" });
case "ledger":
return t("panels.ledger.title", { defaultValue: "账务流水" });
case "bills":
return t("panels.bills.title", { defaultValue: "账单" });
default:
return "";
}
}
export function SettlementCenterShell(): React.ReactElement {
const { t } = useTranslation(["settlementCenter", "agents", "common"]);
const { t } = useTranslation(["settlementCenter", "common"]);
const router = useRouter();
const searchParams = useSearchParams();
const profile = useAdminProfile();
const boundAgent = profile?.agent ?? null;
const canManagePeriods =
const { periodId: activePeriodId, view: activeView } = parseSettlementCenterView(
searchParams.get("period"),
searchParams.get("view"),
);
const canOperateBills =
profile?.is_super_admin === true ||
adminHasAnyPermission(profile?.permissions, [PRD_SETTLEMENT_AGENT_MANAGE]);
const canManagePeriods = canOperateBills && boundAgent === null;
const [activeSection, setActiveSection] = useState<SettlementCenterSection>("overview");
const [siteOptions, setSiteOptions] = useState<SiteOption[]>([]);
const [adminSiteId, setAdminSiteId] = useState<number | null>(null);
const [periods, setPeriods] = useState<SettlementPeriodRow[]>([]);
const [periodFilter, setPeriodFilter] = useState<AgentSettlementPeriodFilter>("all");
const [periodFilterReady, setPeriodFilterReady] = useState(false);
const [periodsReady, setPeriodsReady] = useState(false);
const [detailBillId, setDetailBillId] = useState<number | null>(null);
const [billsInitialCategory, setBillsInitialCategory] = useState<BillCategory>("all");
const [listRevision, setListRevision] = useState(0);
const [refreshKey, setRefreshKey] = useState(0);
useEffect(() => {
if (boundAgent?.admin_site_id) {
const label = boundAgent.name
? `${boundAgent.name} (${boundAgent.site_code || boundAgent.code})`
: boundAgent.code;
const label = formatAdminSiteLabel(boundAgent.name, boundAgent.site_code ?? boundAgent.code);
setSiteOptions([{ id: boundAgent.admin_site_id, label, currency_code: "NPR" }]);
setAdminSiteId(boundAgent.admin_site_id);
return;
@@ -108,7 +70,7 @@ export function SettlementCenterShell(): React.ReactElement {
void getAdminIntegrationSites().then((sites) => {
const options = (sites.items ?? []).map((site) => ({
id: site.id,
label: site.name ? `${site.name} (${site.code})` : site.code,
label: formatAdminSiteLabel(site.name, site.code),
currency_code: site.currency_code ?? "NPR",
}));
setSiteOptions(options);
@@ -118,319 +80,140 @@ export function SettlementCenterShell(): React.ReactElement {
});
}, [adminSiteId, boundAgent]);
const loadPeriods = useCallback(async () => {
if (adminSiteId === null) {
setPeriods([]);
return;
const siteId = adminSiteId ?? siteOptions[0]?.id ?? null;
const siteLabel = siteOptions.find((s) => s.id === siteId)?.label ?? null;
const currency = siteOptions.find((s) => s.id === siteId)?.currency_code ?? "NPR";
const loadPeriods = useCallback(async (): Promise<SettlementPeriodRow[]> => {
if (siteId === null) {
return [];
}
try {
const data = await getSettlementPeriods({ admin_site_id: adminSiteId });
setPeriods(data.items ?? []);
const data = await getSettlementPeriods({ admin_site_id: siteId });
const items = data.items ?? [];
setPeriods(items);
setPeriodsReady(true);
return items;
} catch {
setPeriods([]);
toast.error(t("periods.loadFailed", { defaultValue: "账期列表加载失败" }));
setPeriodsReady(true);
toast.error(t("periods.loadFailed", { defaultValue: "账期加载失败" }));
return [];
}
}, [adminSiteId, t]);
}, [siteId, t]);
useEffect(() => {
if (canManagePeriods || adminSiteId === null) {
return;
}
setPeriodsReady(false);
void loadPeriods();
}, [adminSiteId, canManagePeriods, loadPeriods]);
}, [loadPeriods]);
const handlePeriodsChange = useCallback((items: SettlementPeriodRow[]) => {
setPeriods(items);
}, []);
const activePeriod =
activePeriodId !== null ? (periods.find((row) => row.id === activePeriodId) ?? null) : null;
useEffect(() => {
if (periodFilterReady || adminSiteId === null) {
return;
}
setPeriodFilter(periods.length === 0 ? "all" : pickDefaultPeriodId(periods));
setPeriodFilterReady(true);
}, [adminSiteId, periodFilterReady, periods]);
const activeCurrency =
siteOptions.find((site) => site.id === adminSiteId)?.currency_code ?? "NPR";
const openPeriod = useMemo(
() => periods.filter((row) => row.status === "open").sort((a, b) => b.id - a.id)[0] ?? null,
[periods],
);
const summaryTotals = useMemo(
() =>
periods.reduce(
(acc, row) => {
acc.pendingConfirm += row.summary?.pending_confirm ?? 0;
acc.awaitingPayment += row.summary?.awaiting_payment ?? 0;
acc.totalUnpaid += row.summary?.total_unpaid ?? 0;
return acc;
},
{ pendingConfirm: 0, awaitingPayment: 0, totalUnpaid: 0 },
),
[periods],
);
const handlePeriodClosed = useCallback(
(result?: { unsettled_ticket_count?: number }) => {
void loadPeriods();
setActiveSection("bills");
setBillsInitialCategory("pending_confirm");
setListRevision((n) => n + 1);
const unsettled = result?.unsettled_ticket_count ?? 0;
if (unsettled > 0) {
toast.warning(
t("toast.periodClosedUnsettled", {
defaultValue: "账期已关账;仍有 {{count}} 笔注单未结算,请尽快处理。",
count: unsettled,
}),
);
} else {
toast.success(t("toast.periodClosed", { defaultValue: "账期已关账" }));
}
},
[loadPeriods, t],
);
const selectSiteId = adminSiteId ?? siteOptions[0]?.id ?? null;
const selectedSiteLabel = siteOptions.find((site) => site.id === selectSiteId)?.label ?? null;
const panelTitle = sectionTitle(activeSection, t);
const allPeriodsCompleted =
periods.length > 0 && periods.every((row) => row.status === "completed");
const showPeriodToolbar =
(activeSection === "ledger" || activeSection === "bills") && periods.length > 0;
const selectedPeriod =
periodFilter !== "all" ? (periods.find((row) => row.id === periodFilter) ?? null) : openPeriod;
const pipelineCounts = selectedPeriod?.pipeline ?? {
credit_ledger_count: 0,
share_ledger_count: 0,
const openPeriodView = (periodId: number, view: SettlementPeriodView): void => {
router.push(settlementPeriodViewHref(periodId, view));
};
const overviewStats = [
{
label: t("overview.pendingConfirm", { defaultValue: "待确认" }),
value: String(summaryTotals.pendingConfirm),
icon: ClipboardCheck,
},
{
label: t("overview.awaitingPayment", { defaultValue: "待收付" }),
value: String(summaryTotals.awaitingPayment),
icon: CircleDollarSign,
},
{
label: t("overview.totalUnpaid", { defaultValue: "未结合计" }),
value: formatDashboardMoneyMinor(summaryTotals.totalUnpaid, activeCurrency),
icon: Landmark,
},
{
label: t("overview.openPeriod", { defaultValue: "进行中账期" }),
value: openPeriod
? formatSettlementPeriodSpan(openPeriod.period_start, openPeriod.period_end)
: "—",
icon: CalendarClock,
},
{
label: t("overview.creditLedger", { defaultValue: "信用流水(账期内)" }),
value: String(pipelineCounts.credit_ledger_count),
icon: CalendarClock,
},
{
label: t("overview.shareLedger", { defaultValue: "占成流水(账期内)" }),
value: String(pipelineCounts.share_ledger_count),
icon: CalendarClock,
},
];
function renderMainPanel(): React.ReactElement {
if (activeSection === "overview") {
return (
<div className="space-y-5">
<p className="text-sm text-muted-foreground">
{t("overview.pipelineHint", {
defaultValue: "账单须关账后生成;下方为账期内实时流水笔数。",
})}
</p>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-6">
{overviewStats.map((stat) => {
const Icon = stat.icon;
return (
<button
key={stat.label}
type="button"
className="rounded-xl border border-border/70 bg-card px-4 py-4 text-left transition-colors hover:border-primary/30 hover:bg-muted/30"
onClick={() => {
if (stat.label === t("overview.pendingConfirm", { defaultValue: "待确认" })) {
setBillsInitialCategory("pending_confirm");
setActiveSection("bills");
} else if (
stat.label === t("overview.awaitingPayment", { defaultValue: "待收付" })
) {
setBillsInitialCategory("awaiting_payment");
setActiveSection("bills");
} else if (
stat.label === t("overview.creditLedger", { defaultValue: "信用流水(账期内)" })
) {
setActiveSection("ledger");
}
}}
>
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">{stat.label}</p>
<Icon className="size-4 text-muted-foreground" />
</div>
<p className="mt-2 text-base font-semibold tabular-nums">{stat.value}</p>
</button>
);
})}
</div>
</div>
);
}
if (activeSection === "periods" && adminSiteId !== null) {
return (
<AgentPeriodsConsole
adminSiteId={adminSiteId}
canManagePeriods={canManagePeriods}
settlementCycle="weekly"
siteCurrencyCode={activeCurrency}
embedded
onPeriodsChange={handlePeriodsChange}
onPeriodClosed={handlePeriodClosed}
/>
);
}
if (activeSection === "ledger" && adminSiteId !== null && periodFilterReady) {
return (
<SettlementLedgerPanel
adminSiteId={adminSiteId}
periodFilter={periodFilter}
currencyCode={activeCurrency}
canManage={canManagePeriods}
onOpenBill={setDetailBillId}
refreshKey={listRevision}
/>
);
}
if (activeSection === "bills" && adminSiteId !== null && periodFilterReady) {
return (
<SettlementBillsPanel
adminSiteId={adminSiteId}
periodFilter={periodFilter}
currencyCode={activeCurrency}
onOpenDetail={setDetailBillId}
initialCategory={billsInitialCategory}
refreshKey={listRevision}
/>
);
}
return <AdminNoResourceState />;
}
const isListMode = activePeriodId === null;
return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
<header className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-xl font-semibold tracking-tight">
{t("title", { defaultValue: "结算中心" })}
</h1>
<AdminStatusBadge
status={openPeriod ? "processing" : allPeriodsCompleted ? "completed" : "idle"}
>
{openPeriod
? t("header.statusRunning", { defaultValue: "账期进行中" })
: allPeriodsCompleted
? t("header.statusCompleted", { defaultValue: "账期已结清" })
: t("header.statusIdle", { defaultValue: "等待开期" })}
</AdminStatusBadge>
</div>
<p className="text-sm text-muted-foreground">
{t("header.subtitle", { defaultValue: "信用占成账务" })}
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-xl font-semibold tracking-tight">
{t("title", { defaultValue: "结算中心" })}
</h1>
<p className="mt-1 text-sm text-muted-foreground">
{isListMode
? t("subtitleList", { defaultValue: "账期列表:开账、关账,从行操作进入账单与报表。" })
: t("subtitle", { defaultValue: "账期关账、账单确认与收付登记" })}
</p>
</div>
{siteOptions.length <= 1 && selectedSiteLabel ? (
<p className="text-sm text-muted-foreground">{selectedSiteLabel}</p>
{siteOptions.length >= 1 && siteId !== null ? (
<Select
value={String(siteId)}
onValueChange={(v) => {
setAdminSiteId(Number(v));
setPeriodsReady(false);
router.push("/admin/settlement-center");
}}
>
<SelectTrigger className="h-9 w-[220px]">
<SelectValue>{siteLabel}</SelectValue>
</SelectTrigger>
<SelectContent>
{siteOptions.map((site) => (
<SelectItem key={site.id} value={String(site.id)}>
{site.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : null}
</header>
</div>
{adminSiteId === null ? (
<p className="text-sm text-muted-foreground">{t("empty.noSite", { defaultValue: "请选择接入站点。" })}</p>
) : (
<div className="min-w-0 space-y-4">
<SettlementCenterNav
active={activeSection}
onChange={(section) => {
if (section === "bills") {
setBillsInitialCategory("all");
}
setActiveSection(section);
}}
counts={{
pendingConfirm: summaryTotals.pendingConfirm,
awaitingPayment: summaryTotals.awaitingPayment,
}}
siteSelector={
siteOptions.length > 1 && selectSiteId !== null ? (
<Select
value={String(selectSiteId)}
onValueChange={(value) => {
setAdminSiteId(Number(value));
setPeriodFilter("all");
setPeriodFilterReady(false);
}}
>
<SelectTrigger className="h-9 w-[220px] bg-background">
<SelectValue>{selectedSiteLabel}</SelectValue>
</SelectTrigger>
<SelectContent>
{siteOptions.map((site) => (
<SelectItem key={site.id} value={String(site.id)}>
{site.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : null
{siteId === null || !periodsReady ? (
<p className="text-sm text-muted-foreground">{t("empty.noSite", { defaultValue: "请选择站点。" })}</p>
) : isListMode ? (
<SettlementPeriodWorkbench
adminSiteId={siteId}
currencyCode={currency}
canManage={canManagePeriods}
periods={periods}
onViewDetail={(id) => openPeriodView(id, "bills")}
onReloadPeriods={loadPeriods}
onPeriodOpened={() => {
setRefreshKey((n) => n + 1);
}}
onPeriodClosed={(result) => {
setRefreshKey((n) => n + 1);
const n = result?.unsettled_ticket_count ?? 0;
if (n > 0) {
toast.warning(
t("toast.periodClosedUnsettled", {
defaultValue: "已关账,仍有 {{count}} 笔注单未结算。",
count: n,
}),
);
}
/>
{showPeriodToolbar && periodFilterReady ? (
<SettlementPeriodToolbar
periods={periods}
value={periodFilter}
onChange={(next) => {
setPeriodFilter(next);
setPeriodFilterReady(true);
}}
/>
) : null}
<AdminPageCard title={panelTitle}>{renderMainPanel()}</AdminPageCard>
</div>
}}
/>
) : activePeriod === null ? (
<p className="text-sm text-muted-foreground">
{t("periodDetail.notFound", { defaultValue: "账期不存在或已切换站点,请返回列表。" })}
</p>
) : (
<SettlementCenterPeriodDetail
period={activePeriod}
view={activeView}
adminSiteId={siteId}
currencyCode={currency}
canOperateBills={canOperateBills}
refreshKey={refreshKey}
onOpenBillDetail={setDetailBillId}
/>
)}
<Dialog open={detailBillId !== null} onOpenChange={(open) => !open && setDetailBillId(null)}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>
{t("actions.billDetail", { defaultValue: "账单详情 · 确认 / 收付" })}
</DialogTitle>
<DialogContent
className="grid !h-[min(92vh,980px)] !w-[calc(100vw-2rem)] !max-w-none sm:!w-[min(1040px,calc(100vw-2rem))] sm:!max-w-[1040px] grid-rows-[auto,minmax(0,1fr)] overflow-hidden p-0"
>
<DialogHeader className="border-b px-6 py-4">
<DialogTitle>{t("actions.billDetail", { defaultValue: "账单详情" })}</DialogTitle>
</DialogHeader>
{detailBillId !== null ? (
<AgentBillDetail
billId={detailBillId}
currencyCode={activeCurrency}
canManage={canManagePeriods}
onUpdated={() => {
void loadPeriods();
setListRevision((n) => n + 1);
}}
/>
<div className="min-h-0 overflow-y-auto px-6 py-5">
<AgentBillDetail
billId={detailBillId}
currencyCode={currency}
canManage={canOperateBills}
onUpdated={() => {
void loadPeriods();
setRefreshKey((n) => n + 1);
}}
/>
</div>
) : null}
</DialogContent>
</Dialog>