Updated the public documentation site with improved layout and accessibility, including new sections for client integration and admin guides. Enhanced API queries by adding 'active_only' and 'group_by' parameters for better data filtering in risk management. Refined UI components for agent management, ensuring consistent styling and improved user experience across the application. Added localization support for new documentation content in English and Nepali.
391 lines
14 KiB
TypeScript
391 lines
14 KiB
TypeScript
"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";
|
|
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 { AdminPageGuide } from "@/components/admin/admin-page-guide";
|
|
import { ADMIN_DOC_LINKS } from "@/lib/admin-doc-links";
|
|
import { AdminNoIntegrationSiteState } from "@/components/admin/admin-no-integration-site-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";
|
|
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 {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
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";
|
|
import { cn } from "@/lib/utils";
|
|
import { useAdminProfile } from "@/stores/admin-session";
|
|
|
|
type SiteOption = { id: number; label: string; code: string; currency_code: string };
|
|
|
|
export function SettlementCenterShell(): React.ReactElement {
|
|
const { t } = useTranslation(["settlementCenter", "common"]);
|
|
const router = useRouter();
|
|
const searchParams = useSearchParams();
|
|
const profile = useAdminProfile();
|
|
const boundAgent = profile?.agent ?? null;
|
|
|
|
const { siteId: siteFromUrl, periodId: activePeriodId, view: activeView } = parseSettlementCenterView(
|
|
searchParams.get("site"),
|
|
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 canFinanceAdjustments = canOperateBills && boundAgent === null;
|
|
|
|
const [siteOptions, setSiteOptions] = useState<SiteOption[]>([]);
|
|
const [sitesReady, setSitesReady] = useState(() => boundAgent?.admin_site_id != null);
|
|
const [adminSiteId, setAdminSiteId] = useState<number | null>(null);
|
|
const [sitePickerOpen, setSitePickerOpen] = useState(false);
|
|
const [siteKeyword, setSiteKeyword] = useState("");
|
|
const [periods, setPeriods] = useState<SettlementPeriodRow[]>([]);
|
|
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.admin_site_name,
|
|
boundAgent.site_code ?? boundAgent.code,
|
|
);
|
|
setSiteOptions([{
|
|
id: boundAgent.admin_site_id,
|
|
label,
|
|
code: boundAgent.site_code ?? boundAgent.code ?? "",
|
|
currency_code: "NPR",
|
|
}]);
|
|
setAdminSiteId(boundAgent.admin_site_id);
|
|
setSitesReady(true);
|
|
return;
|
|
}
|
|
|
|
setSitesReady(false);
|
|
void getAdminIntegrationSites()
|
|
.then((sites) => {
|
|
const options = (sites.items ?? []).map((site) => ({
|
|
id: site.id,
|
|
label: formatAdminSiteLabel(site.name, site.code),
|
|
code: site.code,
|
|
currency_code: site.currency_code ?? "NPR",
|
|
}));
|
|
setSiteOptions(options);
|
|
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;
|
|
});
|
|
})
|
|
.catch(() => {
|
|
setSiteOptions([]);
|
|
})
|
|
.finally(() => {
|
|
setSitesReady(true);
|
|
});
|
|
}, [boundAgent, siteFromUrl]);
|
|
|
|
const siteId = adminSiteId ?? siteOptions[0]?.id ?? null;
|
|
const selectedSite = siteOptions.find((s) => s.id === siteId) ?? null;
|
|
const siteLabel = selectedSite?.label ?? null;
|
|
const currency = siteOptions.find((s) => s.id === siteId)?.currency_code ?? "NPR";
|
|
const filteredSites = siteKeyword.trim().toLowerCase()
|
|
? 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}>
|
|
<PopoverTrigger
|
|
render={
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-9 w-[240px] justify-between gap-2 bg-background px-3 font-normal"
|
|
/>
|
|
}
|
|
>
|
|
<span className="min-w-0 flex-1 truncate text-left">{siteLabel ?? "—"}</span>
|
|
<span className="shrink-0 text-xs text-muted-foreground">
|
|
{selectedSite?.code ?? ""}
|
|
</span>
|
|
<ChevronDown className="size-4 shrink-0 text-muted-foreground" />
|
|
</PopoverTrigger>
|
|
<PopoverContent align="end" className="w-[320px] p-0">
|
|
<div className="border-b border-border/60 p-3">
|
|
<div className="relative">
|
|
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
|
<Input
|
|
value={siteKeyword}
|
|
onChange={(event) => setSiteKeyword(event.target.value)}
|
|
placeholder={t("siteSearch", { defaultValue: "搜索站点名称" })}
|
|
className="pl-9"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<ScrollArea className="max-h-72">
|
|
<div className="p-2">
|
|
{filteredSites.map((site) => {
|
|
const active = site.id === siteId;
|
|
|
|
return (
|
|
<button
|
|
key={site.id}
|
|
type="button"
|
|
className={cn(
|
|
"flex w-full items-center gap-3 rounded-md px-3 py-2 text-left transition-colors",
|
|
active ? "bg-primary/10 text-primary" : "hover:bg-muted/70",
|
|
)}
|
|
onClick={() => {
|
|
setAdminSiteId(site.id);
|
|
setSitePickerOpen(false);
|
|
setSiteKeyword("");
|
|
router.replace(settlementCenterListHref(site.id));
|
|
}}
|
|
>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="truncate text-sm font-medium text-foreground">{site.label}</div>
|
|
<div className="truncate text-xs text-muted-foreground">{site.code}</div>
|
|
</div>
|
|
{active ? <Check className="size-4 shrink-0" /> : null}
|
|
</button>
|
|
);
|
|
})}
|
|
{filteredSites.length === 0 ? (
|
|
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
|
|
{t("common:states.empty", { defaultValue: "暂无数据" })}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</ScrollArea>
|
|
</PopoverContent>
|
|
</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 [];
|
|
}
|
|
try {
|
|
const data = await getSettlementPeriods({ admin_site_id: siteId });
|
|
const items = data.items ?? [];
|
|
setPeriods(items);
|
|
setPeriodsReady(true);
|
|
return items;
|
|
} catch {
|
|
setPeriods([]);
|
|
setPeriodsReady(true);
|
|
toast.error(t("periods.loadFailed", { defaultValue: "账期加载失败" }));
|
|
return [];
|
|
}
|
|
}, [siteId, t]);
|
|
|
|
useEffect(() => {
|
|
setPeriodsReady(false);
|
|
void loadPeriods();
|
|
}, [loadPeriods]);
|
|
|
|
const activePeriod =
|
|
activePeriodId !== null ? (periods.find((row) => row.id === activePeriodId) ?? null) : null;
|
|
|
|
const openPeriodView = (periodId: number, view: SettlementPeriodView): void => {
|
|
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]);
|
|
|
|
const shellBootstrapping =
|
|
!sitesReady || (siteId !== null && !periodsReady);
|
|
|
|
return (
|
|
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4">
|
|
<AdminPageGuide guide={t("pageGuide")} docHref={ADMIN_DOC_LINKS.settlementCenter} />
|
|
{shellBootstrapping ? (
|
|
<AdminLoadingState />
|
|
) : siteId === null && siteOptions.length === 0 && boundAgent === null ? (
|
|
<AdminNoIntegrationSiteState canCreate={profile?.is_super_admin === true} />
|
|
) : siteId === null ? (
|
|
<p className="text-sm text-muted-foreground">{t("empty.noSite", { defaultValue: "请选择站点。" })}</p>
|
|
) : isListMode ? (
|
|
<SettlementPeriodWorkbench
|
|
adminSiteId={siteId}
|
|
currencyCode={currency}
|
|
canManage={canManagePeriods}
|
|
periods={periods}
|
|
headerActions={headerActions}
|
|
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,
|
|
}),
|
|
);
|
|
}
|
|
}}
|
|
/>
|
|
) : activePeriod === null ? (
|
|
!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}
|
|
view={activeView}
|
|
adminSiteId={siteId}
|
|
currencyCode={currency}
|
|
canOperateBills={canOperateBills}
|
|
boundAgentId={boundAgent?.id ?? null}
|
|
refreshKey={refreshKey}
|
|
onOpenBillDetail={setDetailBillId}
|
|
/>
|
|
)}
|
|
|
|
<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(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>
|
|
</DialogHeader>
|
|
{detailBillId !== null ? (
|
|
<div className="min-h-0 overflow-y-auto px-6 py-5">
|
|
<AgentBillDetail
|
|
billId={detailBillId}
|
|
currencyCode={currency}
|
|
canManage={canOperateBills}
|
|
boundAgent={boundAgent}
|
|
canFinanceAdjustments={canFinanceAdjustments}
|
|
onUpdated={() => {
|
|
void loadPeriods();
|
|
setRefreshKey((n) => n + 1);
|
|
}}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|