침입수 즉시 제출 alert 복구 및 전역 완료 alert 감시 추가

This commit is contained in:
kiminchan
2026-07-09 17:30:21 +09:00
parent 4181257afd
commit a9c38b356a
4 changed files with 219 additions and 13 deletions
@@ -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<z.infer<typeof formSchema>>({
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;
}
}
@@ -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;
}
@@ -129,4 +129,11 @@
## 19. 2026-07-09 분석 대상지역 기본값 보정
- `ii/irsetting`, `ii/rdii`의 기본 선택값을 `경기도 / 양평군 / 양평`으로 맞췄다.
- 공통 `CtpvSggPrcsFcltSelectBox`는 mount 시 부모 값을 강제로 `0`으로 되돌리던 초기화 흐름을 제거하고, 시설 목록 로딩 후 `defaultSwtrmtFcltNm` 이름 기준으로 기본 시설을 자동 선택하게 보강했다.
- 공통 `CtpvSggPrcsFcltSelectBox`는 mount 시 부모 값을 강제로 `0`으로 되돌리던 초기화 흐름을 제거하고, 시설 목록 로딩 후 `defaultSwtrmtFcltNm` 이름 기준으로 기본 시설을 자동 선택하게 보강했다.
- 이 보정으로 두 분석 화면 진입 직후 `하수처리시설명` 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를 공통 처리한다.
- 상태 추적 번호가 없으면 즉시 사용자에게 후속 결과목록 확인 안내를 띄운다.
+3 -2
View File
@@ -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}
</DndProvider>
</MenuWrapper>
<GlobalAnalysisCompletionWatcher />
<AlertDialog />
<PromptDialog />
<ConfirmDialog />
@@ -43,4 +44,4 @@ const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => {
);
};
export { AppProvider };
export { AppProvider };