[st] feat: 검증 미통과 내역 메뉴 작업

This commit is contained in:
2026-05-04 10:34:53 +09:00
parent 3637d09d03
commit 4a57b363eb
5 changed files with 185 additions and 7 deletions
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useMemo } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { usePathname } from "next/navigation";
import {
@@ -11,6 +11,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import { getCookie, useAlertStore, useMenuStore } from "@/shared/lib";
import { isVerificationSelectionReportMenu } from "@/shared/lib/verification-selection-report";
import { StatisticsPageLayout } from "@/shared/ui/statistics-page-layout";
import { tokenManager } from "@/entities/auth";
import type { MenuItem } from "@/entities/menu";
@@ -20,6 +21,8 @@ import { getConfig } from "@common/config";
import { getTabStoreState, useTabStore } from "@/store/tab-store";
import VerificationSelectionReport from "@/components/statistics/VerificationSelectionReport";
const appConfig = getConfig();
const userId = getCookie(appConfig.AUTH_USER_ID ?? "");
const appSiteId = getCookie("site-id-key") ?? "";
@@ -33,6 +36,10 @@ export default function WithBaseLayout({
const pathname = usePathname();
const queryClient = useQueryClient();
/** 탭 추가 없이 메뉴만 눌렀을 때 패널 유지 (다른 메뉴 클릭해도 유지) */
const [isVerificationSelectionReportPanelOpen, setReportPanelOpen] =
useState(false);
const setCurrentMenuInfo = useMenuStore(
(state) => state.setCurrentMenuInfo,
);
@@ -145,6 +152,12 @@ export default function WithBaseLayout({
const handleMenuClick = useCallback(
(item: MenuItem) => {
setCurrentMenuInfo(item);
if (isVerificationSelectionReportMenu(item)) {
setReportPanelOpen(true);
return;
}
const addTabResult: boolean = addTab(item);
// packages/rsone의 스토어에도 동기화
@@ -153,11 +166,11 @@ export default function WithBaseLayout({
}
if (!addTabResult) {
alert(`최대 추가 할 수 있는 화면 수를 초과 하였습니다.
alert(`최대 추가 할 수 있는 화면 수(10개)를 \n초과 하였습니다.
기존 화면을 닫은 후 이용해주세요.`);
}
},
[setCurrentMenuInfo, addTab, addTabForGrid, alert],
[setCurrentMenuInfo, addTab, addTabForGrid, alert, setReportPanelOpen],
);
return (
@@ -167,7 +180,14 @@ export default function WithBaseLayout({
currentPath={currentPath}
handleMenuClick={handleMenuClick}
>
<StatisticsPageLayout>{children}</StatisticsPageLayout>
<div className="flex flex-1 h-full">
{isVerificationSelectionReportPanelOpen && (
<VerificationSelectionReport
onClose={() => setReportPanelOpen(false)}
/>
)}
<StatisticsPageLayout>{children}</StatisticsPageLayout>
</div>
</BaseLayout>
);
}
@@ -0,0 +1,138 @@
"use client";
import { useDashboardVrfcReport } from "@/entities/statistics";
import dayjs from "dayjs";
import { useUserStore } from "@/shared/lib";
import {
Badge,
type BadgeProps,
LoadingWithAnimation,
} from "@common/components";
const TYPE_CODE_COLOR_MAP: Record<string, BadgeProps["color"]> = {
NV: "gray",
IMR: "red",
};
const TYPE_CODE_NAME_MAP: Record<string, string> = {
NV: "일반",
IMR: "I-MR",
};
type VerificationSelectionReportProps = {
onClose?: () => void;
};
const VerificationSelectionReport = ({
onClose,
}: VerificationSelectionReportProps) => {
const userInfo = useUserStore((state) => state.userInfo);
const param = {
statsYr: String(dayjs().year() - 1),
dtlInstCd: userInfo?.dtlInstCd || '',
enoCd: userInfo?.enoCd || '',
laeSeCd: userInfo?.laeSeCd || '',
};
const { data, isLoading, error } = useDashboardVrfcReport(param);
const imrList = data?.list.filter((item) => item.vrfcCd === "IMR") ?? [];
const nvList = data?.list.filter((item) => item.vrfcCd === "NV") ?? [];
const renderSection = (typeCode: "IMR" | "NV", items: typeof imrList) => (
<section className="flex min-h-0 flex-1 flex-col rounded-sm border border-border h-[50rem]">
<div className="flex items-center justify-between border-b border-border px-3 py-2">
<Badge color={TYPE_CODE_COLOR_MAP[typeCode]} className="rounded-full px-2 py-1 text-lg">
{TYPE_CODE_NAME_MAP[typeCode]} {items.length}
</Badge>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2">
{items.length === 0 ? (
<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, index) => (
<div
key={`${typeCode}-${index}`}
className="flex flex-col rounded-sm border border-border p-3 cursor-pointer hover:bg-gray-100"
>
<div className="mb-2 flex items-center justify-between">
<span className="font-medium">
{item.statsNm}
</span>
<Badge
color={TYPE_CODE_COLOR_MAP[item.vrfcCd]}
className="text-sm"
>
{TYPE_CODE_NAME_MAP[item.vrfcCd]}
</Badge>
</div>
<p className="text-lg text-muted-foreground">
{item.rsnExpln}
</p>
<div className="flex justify-between text-lg text-muted-foreground">
<span>{item.admdstNm}</span>
<span>
현재: {item.nowVlCn ?? "-"} / 비교:{" "}
{item.compVlCn ?? "-"}
</span>
</div>
</div>
))}
</div>
)}
</div>
</section>
);
return (
<div className="w-1/5 bg-container p-4 !pb-2 flex border-r border-border">
<div className="card card-border flex-1 bg-background !px-0">
<div className="border-b border-border flex justify-between">
<h2 className="text-[2.4rem] font-semibold text-title p-4 ">검증 미통과 내역</h2>
<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="card-body max-h-[calc(100%-3.25rem)] p-2.5">
{isLoading && (
<div className="flex h-full items-center justify-center">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
)}
{error && (
<div className="flex h-full items-center justify-center">
데이터를 불러올 수 없습니다.
</div>
)}
{data && (
<div className="grid grid-rows-2 gap-6">
{renderSection("IMR", imrList)}
{renderSection("NV", nvList)}
</div>
)}
</div>
</div>
</div>
);
};
export default VerificationSelectionReport;
@@ -1,6 +1,6 @@
"use client";
import { Suspense, useEffect, useRef } from "react";
import { Suspense, useEffect, useMemo, useRef } from "react";
import dynamic from "next/dynamic";
import { SkeletonPage } from "@/shared/ui/skeleton-page";
@@ -8,11 +8,20 @@ import { SkeletonPage } from "@/shared/ui/skeleton-page";
import "@rsone/components/index.css";
import Dashboard from "@/components/statistics/Dashboard";
import { isVerificationSelectionReportMenu } from "@/shared/lib/verification-selection-report";
import { useAdminStore } from "@/store/admin-store";
import { useTabStore } from "@/store/tab-store";
const StatisticsPage = () => {
const { tabs, activeTabId } = useTabStore();
const tabsForBody = useMemo(
() =>
tabs.filter(
(tab) => !isVerificationSelectionReportMenu(tab.menuItem),
),
[tabs],
);
const fetchAdminInfo = useAdminStore((state) => state.fetchAdminInfo);
// 컴포넌트 캐시를 useRef로 유지 (재렌더링 시에도 유지됨)
@@ -45,7 +54,7 @@ const StatisticsPage = () => {
return (
<div className="card card-border !mt-6 border-border bg-background">
{tabs.map((tab) => {
{tabsForBody.map((tab) => {
// 렌더링 시점에 컴포넌트가 없으면 바로 로드
if (!componentCache.current.has(tab.id)) {
const componentName = tab.urlPath;
@@ -0,0 +1,11 @@
import type { MenuItem } from "@/entities/menu";
export const VERIFICATION_SELECTION_REPORT_MARKER =
"VerificationSelectionReport";
export function isVerificationSelectionReportMenu(item: MenuItem): boolean {
return (
item.menuEngNm === VERIFICATION_SELECTION_REPORT_MARKER ||
!!item.urlPath?.includes(VERIFICATION_SELECTION_REPORT_MARKER)
);
}
@@ -10,7 +10,7 @@ function StatisticsPageLayout({ children }: { children: React.ReactNode }) {
const menuPaths = pageTitle?.path?.split(">").map((path) => path.trim());
return (
<div className="h-auto min-h-full bg-container pb-2">
<div className="h-auto min-h-full bg-container pb-2 flex-1">
<TabBar />
<PageTitle
title={pageTitle?.label ?? ""}