Files
frontend/apps/st/src/components/statistics/VerificationSelectionReport.tsx
T

507 lines
19 KiB
TypeScript

"use client";
import { useCallback, useEffect, useState } from "react";
import {
type InfiniteData,
keepPreviousData,
useInfiniteQuery,
useQueryClient,
} from "@tanstack/react-query";
import { useTabStore as useGridTabStore } from "@rsone/components";
import { verificationInputReportApi } from "@/entities/statistics/api/verification-input-report-service";
import type {
VerificationInputReportItem,
VerificationInputReportResponse,
VerificationInputReportTypeCountingInfo,
} from "@/entities/statistics/model/verification-input-report-interface";
import { useAlertStore, useMenuStore } from "@/shared/lib";
import {
findMenuItemByUrlPath,
resolveInputPageNavigationFromStatsCd,
} from "@/shared/lib/stats-cd-input-navigation";
import { useStatisticsInputFocusStore } from "@/store/statistics-input-focus-store";
import { useTabStore } from "@/store/tab-store";
import dayjs from "dayjs";
import {
Badge,
type BadgeProps,
LoadingWithAnimation,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
Label,
} from "@common/components";
import { CodeSelectWithAll } from "@/entities/code";
type VerificationReportTabKey = "NV" | "IMR" | "HB";
const VERIFICATION_REPORT_TABS: { value: VerificationReportTabKey; label: string }[] = [
{ value: "NV", label: "일반" },
{ value: "IMR", label: "I-MR" },
{ value: "HB", label: "HB" },
];
const TYPE_CODE_COLOR_MAP: Record<string, BadgeProps["color"]> = {
NV: "gray",
IMR: "red",
HB: "blue",
};
const TYPE_CODE_NAME_MAP: Record<string, string> = {
NV: "일반",
IMR: "I-MR",
HB: "HB",
};
const PAGE_SIZE = 100;
const SCROLL_LOAD_THRESHOLD_PX = 120;
function getNextVerificationInputReportPage(
lastPage: VerificationInputReportResponse,
): number | undefined {
const pageInfo = lastPage?.data?.pageInfo;
if (!pageInfo) return undefined;
const { currentPage, totalPages } = pageInfo;
if (currentPage + 1 <= totalPages) return currentPage + 1;
return undefined;
}
function flattenVerificationInputReportResults(
data: InfiniteData<VerificationInputReportResponse> | undefined,
): VerificationInputReportItem[] {
return (
data?.pages.flatMap((page) => page?.data?.verifyResultList ?? []) ?? []
);
}
function getVerifyTypeCountingInfo(
data: InfiniteData<VerificationInputReportResponse> | undefined,
): VerificationInputReportTypeCountingInfo | null {
return data?.pages[0]?.data?.verifyTypeCountingInfo ?? null;
}
function formatReportItemTitle(item: VerificationInputReportItem): string {
return item.statsNm?.trim() || item.vrfcTrgtNm?.trim() || item.statsCd;
}
function formatReportItemRegion(item: VerificationInputReportItem): string {
const parts = [item.ctpvNm, item.sggNm].filter(
(v): v is string => Boolean(v?.trim()),
);
return parts.length > 0 ? parts.join(" ") : "-";
}
function useVerificationReportList(
statsYr: string,
statsCdQueryKey: string,
vrfcMthdCd: VerificationReportTabKey,
statsCd: string,
enabled: boolean,
) {
return useInfiniteQuery({
queryKey: [
"verificationInputReport",
statsYr,
vrfcMthdCd,
statsCdQueryKey,
],
initialPageParam: 1,
queryFn: ({ pageParam }) =>
verificationInputReportApi.getVerificationInputReport({
statsYr,
vrfcMthdCd,
page: pageParam,
size: PAGE_SIZE,
...(!(statsCd === "" || statsCd === "ALL") && { statsCd }),
}),
getNextPageParam: getNextVerificationInputReportPage,
placeholderData: keepPreviousData,
refetchOnWindowFocus: false,
// 탭 전환만으로는 재조회하지 않음(명시적 새로고침/조건 변경 시만 조회)
staleTime: Infinity,
refetchOnMount: false,
enabled,
});
}
type VerificationSelectionReportProps = {
onClose?: () => void;
};
const VerificationSelectionReport = ({
onClose,
}: VerificationSelectionReportProps) => {
const queryClient = useQueryClient();
const menuList = useTabStore((s) => s.menuList);
const addTab = useTabStore((s) => s.addTab);
const setCurrentMenuInfo = useMenuStore((s) => s.setCurrentMenuInfo);
const addTabForGrid = useGridTabStore((s) => s.addTab);
const setPendingInputTab = useStatisticsInputFocusStore(
(s) => s.setPendingInputTab,
);
const alert = useAlertStore((s) => s.setMessage);
const [activeTab, setActiveTab] = useState<VerificationReportTabKey>("NV");
const [statsCd, setStatsCd] = useState<string>("ALL");
const [isFullReloading, setIsFullReloading] = useState(false);
const statsYr = String(dayjs().year() - 1);
const statsCdQueryKey =
statsCd === "" || statsCd === "ALL" ? "" : statsCd;
const handleStatsCdChange = useCallback(
(nextStatsCd: string) => {
setStatsCd(nextStatsCd);
setActiveTab("NV");
setIsFullReloading(true);
// 통계항목 변경 시 모든 탭 캐시 제거 후 NV부터 다시 조회
void queryClient.removeQueries({
queryKey: ["verificationInputReport", statsYr],
});
},
[queryClient, statsYr],
);
const nvQuery = useVerificationReportList(
statsYr,
statsCdQueryKey,
"NV",
statsCd,
activeTab === "NV",
);
const imrQuery = useVerificationReportList(
statsYr,
statsCdQueryKey,
"IMR",
statsCd,
activeTab === "IMR",
);
const hbQuery = useVerificationReportList(
statsYr,
statsCdQueryKey,
"HB",
statsCd,
activeTab === "HB",
);
const queryByTab = {
NV: nvQuery,
IMR: imrQuery,
HB: hbQuery,
} as const;
const activeQuery = queryByTab[activeTab];
const isRefreshing = activeQuery.isFetching && !activeQuery.isPending;
const error = activeQuery.error;
/** 통계항목 변경·최초 진입: 탭 영역 숨기고 카드 전체 로딩만 표시 */
const hideTabsSection =
isFullReloading ||
(activeTab === "NV" && !nvQuery.data && (nvQuery.isPending || nvQuery.isFetching));
useEffect(() => {
if (!isFullReloading) return;
if (nvQuery.isFetching) return;
if (nvQuery.isSuccess || nvQuery.isError) {
setIsFullReloading(false);
}
}, [isFullReloading, nvQuery.isFetching, nvQuery.isSuccess, nvQuery.isError]);
const handleRefresh = useCallback(() => {
setStatsCd("ALL");
setActiveTab("NV");
setIsFullReloading(true);
void queryClient.removeQueries({
queryKey: ["verificationInputReport", statsYr],
});
}, [queryClient, statsYr]);
const listsByTab: Record<VerificationReportTabKey, VerificationInputReportItem[]> = {
NV: flattenVerificationInputReportResults(nvQuery.data),
IMR: flattenVerificationInputReportResults(imrQuery.data),
HB: flattenVerificationInputReportResults(hbQuery.data),
};
const typeCountingInfo =
getVerifyTypeCountingInfo(nvQuery.data) ??
getVerifyTypeCountingInfo(imrQuery.data) ??
getVerifyTypeCountingInfo(hbQuery.data);
const totalCountByTab: Record<VerificationReportTabKey, number> = {
NV: typeCountingInfo?.nvCount ?? listsByTab.NV.length,
IMR: typeCountingInfo?.imrCount ?? listsByTab.IMR.length,
HB: typeCountingInfo?.hbCount ?? listsByTab.HB.length,
};
const openInputFromStatsCd = useCallback(
(item: VerificationInputReportItem) => {
const nav = resolveInputPageNavigationFromStatsCd(item.statsCd);
if (!nav) {
alert(
`입력 화면으로 연결되지 않는 통계코드입니다: ${item.statsCd}`,
);
return;
}
if (!menuList) {
alert(
"메뉴 정보를 불러오는 중입니다. 잠시 후 다시 시도해 주세요.",
);
return;
}
const menu = findMenuItemByUrlPath(menuList, nav.urlPath);
if (!menu) {
alert(
"해당 입력 메뉴를 찾을 수 없습니다. 메뉴 권한을 확인해 주세요.",
);
return;
}
setPendingInputTab(nav.urlPath, nav.tabKey, {
statsYr: item.statsYr,
sewerBzmnCd: item.sewerBzmnCd,
ctpvNm: item.ctpvNm,
sggNm: item.sggNm ?? "",
});
setCurrentMenuInfo(menu);
const ok = addTab(menu);
if (ok) {
addTabForGrid(menu);
} else {
alert(`최대 추가 할 수 있는 화면 수(10개)를 \n초과 하였습니다.
기존 화면을 닫은 후 이용해주세요.`);
}
},
[
menuList,
addTab,
addTabForGrid,
setCurrentMenuInfo,
setPendingInputTab,
alert,
],
);
const handleListScroll = useCallback(
(
e: React.UIEvent<HTMLDivElement>,
hasNextPage: boolean,
isFetchingNextPage: boolean,
fetchNextPage: () => void,
) => {
const el = e.currentTarget;
const remaining =
el.scrollHeight - el.scrollTop - el.clientHeight;
if (remaining > SCROLL_LOAD_THRESHOLD_PX) return;
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
[],
);
const isTabContentLoading = (tab: VerificationReportTabKey) => {
const query = queryByTab[tab];
return (
activeTab === tab &&
!hideTabsSection &&
(query.isPending ||
(query.isFetching && !query.isFetchingNextPage) ||
(!query.data && !query.error))
);
};
const renderResultList = (
typeCode: VerificationReportTabKey,
items: VerificationInputReportItem[],
isTabLoading: boolean,
hasNextPage: boolean,
isFetchingNextPage: boolean,
fetchNextPage: () => void,
) => (
<div
className="min-h-0 flex-1 overflow-y-auto p-2"
onScroll={(e) =>
handleListScroll(
e,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
)
}
>
{isTabLoading ? (
<div className="flex h-full min-h-[8rem] items-center justify-center">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
) : items.length === 0 && !isFetchingNextPage ? (
<div className="flex h-full min-h-[8rem] items-center justify-center text-lg text-muted-foreground">
항목이 없습니다.
</div>
) : (
<div className="space-y-2 tracking-tight">
{items.map((item) => (
<div
key={`${typeCode}-${item.vrfcSn}`}
role="button"
tabIndex={0}
className="flex flex-col rounded-sm border border-border p-3 cursor-pointer hover:bg-gray-100"
onClick={() => openInputFromStatsCd(item)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
openInputFromStatsCd(item);
}
}}
>
<div className="mb-2">
<div className="font-medium">
{formatReportItemTitle(item)}
</div>
<Badge
color={
TYPE_CODE_COLOR_MAP[item.vrfcMthdCd] ??
"gray"
}
className="text-sm float-end"
>
{TYPE_CODE_NAME_MAP[item.vrfcMthdCd] ??
item.vrfcMthdNm}
</Badge>
</div>
<p className="text-lg text-muted-foreground">
{item.rsnExpln}
</p>
<div className="flex justify-between text-lg text-muted-foreground">
<span>{formatReportItemRegion(item)}</span>
<span>
현재: {item.nowVlCn ?? "-"} / 비교:{" "}
{item.compVlCn ?? "-"}
</span>
</div>
</div>
))}
{isFetchingNextPage && (
<div className="flex justify-center py-3">
<div className="relative size-[3rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
)}
</div>
)}
</div>
);
return (
<div className="w-1/5 min-w-0 bg-container p-4 !pb-2 flex border-r border-border">
<div className="card card-border min-w-0 flex-1 bg-background !px-0 max-h-[1000px] flex flex-col">
<div className="border-b border-border flex justify-between">
<div className="flex justify-center items-center text-[2.4rem] ">
<h2 className="font-semibold text-title p-4 pr-1">이상치 선별 보고서</h2>
<span
role="button"
tabIndex={0}
aria-label="새로고침"
className={`text-gray-400 cursor-pointer hover:text-[#2094fa] ${isRefreshing ? "pointer-events-none opacity-50" : ""}`}
onClick={handleRefresh}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleRefresh();
}
}}
>
<i className="diveicon di-arrow-reflash" />
</span>
</div>
<span
role="button"
tabIndex={0}
aria-label="닫기"
className="mt-2 mr-4 flex flex-shrink-0 cursor-pointer items-start justify-center text-3xl leading-none hover:text-[#2094fa]"
onClick={() => onClose?.()}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClose?.();
}
}}
>
<i className="ri-close-line" />
</span>
</div>
<div className="flex min-w-0 items-center gap-2 pt-2.5 px-4">
<Label className="text-label shrink-0">통계항목</Label>
<CodeSelectWithAll
upCd="STITEM"
value={statsCd}
onValueChange={handleStatsCdChange}
placeholder="전체"
className="min-w-0 flex-1 overflow-hidden"
/>
</div>
<div className="card-body p-2.5 flex-1 flex flex-col overflow-hidden relative min-h-0">
{hideTabsSection && (
<div className="absolute inset-0 z-50 [data-theme='black']:bg-transparent rounded-lg p-10 flex justify-center items-center">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
)}
{!hideTabsSection && error && (
<div className="absolute inset-0 z-50 [data-theme='black']:bg-transparent rounded-lg p-10 flex justify-center items-center">
데이터를 불러올 수 없습니다.
</div>
)}
{!hideTabsSection && !error && (
<section className="flex min-h-0 flex-1 flex-col">
<Tabs
value={activeTab}
onValueChange={(value) =>
setActiveTab(value as VerificationReportTabKey)
}
className="flex min-h-0 flex-1 flex-col"
variant="default"
>
<TabsList className="grid w-full grid-cols-3 p-1 shrink-0">
{VERIFICATION_REPORT_TABS.map((tab) => (
<TabsTrigger
key={tab.value}
value={tab.value}
className="!px-2 !py-2 text-[1.3rem] whitespace-normal"
>
{tab.label} ({totalCountByTab[tab.value]})
</TabsTrigger>
))}
</TabsList>
{VERIFICATION_REPORT_TABS.map((tab) => (
<TabsContent
key={tab.value}
value={tab.value}
className="mt-0 flex min-h-0 flex-1 flex-col p-0 data-[state=inactive]:hidden"
>
{renderResultList(
tab.value,
listsByTab[tab.value],
isTabContentLoading(tab.value),
queryByTab[tab.value].hasNextPage ?? false,
queryByTab[tab.value].isFetchingNextPage,
queryByTab[tab.value].fetchNextPage,
)}
</TabsContent>
))}
</Tabs>
</section>
)}
</div>
</div>
</div>
);
};
export default VerificationSelectionReport;