diff --git a/apps/sn/app/(map-layout)/map/components/inftwtResult/InftwtResultPopupPage.tsx b/apps/sn/app/(map-layout)/map/components/inftwtResult/InftwtResultPopupPage.tsx index 4d87dc430..b49fad160 100644 --- a/apps/sn/app/(map-layout)/map/components/inftwtResult/InftwtResultPopupPage.tsx +++ b/apps/sn/app/(map-layout)/map/components/inftwtResult/InftwtResultPopupPage.tsx @@ -321,6 +321,11 @@ export default function InftwtResultPopupPage() { pageSize={INFTWT_RESULT_PAGE_SIZE} total={total} cellClick={(index) => handleRowClick(index)} + cellClassName={(_, headId) => + headId === "anlsInfo" + ? "whitespace-normal break-all leading-[1.35]" + : "" + } onPageChange={(page) => void handleSearch(page)} curPage={currentPage} index={selectedIndex} diff --git a/apps/sn/app/(with-base-layout)/ii/irsetting/page.tsx b/apps/sn/app/(with-base-layout)/ii/irsetting/page.tsx index 686ea2d91..858c5a9d8 100644 --- a/apps/sn/app/(with-base-layout)/ii/irsetting/page.tsx +++ b/apps/sn/app/(with-base-layout)/ii/irsetting/page.tsx @@ -99,6 +99,11 @@ const IrSetting = () => { buildStatusUrl: (analysisSn) => `v1/inftwtAnls/${analysisSn}/status`, successMessage: '침입수 분석이 완료되었습니다.', failureMessage: '침입수 분석이 실패했습니다. 로그를 확인해주세요.', + analysisType: 'INFTWT', + analysisName: '침입수 분석', + initialProgressPercent: 10, + initialProgressStep: 'REQUEST_ACCEPTED', + initialProgressMessage: '요청 접수 완료', }); const form = useForm>({ diff --git a/apps/sn/app/(with-base-layout)/ii/useAnalysisCompletionWatcher.ts b/apps/sn/app/(with-base-layout)/ii/useAnalysisCompletionWatcher.ts deleted file mode 100644 index 8f28ee307..000000000 --- a/apps/sn/app/(with-base-layout)/ii/useAnalysisCompletionWatcher.ts +++ /dev/null @@ -1,184 +0,0 @@ -"use client"; - -import { useEffect } from "react"; - -import { getData } from "@/components/lib/api"; - -type AnalysisStatusResponse = { - scsYn?: string | null; - message?: string | null; -}; - -type UseAnalysisCompletionWatcherParams = { - buildStatusUrl: (analysisSn: number) => string; - successMessage: string; - failureMessage: string; - intervalMs?: number; -}; - -type PendingAnalysisWatch = { - analysisSn: number; - failureMessage: string; - key: string; - statusUrl: string; - successMessage: string; -}; - -const DEFAULT_POLLING_INTERVAL_MS = 3000; -const PENDING_ANALYSIS_WATCHES_STORAGE_KEY = "SN_PENDING_ANALYSIS_WATCHES"; - -const loadPendingAnalysisWatches = (): PendingAnalysisWatch[] => { - if (typeof window === "undefined") { - return []; - } - - try { - const raw = window.localStorage.getItem(PENDING_ANALYSIS_WATCHES_STORAGE_KEY); - if (!raw) { - return []; - } - - const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? parsed : []; - } catch (error) { - console.error("분석 완료 감시 목록을 읽는 중 오류가 발생했습니다.", error); - return []; - } -}; - -const savePendingAnalysisWatches = (items: PendingAnalysisWatch[]) => { - if (typeof window === "undefined") { - return; - } - - if (items.length === 0) { - window.localStorage.removeItem(PENDING_ANALYSIS_WATCHES_STORAGE_KEY); - return; - } - - window.localStorage.setItem(PENDING_ANALYSIS_WATCHES_STORAGE_KEY, JSON.stringify(items)); -}; - -const createWatchKey = (statusUrl: string, analysisSn: number) => `${statusUrl}::${analysisSn}`; - -export function useAnalysisCompletionWatcher({ - buildStatusUrl, - successMessage, - failureMessage, -}: UseAnalysisCompletionWatcherParams) { - const startWatching = (analysisSn: number) => { - if (typeof window === "undefined" || !Number.isFinite(analysisSn) || analysisSn <= 0) { - return; - } - - const statusUrl = buildStatusUrl(analysisSn); - const nextItem: PendingAnalysisWatch = { - analysisSn, - failureMessage, - key: createWatchKey(statusUrl, analysisSn), - statusUrl, - successMessage, - }; - - const nextItems = [ - ...loadPendingAnalysisWatches().filter((item) => item.key !== nextItem.key), - nextItem, - ]; - - savePendingAnalysisWatches(nextItems); - }; - - const stopWatching = (analysisSn?: number) => { - if (typeof window === "undefined") { - return; - } - - if (!analysisSn) { - savePendingAnalysisWatches([]); - return; - } - - const nextItems = loadPendingAnalysisWatches().filter((item) => item.analysisSn !== analysisSn); - savePendingAnalysisWatches(nextItems); - }; - - return { - startWatching, - stopWatching, - }; -} - -export function GlobalAnalysisCompletionWatcher() { - useEffect(() => { - let intervalId: number | null = null; - let isPolling = false; - - const pollPendingAnalysis = async () => { - if (isPolling || typeof window === "undefined") { - return; - } - - if (document.visibilityState !== "visible") { - return; - } - - const currentItems = loadPendingAnalysisWatches(); - if (currentItems.length === 0) { - return; - } - - isPolling = true; - - try { - for (const item of currentItems) { - const response = (await getData(item.statusUrl)) as AnalysisStatusResponse | null; - if (!response) { - continue; - } - - const scsYn = String(response.scsYn ?? "").trim().toUpperCase(); - const message = String(response.message ?? "").trim(); - - if (scsYn !== "Y" && scsYn !== "N") { - continue; - } - - const latestItems = loadPendingAnalysisWatches(); - const exists = latestItems.some((latestItem) => latestItem.key === item.key); - if (!exists) { - continue; - } - - savePendingAnalysisWatches(latestItems.filter((latestItem) => latestItem.key !== item.key)); - alert(message || (scsYn === "Y" ? item.successMessage : item.failureMessage)); - } - } catch (error) { - console.error("분석 완료 상태 조회 중 오류가 발생했습니다.", error); - } finally { - isPolling = false; - } - }; - - const handleStorageOrVisibility = () => { - void pollPendingAnalysis(); - }; - - intervalId = window.setInterval(() => { - void pollPendingAnalysis(); - }, DEFAULT_POLLING_INTERVAL_MS); - - window.addEventListener("storage", handleStorageOrVisibility); - document.addEventListener("visibilitychange", handleStorageOrVisibility); - void pollPendingAnalysis(); - - return () => { - if (intervalId !== null) { - window.clearInterval(intervalId); - } - window.removeEventListener("storage", handleStorageOrVisibility); - document.removeEventListener("visibilitychange", handleStorageOrVisibility); - }; - }, []); - - return null; -} \ No newline at end of file diff --git a/apps/sn/app/(with-base-layout)/ii/useAnalysisCompletionWatcher.tsx b/apps/sn/app/(with-base-layout)/ii/useAnalysisCompletionWatcher.tsx new file mode 100644 index 000000000..970675d23 --- /dev/null +++ b/apps/sn/app/(with-base-layout)/ii/useAnalysisCompletionWatcher.tsx @@ -0,0 +1,438 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { usePathname } from "next/navigation"; + +import { getRawJson } from "@/components/lib/api"; + +type AnalysisStatusResponse = { + scsYn?: string | null; + message?: string | null; + progressPercent?: number | null; + progressStep?: string | null; + progressMessage?: string | null; + updatedAt?: string | null; +}; + +type AnalysisStatusEnvelope = { + status?: number; + message?: string | null; + data?: AnalysisStatusResponse | null; +}; + +const unwrapAnalysisStatusResponse = ( + response: AnalysisStatusResponse | AnalysisStatusEnvelope | null +): AnalysisStatusResponse | null => { + if (!response || typeof response !== "object") { + return null; + } + + const candidate = response as AnalysisStatusEnvelope; + if (candidate.data && typeof candidate.data === "object") { + return candidate.data; + } + + return response as AnalysisStatusResponse; +}; + +type UseAnalysisCompletionWatcherParams = { + buildStatusUrl: (analysisSn: number) => string; + successMessage: string; + failureMessage: string; + analysisType?: string; + analysisName?: string; + intervalMs?: number; + initialProgressPercent?: number; + initialProgressStep?: string; + initialProgressMessage?: string; +}; + +type PendingAnalysisWatch = { + analysisSn: number; + analysisType: string; + analysisName: string; + failureMessage: string; + key: string; + progressMessage?: string; + progressPercent?: number; + progressStep?: string; + statusUrl: string; + successMessage: string; + updatedAt?: string | null; +}; + +const DEFAULT_ANALYSIS_TYPE = "GENERIC"; +const DEFAULT_ANALYSIS_NAME = "분석 진행"; +const DEFAULT_POLLING_INTERVAL_MS = 3000; +const DEFAULT_PROGRESS_MESSAGE = "진행 상태를 확인하는 중입니다."; +const INFTWT_ANALYSIS_TYPE = "INFTWT"; +const PENDING_ANALYSIS_WATCHES_STORAGE_KEY = "SN_PENDING_ANALYSIS_WATCHES"; +const PENDING_ANALYSIS_WATCHES_CHANGED_EVENT = "sn-pending-analysis-watches-changed"; + +const clampProgressPercent = (value?: number | null) => { + if (!Number.isFinite(value)) { + return undefined; + } + + return Math.max(0, Math.min(100, Number(value))); +}; + +const createWatchKey = (statusUrl: string, analysisSn: number) => `${statusUrl}::${analysisSn}`; + +const dispatchPendingAnalysisChanged = () => { + if (typeof window === "undefined") { + return; + } + + window.dispatchEvent(new Event(PENDING_ANALYSIS_WATCHES_CHANGED_EVENT)); +}; + +const normalizePendingAnalysisWatch = (raw: unknown): PendingAnalysisWatch | null => { + if (!raw || typeof raw !== "object") { + return null; + } + + const candidate = raw as Record; + const analysisSn = Number(candidate.analysisSn ?? 0); + const statusUrl = String(candidate.statusUrl ?? "").trim(); + if (!Number.isFinite(analysisSn) || analysisSn <= 0 || !statusUrl) { + return null; + } + + const progressPercent = clampProgressPercent( + typeof candidate.progressPercent === "number" + ? candidate.progressPercent + : Number(candidate.progressPercent) + ); + + return { + analysisSn, + analysisType: + String(candidate.analysisType ?? DEFAULT_ANALYSIS_TYPE).trim() || + DEFAULT_ANALYSIS_TYPE, + analysisName: + String(candidate.analysisName ?? DEFAULT_ANALYSIS_NAME).trim() || + DEFAULT_ANALYSIS_NAME, + failureMessage: + String(candidate.failureMessage ?? "분석이 실패했습니다.").trim() || + "분석이 실패했습니다.", + key: + String(candidate.key ?? createWatchKey(statusUrl, analysisSn)).trim() || + createWatchKey(statusUrl, analysisSn), + progressMessage: + String(candidate.progressMessage ?? "").trim() || undefined, + progressPercent, + progressStep: String(candidate.progressStep ?? "").trim() || undefined, + statusUrl, + successMessage: + String(candidate.successMessage ?? "분석이 완료되었습니다.").trim() || + "분석이 완료되었습니다.", + updatedAt: + candidate.updatedAt == null + ? undefined + : String(candidate.updatedAt).trim() || undefined, + }; +}; + +const loadPendingAnalysisWatches = (): PendingAnalysisWatch[] => { + if (typeof window === "undefined") { + return []; + } + + try { + const raw = window.localStorage.getItem(PENDING_ANALYSIS_WATCHES_STORAGE_KEY); + if (!raw) { + return []; + } + + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return []; + } + + return parsed + .map(normalizePendingAnalysisWatch) + .filter((item): item is PendingAnalysisWatch => item !== null); + } catch (error) { + console.error("분석 완료 감시 목록을 읽는 중 오류가 발생했습니다.", error); + return []; + } +}; + +const savePendingAnalysisWatches = (items: PendingAnalysisWatch[]) => { + if (typeof window === "undefined") { + return; + } + + if (items.length === 0) { + window.localStorage.removeItem(PENDING_ANALYSIS_WATCHES_STORAGE_KEY); + dispatchPendingAnalysisChanged(); + return; + } + + window.localStorage.setItem( + PENDING_ANALYSIS_WATCHES_STORAGE_KEY, + JSON.stringify(items) + ); + dispatchPendingAnalysisChanged(); +}; + +const replacePendingAnalysisWatch = ( + items: PendingAnalysisWatch[], + nextItem: PendingAnalysisWatch +): PendingAnalysisWatch[] => { + const filteredItems = items.filter((item) => item.key !== nextItem.key); + return [...filteredItems, nextItem]; +}; + +const toProgressText = (item: PendingAnalysisWatch) => { + return ( + item.progressMessage?.trim() || + item.progressStep?.trim() || + DEFAULT_PROGRESS_MESSAGE + ); +}; + +const toOverlayItem = (items: PendingAnalysisWatch[]) => { + for (let index = items.length - 1; index >= 0; index -= 1) { + const item = items[index]; + if (!item) { + continue; + } + + if (item.analysisType !== INFTWT_ANALYSIS_TYPE) { + continue; + } + + if (typeof item.progressPercent !== "number") { + continue; + } + + return item; + } + + return null; +}; + +const AnalysisProgressOverlay = ({ item }: { item: PendingAnalysisWatch }) => { + const progressPercent = clampProgressPercent(item.progressPercent) ?? 0; + const progressText = toProgressText(item); + + return ( +
+
+

+ {item.analysisName} +

+ + {progressPercent}% + +
+
+
+
+

{progressText}

+
+ ); +}; + +export function useAnalysisCompletionWatcher({ + buildStatusUrl, + successMessage, + failureMessage, + analysisType = DEFAULT_ANALYSIS_TYPE, + analysisName = DEFAULT_ANALYSIS_NAME, + initialProgressPercent, + initialProgressStep, + initialProgressMessage, +}: UseAnalysisCompletionWatcherParams) { + const startWatching = (analysisSn: number) => { + if ( + typeof window === "undefined" || + !Number.isFinite(analysisSn) || + analysisSn <= 0 + ) { + return; + } + + const statusUrl = buildStatusUrl(analysisSn); + const nextItem: PendingAnalysisWatch = { + analysisSn, + analysisType, + analysisName, + failureMessage, + key: createWatchKey(statusUrl, analysisSn), + progressMessage: initialProgressMessage, + progressPercent: clampProgressPercent(initialProgressPercent), + progressStep: initialProgressStep, + statusUrl, + successMessage, + updatedAt: undefined, + }; + + const nextItems = replacePendingAnalysisWatch( + loadPendingAnalysisWatches(), + nextItem + ); + savePendingAnalysisWatches(nextItems); + }; + + const stopWatching = (analysisSn?: number) => { + if (typeof window === "undefined") { + return; + } + + if (!analysisSn) { + savePendingAnalysisWatches([]); + return; + } + + const nextItems = loadPendingAnalysisWatches().filter( + (item) => item.analysisSn !== analysisSn + ); + savePendingAnalysisWatches(nextItems); + }; + + return { + startWatching, + stopWatching, + }; +} + +export function GlobalAnalysisCompletionWatcher() { + const pathname = usePathname(); + // 전역 overlay와 완료 alert가 같은 storage 소스를 바라보도록 local state로 동기화한다. + const [pendingItems, setPendingItems] = useState([]); + + const activeOverlayItem = useMemo(() => toOverlayItem(pendingItems), [pendingItems]); + const shouldHideOverlay = pathname?.includes("/popup/") ?? false; + + useEffect(() => { + if (shouldHideOverlay) { + setPendingItems([]); + return; + } + let intervalId: number | null = null; + let isPolling = false; + + const syncPendingItems = () => { + setPendingItems(loadPendingAnalysisWatches()); + }; + + const pollPendingAnalysis = async () => { + if (isPolling || typeof window === "undefined") { + return; + } + + if (document.visibilityState !== "visible") { + syncPendingItems(); + return; + } + + const currentItems = loadPendingAnalysisWatches(); + syncPendingItems(); + if (currentItems.length === 0) { + return; + } + + isPolling = true; + + try { + for (const item of currentItems) { + const pollUrl = `${item.statusUrl}${item.statusUrl.includes("?") ? "&" : "?"}_ts=${Date.now()}`; + const rawResponse = (await getRawJson(pollUrl, { cache: "no-store" })) as AnalysisStatusResponse | AnalysisStatusEnvelope | null; + const response = unwrapAnalysisStatusResponse(rawResponse); + if (!response) { + continue; + } + + const scsYn = String(response.scsYn ?? "").trim().toUpperCase(); + const message = String(response.message ?? "").trim(); + const latestItems = loadPendingAnalysisWatches(); + const latestItem = latestItems.find( + (latestWatch) => latestWatch.key === item.key + ); + if (!latestItem) { + continue; + } + + const updatedItem: PendingAnalysisWatch = { + ...latestItem, + progressMessage: + String( + response.progressMessage ?? latestItem.progressMessage ?? "" + ).trim() || latestItem.progressMessage, + progressPercent: + clampProgressPercent( + typeof response.progressPercent === "number" + ? response.progressPercent + : Number(response.progressPercent) + ) ?? latestItem.progressPercent, + progressStep: + String(response.progressStep ?? latestItem.progressStep ?? "").trim() || + latestItem.progressStep, + updatedAt: + String(response.updatedAt ?? latestItem.updatedAt ?? "").trim() || + latestItem.updatedAt, + }; + + if (scsYn !== "Y" && scsYn !== "N") { + savePendingAnalysisWatches( + replacePendingAnalysisWatch(latestItems, updatedItem) + ); + continue; + } + + savePendingAnalysisWatches( + latestItems.filter((latestWatch) => latestWatch.key !== item.key) + ); + alert(message || (scsYn === "Y" ? item.successMessage : item.failureMessage)); + } + } catch (error) { + console.error("분석 완료 상태 조회 중 오류가 발생했습니다.", error); + } finally { + isPolling = false; + syncPendingItems(); + } + }; + + const handlePendingAnalysisChanged = () => { + syncPendingItems(); + void pollPendingAnalysis(); + }; + + intervalId = window.setInterval(() => { + void pollPendingAnalysis(); + }, DEFAULT_POLLING_INTERVAL_MS); + + window.addEventListener("storage", handlePendingAnalysisChanged); + window.addEventListener( + PENDING_ANALYSIS_WATCHES_CHANGED_EVENT, + handlePendingAnalysisChanged + ); + document.addEventListener("visibilitychange", handlePendingAnalysisChanged); + syncPendingItems(); + void pollPendingAnalysis(); + + return () => { + if (intervalId !== null) { + window.clearInterval(intervalId); + } + window.removeEventListener("storage", handlePendingAnalysisChanged); + window.removeEventListener( + PENDING_ANALYSIS_WATCHES_CHANGED_EVENT, + handlePendingAnalysisChanged + ); + document.removeEventListener("visibilitychange", handlePendingAnalysisChanged); + }; + }, [shouldHideOverlay]); + + if (shouldHideOverlay) { + return null; + } + + return activeOverlayItem ? : null; +} \ No newline at end of file diff --git a/apps/sn/app/(with-base-layout)/layout.tsx b/apps/sn/app/(with-base-layout)/layout.tsx index fa2527da1..c51de9039 100644 --- a/apps/sn/app/(with-base-layout)/layout.tsx +++ b/apps/sn/app/(with-base-layout)/layout.tsx @@ -13,14 +13,13 @@ */ "use client"; -import React, { useEffect} from 'react'; +import React, { useEffect } from 'react'; import { usePathname, useRouter } from "next/navigation"; import { useQueryClient } from "@tanstack/react-query"; import { BaseLayout } from "@common/components"; import type { MenuItem } from "@/entities/menu"; import { useMenuStore } from "@/shared/lib"; import { PageLayout } from "@/shared/ui/page-layout"; -import { GlobalAnalysisCompletionWatcher } from './ii/useAnalysisCompletionWatcher'; import useStore from '../store/useStore'; export default function WithBaseLayout({ @@ -49,15 +48,12 @@ export default function WithBaseLayout({ }; return ( - <> - - - {children} - - + + {children} + ); } \ No newline at end of file diff --git a/apps/sn/docs/작업계획/SN-침입수분석결과-지도연계유지보수/001-fe-SN-침입수분석결과-지도연계유지보수-작업카드.md b/apps/sn/docs/작업계획/SN-침입수분석결과-지도연계유지보수/001-fe-SN-침입수분석결과-지도연계유지보수-작업카드.md index 2b1352fba..d0cd924e7 100644 --- a/apps/sn/docs/작업계획/SN-침입수분석결과-지도연계유지보수/001-fe-SN-침입수분석결과-지도연계유지보수-작업카드.md +++ b/apps/sn/docs/작업계획/SN-침입수분석결과-지도연계유지보수/001-fe-SN-침입수분석결과-지도연계유지보수-작업카드.md @@ -142,3 +142,63 @@ - 버튼 클릭 시 분석 화면을 닫지 않고, 별도 `/map` 창과 `침입수 분석결과` popup을 함께 열어 기존 지도형 결과 흐름으로 바로 진입시킨다. - popup row select는 기존 `window.opener.postMessage`를 유지하되, opener가 분석 화면일 수 있으므로 storage 이벤트 기반 보조 동기화를 함께 둔다. - `map` 화면은 `analysisPopupKey` query를 받아 `분석도구` 활성 메뉴 상태를 `침입수 분석결과` 기준으로 맞춘다. + +## 15. 2026-07-10 전역 progress watcher / overlay 설계 + +- 현재 FE는 `ii/useAnalysisCompletionWatcher.ts`에서 완료 alert용 polling만 담당하므로, 같은 파일 또는 인접 컴포넌트로 역할을 아래처럼 확장한다. + - 분석 요청 등록 + - 상태 polling 갱신 + - 완료 alert 처리 + - 전역 progress overlay 렌더용 상태 제공 +- 실제 전역 mount는 `FE_APP_ROOT/src/app/providers/app-provider.tsx`에만 둔다. +- 구현 단계에서는 `FE_APP_ROOT/app/(with-base-layout)/layout.tsx`의 중복 `GlobalAnalysisCompletionWatcher` mount를 제거해 polling 2중 실행을 막는다. + +### 15.1 수정 대상 파일 보정 + +| 구분 | 파일 경로 | 수정 예정 | 메모 | +| --- | --- | --- | --- | +| 전역 provider | `FE_APP_ROOT/src/app/providers/app-provider.tsx` | 예정 | watcher / progress overlay 단일 mount | +| 레이아웃 정리 | `FE_APP_ROOT/app/(with-base-layout)/layout.tsx` | 예정 | 중복 watcher 제거 또는 전역 mount 정리 | +| 상태 watcher | `FE_APP_ROOT/app/(with-base-layout)/ii/useAnalysisCompletionWatcher.ts` | 예정 | storage schema, progress polling, 완료/실패 종료 처리 확장 | +| 침입수 실행 화면 | `FE_APP_ROOT/app/(with-base-layout)/ii/irsetting/page.tsx` | 예정 | 접수 후 progress 감시 등록, full-screen 로딩 즉시 해제 유지 | +| 전역 overlay | `FE_APP_ROOT/app/(with-base-layout)/ii/**` 또는 `FE_APP_ROOT/src/app/providers/**` | 예정 | 좌측 하단 progress UI 신규 컴포넌트 | + +### 15.2 FE 상태 모델 + +- 기존 `SN_PENDING_ANALYSIS_WATCHES` storage key는 유지하되 item schema를 확장한다. +- 1차 침입수 분석용 item 초안 + - `analysisType: "INFTWT"` + - `analysisSn` + - `analysisName` + - `statusUrl` + - `progressPercent` + - `progressStep` + - `progressMessage` + - `updatedAt` + - `successMessage` + - `failureMessage` +- 추후 RDII 복제를 위해 `analysisType` 필드는 미리 포함한다. + +### 15.3 사용자 표시 규칙 + +- 접수 직후 alert 문구는 기존 사용자 요구대로 `분석 데이터가 성공적으로 제출되었습니다.`를 유지한다. +- 중앙 로딩 overlay는 접수 응답 직후 바로 닫는다. +- 전역 progress overlay는 좌측 하단에 고정하고 아래만 표시한다. + - 분석명 + - 진행률 바 + - 현재 단계 1줄 문구 +- 완료 후에는 overlay 안에서 별도 성공 메시지를 띄우지 않고 기존 alert만 사용한다. + +### 15.4 polling / 렌더링 규칙 + +- polling 주기는 현재 3초를 유지하되, status 응답이 바뀔 때마다 storage를 갱신해 overlay가 같은 데이터를 읽게 한다. +- `scsYn`이 `Y` 또는 `N`이면 해당 watch item을 제거하고 alert를 띄운다. +- 진행 중(`P`)이면 overlay만 갱신한다. +- `/map`, `ii/result`, `ii/irsetting`, 기타 `with-base-layout` 페이지에서 같은 storage를 읽으므로 페이지 이동과 무관하게 overlay가 유지된다. + +### 15.5 검증 포인트 보강 + +- `침입수분석 실행 -> 접수 alert -> 중앙 로딩 종료 -> 좌측 하단 progress 표시` +- `ii/irsetting -> ii/result -> /map` 이동 중 overlay 유지 +- 완료 시점 alert 수신 후 overlay 제거 +- 새로고침 또는 새 창 진입 시 storage에 남아 있는 진행 건을 다시 복구할지 여부는 구현 단계에서 확인하되, 1차는 같은 브라우저 세션 내 유지에 집중한다. diff --git a/apps/sn/docs/작업내용/SN-침입수분석결과-지도연계유지보수/001-fe-SN-침입수분석결과-지도연계유지보수-통합구현기록.md b/apps/sn/docs/작업내용/SN-침입수분석결과-지도연계유지보수/001-fe-SN-침입수분석결과-지도연계유지보수-통합구현기록.md index 6aba0224e..f1228deca 100644 --- a/apps/sn/docs/작업내용/SN-침입수분석결과-지도연계유지보수/001-fe-SN-침입수분석결과-지도연계유지보수-통합구현기록.md +++ b/apps/sn/docs/작업내용/SN-침입수분석결과-지도연계유지보수/001-fe-SN-침입수분석결과-지도연계유지보수-통합구현기록.md @@ -136,4 +136,38 @@ - 제출 성공 시 문구를 `분석 요청이 접수되었습니다. 완료 시 안내됩니다.`로 바꾸고, 응답으로 받은 `inftwtAnlsSn` 기준으로 상태 감시를 시작한다. - 신규 hook `ii/useAnalysisCompletionWatcher.ts`를 추가해 `GET /api/v1/inftwtAnls/{inftwtAnlsSn}/status` polling과 `Y/N` 종료 alert를 공통 처리한다. - 상태 추적 번호가 없으면 즉시 사용자에게 후속 결과목록 확인 안내를 띄운다. -- FE 검증은 `node node_modules\typescript\bin\tsc --noEmit --ignoreDeprecations 5.0 -p tsconfig.json`까지 수행했고, 이번 변경 파일이 아닌 기존 선행 오류만 남았다. \ No newline at end of file +- FE 검증은 `node node_modules\typescript\bin\tsc --noEmit --ignoreDeprecations 5.0 -p tsconfig.json`까지 수행했고, 이번 변경 파일이 아닌 기존 선행 오류만 남았다. + +## 21. 2026-07-10 침입수 분석 전역 progress overlay 구현 + +- `ii/useAnalysisCompletionWatcher.tsx`로 파일을 옮기고, 기존 완료 alert watcher를 `localStorage` 기반 전역 progress watcher + 좌측 하단 overlay 렌더 구조로 확장했다. +- `SN_PENDING_ANALYSIS_WATCHES` item에는 `analysisType`, `analysisName`, `progressPercent`, `progressStep`, `progressMessage`, `updatedAt`를 함께 저장해 페이지 이동 뒤에도 같은 진행 상태를 다시 그릴 수 있게 했다. +- 전역 watcher mount는 `src/app/providers/app-provider.tsx` 하나로 유지하고, `with-base-layout/layout.tsx`의 중복 mount는 제거해 같은 창에서 polling이 두 번 돌지 않도록 정리했다. +- `ii/irsetting/page.tsx`는 `analysisType=INFTWT`, 초기 `10% / REQUEST_ACCEPTED / 요청 접수 완료` 상태로 감시를 시작하면서도, 접수 alert 문구는 사용자 요구대로 `분석 데이터가 성공적으로 제출되었습니다.`를 그대로 유지한다. +- FE 검증은 `node node_modules\typescript\bin\tsc --noEmit --ignoreDeprecations 5.0 -p tsconfig.json`까지 수행했고, 이번 변경 파일 신규 오류는 제거되었으며 남은 실패는 기존 선행 오류만 확인됐다. + +## 22. 2026-07-13 프로그래스바 최종 표시 방식 정리 + +- 팝업창에서는 progress overlay를 숨기고 메인 화면에서만 보이도록 최종 정리했다. +- `status` polling 응답은 공용 wrapper `{ status, message, data }`를 해제해 실제 `data.progressPercent`를 읽도록 보강했다. +- 최종 화면 표시는 보간이 아니라 단계값 고정형으로 정리했다. + - `10%` 요청 접수 완료 + - `20%` 상세 데이터 저장 시작 + - `30%` 상세 데이터 저장 완료 + - `40%` SWMM 분석 실행 중 + - `80%` 결과 저장 중 + - `100%` 완료 +- 이 정리로 사용자는 오래 걸리는 두 구간을 `40%`, `80%`부터 보게 되어, 막판 `99%` 고정처럼 보이는 체감을 줄일 수 있다. + +## 23. 2026-07-13 프로그래스바 구현 마감 정리 + +- 전역 progress overlay는 메인 화면에서만 보이고 `/popup/` 경로에서는 표시하지 않도록 마감했다. +- 최종 표시 방식은 보간 없이 고정 단계값 기준으로 정리했다. + - `10%` 요청 접수 완료 + - `20%` 상세 데이터 저장 시작 + - `30%` 상세 데이터 저장 완료 + - `40%` SWMM 분석 실행 중 + - `80%` 결과 저장 중 + - `100%` 완료 +- `status` polling은 공용 wrapper `{ status, message, data }`를 해제해 실제 `data.progressPercent`를 읽도록 보강했고, `_ts` query와 `cache: no-store`를 적용해 캐시된 상태 응답이 재사용되지 않게 했다. +- 진행상태 카드 폭/색상/팝업 중복 노출도 함께 정리했다.