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 80b4b58cd..686ea2d91 100644 --- a/apps/sn/app/(with-base-layout)/ii/irsetting/page.tsx +++ b/apps/sn/app/(with-base-layout)/ii/irsetting/page.tsx @@ -28,6 +28,7 @@ import { useForm } from 'react-hook-form' import { z } from 'zod' import { excelToFlowRateData, excelToMatchingResultData, excelToTimePatternData, excelToRcptrInfoData } from '@/components/lib/utils' import BaseLayout from '../BaseLayout' +import { useAnalysisCompletionWatcher } from '../useAnalysisCompletionWatcher' import { openAnalysisResultMapView } from '../analysisResultView' import { validateMultipleFiles, FILE_EXTENSIONS, type FileValidationConfig } from '../../fileValidation'; import { validateBasicSelectionsWithAlert } from '../../selectionValidation'; @@ -94,6 +95,12 @@ const IrSetting = () => { enabled: true, }); + const { startWatching } = useAnalysisCompletionWatcher({ + buildStatusUrl: (analysisSn) => `v1/inftwtAnls/${analysisSn}/status`, + successMessage: '침입수 분석이 완료되었습니다.', + failureMessage: '침입수 분석이 실패했습니다. 로그를 확인해주세요.', + }); + const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: { @@ -159,16 +166,21 @@ const IrSetting = () => { const registFile = async (analysisData: any) => { - setIsLoadingResult(true); const data = await postMutiPart('v1/inftwtAnls/savewithfile', analysisData); - if (data) { - setIsLoadingResult(false); - alert("분석 데이터가 성공적으로 제출되었습니다."); - resetAllForms(); // 성공 시 폼 초기화 함수 호출 - } else { - setIsLoadingResult(false); - alert("분석 데이터 제출 중 오류가 발생했습니다."); + if (!data) { + throw new Error('분석 요청 접수 응답이 비어 있습니다.'); } + + const nextAnalysisSn = Number(data?.inftwtAnlsSn ?? 0); + setIsLoadingResult(false); + await new Promise((resolve) => setTimeout(resolve, 0)); + alert("분석 데이터가 성공적으로 제출되었습니다."); + + if (Number.isFinite(nextAnalysisSn) && nextAnalysisSn > 0) { + startWatching(nextAnalysisSn); + } + + resetAllForms(); }; // 침입수 분석 실행 버튼 클릭 핸들러 @@ -370,8 +382,10 @@ const IrSetting = () => { setIsLoadingResult(false); } catch (error) { setIsLoadingResult(false); - console.error("파일 파싱 중 오류 발생:", error); - alert("파일 처리 중 오류가 발생했습니다."); + console.error("침입수 분석 요청 처리 중 오류 발생:", error); + alert(error instanceof Error && error.message.trim() + ? error.message + : "파일 처리 중 오류가 발생했습니다."); return; } } diff --git a/apps/sn/app/(with-base-layout)/ii/useAnalysisCompletionWatcher.ts b/apps/sn/app/(with-base-layout)/ii/useAnalysisCompletionWatcher.ts new file mode 100644 index 000000000..8f28ee307 --- /dev/null +++ b/apps/sn/app/(with-base-layout)/ii/useAnalysisCompletionWatcher.ts @@ -0,0 +1,184 @@ +"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/docs/작업내용/SN-침입수분석결과-지도연계유지보수/001-fe-SN-침입수분석결과-지도연계유지보수-통합구현기록.md b/apps/sn/docs/작업내용/SN-침입수분석결과-지도연계유지보수/001-fe-SN-침입수분석결과-지도연계유지보수-통합구현기록.md index 5067d4fcc..6aba0224e 100644 --- a/apps/sn/docs/작업내용/SN-침입수분석결과-지도연계유지보수/001-fe-SN-침입수분석결과-지도연계유지보수-통합구현기록.md +++ b/apps/sn/docs/작업내용/SN-침입수분석결과-지도연계유지보수/001-fe-SN-침입수분석결과-지도연계유지보수-통합구현기록.md @@ -129,4 +129,11 @@ - `ii/irsetting`, `ii/rdii`의 기본 선택값을 `경기도 / 양평군 / 양평`으로 맞췄다. - 공통 `CtpvSggPrcsFcltSelectBox`는 mount 시 부모 값을 강제로 `0`으로 되돌리던 초기화 흐름을 제거하고, 시설 목록 로딩 후 `defaultSwtrmtFcltNm` 이름 기준으로 기본 시설을 자동 선택하게 보강했다. -- 이 보정으로 두 분석 화면 진입 직후 `하수처리시설명` select도 `양평` 기준으로 바로 보이도록 정리했다. +- 이 보정으로 두 분석 화면 진입 직후 `하수처리시설명` select도 `양평` 기준으로 바로 보이도록 정리했다. +## 20. 2026-07-09 침입수 분석 완료 alert 구현 + +- `ii/irsetting/page.tsx`는 더 이상 `savewithfile` 즉시 응답을 `분석 완료`로 간주하지 않는다. +- 제출 성공 시 문구를 `분석 요청이 접수되었습니다. 완료 시 안내됩니다.`로 바꾸고, 응답으로 받은 `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 diff --git a/apps/sn/src/app/providers/app-provider.tsx b/apps/sn/src/app/providers/app-provider.tsx index 81821a635..654acb9c4 100644 --- a/apps/sn/src/app/providers/app-provider.tsx +++ b/apps/sn/src/app/providers/app-provider.tsx @@ -11,7 +11,7 @@ import { PromptProvider, StoreProvider } from "@/shared/providers"; import { AlertDialog } from "@/shared/ui/alert-dialog"; import { PromptDialog } from "@/shared/ui/prompt-dialog"; import { ConfirmDialog } from "@/shared/ui/confirm-dialog"; - +import { GlobalAnalysisCompletionWatcher } from "../../../app/(with-base-layout)/ii/useAnalysisCompletionWatcher"; const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => { const queryClient = new QueryClient(); @@ -29,6 +29,7 @@ const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => { {children} + @@ -43,4 +44,4 @@ const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => { ); }; -export { AppProvider }; +export { AppProvider }; \ No newline at end of file