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

@@ -1,6 +1,7 @@
"use client";
import { Check, ChevronDown, Search } from "lucide-react";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslation } from "react-i18next";
@@ -8,10 +9,12 @@ import { toast } from "sonner";
import { getSettlementPeriods, type SettlementPeriodRow } from "@/api/admin-agent-settlement";
import { getAdminIntegrationSites } from "@/api/admin-integration-sites";
import { AdminLoadingState } from "@/components/admin/admin-loading-state";
import { AgentBillDetail } from "@/modules/settlement/agent-bill-detail";
import { SettlementCenterPeriodDetail } from "@/modules/settlement/settlement-center-period-detail";
import {
parseSettlementCenterView,
settlementCenterListHref,
settlementPeriodViewHref,
type SettlementPeriodView,
} from "@/modules/settlement/settlement-center-nav";
@@ -25,7 +28,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Button, buttonVariants } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
@@ -41,7 +44,8 @@ export function SettlementCenterShell(): React.ReactElement {
const profile = useAdminProfile();
const boundAgent = profile?.agent ?? null;
const { periodId: activePeriodId, view: activeView } = parseSettlementCenterView(
const { siteId: siteFromUrl, periodId: activePeriodId, view: activeView } = parseSettlementCenterView(
searchParams.get("site"),
searchParams.get("period"),
searchParams.get("view"),
);
@@ -50,6 +54,7 @@ export function SettlementCenterShell(): React.ReactElement {
profile?.is_super_admin === true ||
adminHasAnyPermission(profile?.permissions, [PRD_SETTLEMENT_AGENT_MANAGE]);
const canManagePeriods = canOperateBills && boundAgent === null;
const canFinanceAdjustments = canOperateBills && boundAgent === null;
const [siteOptions, setSiteOptions] = useState<SiteOption[]>([]);
const [adminSiteId, setAdminSiteId] = useState<number | null>(null);
@@ -59,10 +64,14 @@ export function SettlementCenterShell(): React.ReactElement {
const [periodsReady, setPeriodsReady] = useState(false);
const [detailBillId, setDetailBillId] = useState<number | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
const [periodLookupDone, setPeriodLookupDone] = useState(false);
useEffect(() => {
if (boundAgent?.admin_site_id) {
const label = formatAdminSiteLabel(boundAgent.name, boundAgent.site_code ?? boundAgent.code);
const label = formatAdminSiteLabel(
boundAgent.admin_site_name,
boundAgent.site_code ?? boundAgent.code,
);
setSiteOptions([{
id: boundAgent.admin_site_id,
label,
@@ -81,11 +90,17 @@ export function SettlementCenterShell(): React.ReactElement {
currency_code: site.currency_code ?? "NPR",
}));
setSiteOptions(options);
if (adminSiteId === null && options[0]) {
setAdminSiteId(options[0].id);
}
setAdminSiteId((current) => {
if (siteFromUrl !== null && options.some((site) => site.id === siteFromUrl)) {
return siteFromUrl;
}
if (current !== null && options.some((site) => site.id === current)) {
return current;
}
return options[0]?.id ?? null;
});
});
}, [adminSiteId, boundAgent]);
}, [boundAgent, siteFromUrl]);
const siteId = adminSiteId ?? siteOptions[0]?.id ?? null;
const selectedSite = siteOptions.find((s) => s.id === siteId) ?? null;
@@ -95,6 +110,17 @@ export function SettlementCenterShell(): React.ReactElement {
? siteOptions.filter((site) => site.label.toLowerCase().includes(siteKeyword.trim().toLowerCase()))
: siteOptions;
const boundAgentIdentity =
boundAgent !== null ? (
<p className="text-xs text-muted-foreground">
{t("boundAgentIdentity", {
defaultValue: "经营身份:{{agent}} · 账号 {{username}}",
agent: boundAgent.name || boundAgent.code,
username: profile?.username ?? "—",
})}
</p>
) : null;
const siteSelector =
siteOptions.length > 0 && siteId !== null ? (
<Popover open={sitePickerOpen} onOpenChange={setSitePickerOpen}>
@@ -143,6 +169,7 @@ export function SettlementCenterShell(): React.ReactElement {
setAdminSiteId(site.id);
setSitePickerOpen(false);
setSiteKeyword("");
router.replace(settlementCenterListHref(site.id));
}}
>
<div className="min-w-0 flex-1">
@@ -164,6 +191,14 @@ export function SettlementCenterShell(): React.ReactElement {
</Popover>
) : null;
const headerActions =
siteSelector !== null || boundAgentIdentity !== null ? (
<div className="flex flex-col items-end gap-1">
{siteSelector}
{boundAgentIdentity}
</div>
) : null;
const loadPeriods = useCallback(async (): Promise<SettlementPeriodRow[]> => {
if (siteId === null) {
return [];
@@ -191,22 +226,75 @@ export function SettlementCenterShell(): React.ReactElement {
activePeriodId !== null ? (periods.find((row) => row.id === activePeriodId) ?? null) : null;
const openPeriodView = (periodId: number, view: SettlementPeriodView): void => {
router.push(settlementPeriodViewHref(periodId, view));
router.push(settlementPeriodViewHref(periodId, view, siteId));
};
const isListMode = activePeriodId === null;
useEffect(() => {
if (boundAgent !== null || siteId === null) {
return;
}
if (isListMode) {
if (siteFromUrl === siteId) {
return;
}
router.replace(settlementCenterListHref(siteId));
return;
}
if (activePeriodId !== null && siteFromUrl !== siteId) {
router.replace(settlementPeriodViewHref(activePeriodId, activeView, siteId));
}
}, [activePeriodId, activeView, boundAgent, isListMode, router, siteFromUrl, siteId]);
useEffect(() => {
setPeriodLookupDone(false);
}, [activePeriodId, siteId]);
useEffect(() => {
if (!periodsReady || activePeriodId === null || siteId === null) {
return;
}
if (activePeriod !== null) {
setPeriodLookupDone(true);
return;
}
let cancelled = false;
void getSettlementPeriods().then((data) => {
if (cancelled) {
return;
}
const match = (data.items ?? []).find((row) => row.id === activePeriodId);
if (match?.admin_site_id && match.admin_site_id !== siteId) {
setAdminSiteId(match.admin_site_id);
router.replace(settlementPeriodViewHref(activePeriodId, activeView, match.admin_site_id));
return;
}
setPeriodLookupDone(true);
});
return () => {
cancelled = true;
};
}, [activePeriod, activePeriodId, activeView, periodsReady, router, siteId]);
return (
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4">
{siteId === null || !periodsReady ? (
{siteId === null ? (
<p className="text-sm text-muted-foreground">{t("empty.noSite", { defaultValue: "请选择站点。" })}</p>
) : !periodsReady ? (
<AdminLoadingState />
) : isListMode ? (
<SettlementPeriodWorkbench
adminSiteId={siteId}
currencyCode={currency}
canManage={canManagePeriods}
periods={periods}
headerActions={siteSelector}
headerActions={headerActions}
onViewDetail={(id) => openPeriodView(id, "bills")}
onReloadPeriods={loadPeriods}
onPeriodOpened={() => {
@@ -226,9 +314,21 @@ export function SettlementCenterShell(): React.ReactElement {
}}
/>
) : activePeriod === null ? (
<p className="text-sm text-muted-foreground">
{t("periodDetail.notFound", { defaultValue: "账期不存在或已切换站点,请返回列表。" })}
</p>
!periodLookupDone ? (
<AdminLoadingState />
) : (
<div className="flex flex-col gap-3">
<p className="text-sm text-muted-foreground">
{t("periodDetail.notFound", { defaultValue: "账期不存在或已切换站点,请返回列表。" })}
</p>
<Link
href={settlementCenterListHref(siteId)}
className={cn(buttonVariants({ variant: "outline", size: "sm" }), "w-fit")}
>
{t("periodDetail.back", { defaultValue: "返回账期列表" })}
</Link>
</div>
)
) : (
<SettlementCenterPeriodDetail
period={activePeriod}
@@ -236,6 +336,7 @@ export function SettlementCenterShell(): React.ReactElement {
adminSiteId={siteId}
currencyCode={currency}
canOperateBills={canOperateBills}
boundAgentId={boundAgent?.id ?? null}
refreshKey={refreshKey}
onOpenBillDetail={setDetailBillId}
/>
@@ -243,7 +344,7 @@ export function SettlementCenterShell(): React.ReactElement {
<Dialog open={detailBillId !== null} onOpenChange={(open) => !open && setDetailBillId(null)}>
<DialogContent
className="grid !h-[min(92vh,980px)] !w-[calc(100vw-2rem)] !max-w-none sm:!w-[min(860px,calc(100vw-2rem))] sm:!max-w-[860px] grid-rows-[auto,minmax(0,1fr)] overflow-hidden p-0"
className="grid !h-[min(92vh,980px)] !w-[calc(100vw-2rem)] !max-w-none sm:!w-[min(640px,calc(100vw-2rem))] sm:!max-w-[640px] grid-rows-[auto,minmax(0,1fr)] overflow-hidden p-0"
>
<DialogHeader className="border-b px-6 py-4">
<DialogTitle>{t("actions.billDetail", { defaultValue: "账单详情" })}</DialogTitle>
@@ -254,6 +355,8 @@ export function SettlementCenterShell(): React.ReactElement {
billId={detailBillId}
currencyCode={currency}
canManage={canOperateBills}
boundAgent={boundAgent}
canFinanceAdjustments={canFinanceAdjustments}
onUpdated={() => {
void loadPeriods();
setRefreshKey((n) => n + 1);