Files
lotteryAdmin/src/modules/settlement/settlement-center-shell.tsx
kang 641c87ff50 feat(docs, agents, risk): enhance documentation, API queries, and UI components
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.
2026-06-15 17:21:50 +08:00

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>
);
}