507 lines
19 KiB
TypeScript
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;
|