침입수·RDII 분석 화면 결과보기 버튼 및 지도결과 popup 연계

This commit is contained in:
kiminchan
2026-07-09 10:45:53 +09:00
parent 34811d4571
commit 6c84ba6aeb
15 changed files with 169 additions and 8 deletions
@@ -20,6 +20,7 @@ type Props = {
onToggleGisEdit?: () => void;
isGisEditActive?: boolean;
isInfoSearchActive?: boolean;
initialActiveKey?: WindowPopupKey | null;
};
type MenuIconProps = {
@@ -73,9 +74,10 @@ export default function AnalysisPopupMenu({
onToggleGisEdit,
isGisEditActive = false,
isInfoSearchActive = false,
initialActiveKey = null,
}: Props) {
const [isOpen, setIsOpen] = useState(true);
const [activeKey, setActiveKey] = useState<WindowPopupKey | "infoSearch" | null>(null);
const [activeKey, setActiveKey] = useState<WindowPopupKey | "infoSearch" | null>(initialActiveKey);
useEffect(() => {
if (!isGisEditActive && activeKey === "gisEditPopup") {
@@ -89,6 +91,11 @@ export default function AnalysisPopupMenu({
}
}, [activeKey, isInfoSearchActive]);
useEffect(() => {
if (!initialActiveKey) return;
setActiveKey((currentKey) => currentKey ?? initialActiveKey);
}, [initialActiveKey]);
return (
<div className="absolute right-[0.55rem] top-[20px] z-[45]">
<div className="relative">
@@ -12,6 +12,7 @@ import useStore from "@/store/useStore";
import {
INFTWT_RESULT_ROW_SELECT_MESSAGE,
INFTWT_RESULT_ROW_SELECT_STORAGE_KEY,
type InftwtResultRow,
} from "./inftwtResultTypes";
@@ -56,6 +57,20 @@ const DEFAULT_FILTERS: InftwtResultFilters = {
};
const postRowSelectMessage = (row: InftwtResultRow) => {
if (typeof window !== "undefined") {
try {
window.localStorage.setItem(
INFTWT_RESULT_ROW_SELECT_STORAGE_KEY,
JSON.stringify({
row,
timestamp: Date.now(),
}),
);
} catch (error) {
console.warn("침입수 분석결과 선택 동기화 저장에 실패했습니다.", error);
}
}
if (typeof window === "undefined" || !window.opener) return;
window.opener.postMessage(
{
@@ -2,6 +2,7 @@
export const INFTWT_RESULT_ROW_SELECT_MESSAGE = "SN_INFTWT_RESULT_ROW_SELECT";
export const INFTWT_RESULT_OVERLAY_TYPE = "INFTWT_RESULT";
export const INFTWT_RESULT_ROW_SELECT_STORAGE_KEY = "SN_INFTWT_RESULT_ROW_SELECT_STORAGE";
export type InftwtResultRow = {
rowNumber: number;
@@ -18,6 +18,7 @@ import { getSigunguCenter, moveMapToCenter, sidoCenter } from "@common/business_
import {
INFTWT_RESULT_OVERLAY_TYPE,
INFTWT_RESULT_ROW_SELECT_MESSAGE,
INFTWT_RESULT_ROW_SELECT_STORAGE_KEY,
type InftwtResultMapViewResponse,
type InftwtResultRow,
} from "./inftwtResultTypes";
@@ -410,8 +411,24 @@ export const useInftwtResultMap = ({
void handleRowSelect(event.data.row as InftwtResultRow);
};
const handleStorageEvent = (event: StorageEvent) => {
if (event.key !== INFTWT_RESULT_ROW_SELECT_STORAGE_KEY || !event.newValue) return;
try {
const payload = JSON.parse(event.newValue) as { row?: InftwtResultRow };
if (!payload?.row) return;
void handleRowSelect(payload.row);
} catch (error) {
console.warn("침입수 분석결과 storage 동기화 값을 해석하지 못했습니다.", error);
}
};
window.addEventListener("message", handlePopupMessage);
return () => window.removeEventListener("message", handlePopupMessage);
window.addEventListener("storage", handleStorageEvent);
return () => {
window.removeEventListener("message", handlePopupMessage);
window.removeEventListener("storage", handleStorageEvent);
};
}, [handleRowSelect]);
useEffect(() => {
@@ -12,6 +12,7 @@ import useStore from "@/store/useStore";
import {
RDII_RESULT_ROW_SELECT_MESSAGE,
RDII_RESULT_ROW_SELECT_STORAGE_KEY,
type RdiiResultRow,
} from "./rdiiResultTypes";
@@ -58,6 +59,20 @@ const DEFAULT_FILTERS: RdiiResultFilters = {
};
const postRowSelectMessage = (row: RdiiResultRow) => {
if (typeof window !== "undefined") {
try {
window.localStorage.setItem(
RDII_RESULT_ROW_SELECT_STORAGE_KEY,
JSON.stringify({
row,
timestamp: Date.now(),
}),
);
} catch (error) {
console.warn("RDII 분석결과 선택 동기화 저장에 실패했습니다.", error);
}
}
if (typeof window === "undefined" || !window.opener) return;
window.opener.postMessage(
{
@@ -2,6 +2,7 @@
export const RDII_RESULT_ROW_SELECT_MESSAGE = "SN_RDII_RESULT_ROW_SELECT";
export const RDII_RESULT_OVERLAY_TYPE = "RDII_RESULT";
export const RDII_RESULT_ROW_SELECT_STORAGE_KEY = "SN_RDII_RESULT_ROW_SELECT_STORAGE";
export type RdiiResultRow = {
rowNumber: number;
@@ -16,6 +16,7 @@ import { getSigunguCenter, moveMapToCenter, sidoCenter } from "@common/business_
import {
RDII_RESULT_OVERLAY_TYPE,
RDII_RESULT_ROW_SELECT_MESSAGE,
RDII_RESULT_ROW_SELECT_STORAGE_KEY,
type RdiiResultMapViewResponse,
type RdiiResultRow,
} from "./rdiiResultTypes";
@@ -303,8 +304,24 @@ export const useRdiiResultMap = ({
void handleRowSelect(event.data.row as RdiiResultRow);
};
const handleStorageEvent = (event: StorageEvent) => {
if (event.key !== RDII_RESULT_ROW_SELECT_STORAGE_KEY || !event.newValue) return;
try {
const payload = JSON.parse(event.newValue) as { row?: RdiiResultRow };
if (!payload?.row) return;
void handleRowSelect(payload.row);
} catch (error) {
console.warn("RDII 분석결과 storage 동기화 값을 해석하지 못했습니다.", error);
}
};
window.addEventListener("message", handlePopupMessage);
return () => window.removeEventListener("message", handlePopupMessage);
window.addEventListener("storage", handleStorageEvent);
return () => {
window.removeEventListener("message", handlePopupMessage);
window.removeEventListener("storage", handleStorageEvent);
};
}, [handleRowSelect]);
useEffect(() => {
+10 -2
View File
@@ -19,7 +19,7 @@
import dynamic from "next/dynamic";
import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { usePathname } from "next/navigation";
import { usePathname, useSearchParams } from "next/navigation";
import Collection from "ol/Collection.js";
import Feature from "ol/Feature.js";
@@ -41,7 +41,7 @@ import Style from "ol/style/Style.js";
import Text from "ol/style/Text.js";
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
import type { WindowPopupKey } from "@/features/windowPopup/model/popupDefinitions";
import { isWindowPopupKey, type WindowPopupKey } from "@/features/windowPopup/model/popupDefinitions";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
import Gis from "@common/business_components/ui/map/Gis";
import GisApp from "@common/business_components/ui/map/lib/gis.core";
@@ -2401,7 +2401,14 @@ const appendSinkholeControlNode = (data: any[]) => {
};
const Map = () => {
const searchParams = useSearchParams();
const pathname = usePathname();
const initialAnalysisPopupKey = useMemo(() => {
const popupKey = searchParams.get("analysisPopupKey");
if (!popupKey || !isWindowPopupKey(popupKey)) return null;
if (popupKey !== "inftwtResultPopup" && popupKey !== "rdiiResultPopup") return null;
return popupKey;
}, [searchParams]);
const flowDirectionMode = useMemo(() => getFlowDirectionModeByPathname(pathname), [pathname]);
const { isSearchModalOpen, setIsSearchModalOpen } = useSearchModalStore();
const userInfo = useUserStore((state) => state.userInfo);
@@ -7080,6 +7087,7 @@ const Map = () => {
/>
) : null}
<AnalysisPopupMenu
initialActiveKey={initialAnalysisPopupKey}
onOpenPopup={openAnalysisPopup}
onOpenInfoSearch={() => setIsSearchModalOpen(true)}
onToggleGisEdit={handleToggleGisEdit}
@@ -49,6 +49,7 @@ type BaseLayout = {
onSubmitEvent: Function
graphBtn?: boolean
analysisBtn?: string
resultViewBtn?: string
firstDivBtn?: boolean
selectBox?: SelectBox
selectionComponent?: ReactNode
@@ -71,6 +72,7 @@ type BaseLayout = {
onExcelDownload?: () => void;
onDeleteFile?: () => void;
onAnalysis?: () => void;
onResultView?: () => void;
selectedRowData?: any;
isIr?: boolean;
isRdii?: boolean;
@@ -83,6 +85,7 @@ const BaseLayout = ({
onSubmitEvent,
graphBtn = false,
analysisBtn,
resultViewBtn,
selectBox,
selectionComponent,
showDatePicker = false,
@@ -104,6 +107,7 @@ const BaseLayout = ({
onExcelDownload,
onDeleteFile,
onAnalysis,
onResultView,
isIr = false,
isRdii = false,
}: BaseLayout) => {
@@ -251,6 +255,15 @@ const BaseLayout = ({
{analysisBtn && (
<Button className="px-4 py-2 bg-blue-600 text-white rounded-sm text-sm font-medium hover:bg-blue-700 !mr-[1.2rem]" size="sm" onClick={handleConfirm}>{analysisBtn}</Button>
)}
{resultViewBtn && (
<Button
className="px-4 py-2 bg-slate-600 text-white rounded-sm text-sm font-medium hover:bg-slate-700 !mr-[1.2rem]"
size="sm"
onClick={onResultView}
>
{resultViewBtn}
</Button>
)}
</div>
</div>
<Form {...form}>
@@ -0,0 +1,31 @@
"use client";
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
type AnalysisResultPopupKey = "inftwtResultPopup" | "rdiiResultPopup";
const ANALYSIS_RESULT_MAP_WINDOW_NAME = "sn-analysis-result-map";
export const openAnalysisResultMapView = (popupKey: AnalysisResultPopupKey) => {
if (typeof window === "undefined") return;
const mapUrl = `/map?analysisPopupKey=${encodeURIComponent(popupKey)}`;
const mapWindow = window.open(mapUrl, ANALYSIS_RESULT_MAP_WINDOW_NAME);
if (!mapWindow) {
alert("하수관망도 창이 차단되었습니다. 브라우저 팝업 허용 후 다시 시도해주세요.");
return;
}
// 분석 화면에서 직접 결과 popup을 열면 opener는 map이 아니라 현재 화면이 된다.
// map 창은 별도 탭으로 띄우고, 결과 popup의 row 선택은 storage 이벤트로 다시 map에 전달한다.
const popupWindow = openWindowPopup(popupKey);
mapWindow.focus();
if (!popupWindow) {
alert("분석결과 popup이 차단되었습니다. 브라우저 팝업 허용 후 다시 시도해주세요.");
return;
}
popupWindow.focus();
};
@@ -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 { openAnalysisResultMapView } from '../analysisResultView'
import { validateMultipleFiles, FILE_EXTENSIONS, type FileValidationConfig } from '../../fileValidation';
import { validateBasicSelectionsWithAlert } from '../../selectionValidation';
import { tokenManager } from '@/entities/auth/lib/token-manager';
@@ -377,6 +378,7 @@ const IrSetting = () => {
form={form}
onSubmitEvent={() => { }}
graphBtn={false}
resultViewBtn={'결과보기'}
analysisBtn={'침입수분석 실행'}
selectBox={{ title: '분석 대상지역 선택' }}
ctpvCd={ctpvCd}
@@ -389,6 +391,7 @@ const IrSetting = () => {
setSggName={setSggName}
setSwtrmtFcltNm={setSwtrmtFcltNm}
setIsLoading={setIsLoadingResult}
onResultView={() => openAnalysisResultMapView('inftwtResultPopup')}
onAnalysis={handleAnalysisClick}
isIr={true}
>
@@ -22,6 +22,7 @@ import { postMutiPart } from '@/components/lib/api'
import FileUploadForm from "@/components/ui/fileUploadForm"
import FormSection from "@/components/ui/formSection"
import BaseLayout from "../BaseLayout"
import { openAnalysisResultMapView } from "../analysisResultView"
import { DateMonthPicker, filePicked } from "@common/business_components/ui"
import { Input } from "@common/components/ui"
import { zodResolver } from "@hookform/resolvers/zod"
@@ -413,6 +414,7 @@ export default function RTKForm() {
form={form} // react-hook-form 인스턴스 전달
onSubmitEvent={() => { }} // BaseLayout의 form 태그에 연결될 onSubmit
graphBtn={false}
resultViewBtn={'결과보기'}
analysisBtn={'RDII분석 실행'}
ctpvCd={ctpvCd}
sggCd={sggCd}
@@ -424,6 +426,7 @@ export default function RTKForm() {
setSggName={setSggName}
setSwtrmtFcltNm={setSwtrmtFcltNm}
setIsLoading={setIsLoadingResult}
onResultView={() => openAnalysisResultMapView('rdiiResultPopup')}
onAnalysis={handleAnalysisClick}
isRdii={true}
>
@@ -136,3 +136,9 @@
- 새 selector는 시도/시군구 선택 뒤 `GET /api/v1/swagePrcsZones/selectionOptions`를 호출해 `하수처리구역명` 목록을 받고, 선택 시 `하수처리시설명`을 읽기 전용으로 자동 표시한다.
- `ii/rdii`, `ii/result`, 지도 검색 모달 등 기존 `CtpvSggPrcsFcltSelectBox` 사용처는 이번 범위에서 변경하지 않는다.
- 저장 payload는 기존과 동일하게 `swtrmtFcltNm`만 유지해 회귀 범위를 최소화한다.
## 14. 2026-07-09 분석 실행 화면 결과보기 유지보수
- `ii/irsetting` 우측 액션 영역에 `결과보기` 버튼을 추가한다.
- 버튼 클릭 시 분석 화면을 닫지 않고, 별도 `/map` 창과 `침입수 분석결과` popup을 함께 열어 기존 지도형 결과 흐름으로 바로 진입시킨다.
- popup row select는 기존 `window.opener.postMessage`를 유지하되, opener가 분석 화면일 수 있으므로 storage 이벤트 기반 보조 동기화를 함께 둔다.
- `map` 화면은 `analysisPopupKey` query를 받아 `분석도구` 활성 메뉴 상태를 `침입수 분석결과` 기준으로 맞춘다.
@@ -44,3 +44,15 @@
## 8. 최종 확인 메모
- 사용자 실동작 기준으로 `분석도구 > RDII 분석` popup, row click 지도 이동, 관로 highlight, resultOnly popup 흐름이 정상 동작하는 것으로 마감한다.
- FE 전체 타입 오류는 이번 RDII 변경과 무관한 기존 선행 이슈만 남아 별도 정리 대상으로 유지한다.
## 9. 2026-07-09 분석 화면 결과보기 버튼 연계
- `ii/BaseLayout.tsx` 공통 액션 영역에 `결과보기` 버튼 슬롯을 추가하고, `ii/rdii/page.tsx`에서 `rdiiResultPopup` 전용 진입 함수를 연결했다.
- `ii/analysisResultView.ts`는 `/map?analysisPopupKey=rdiiResultPopup` 창과 RDII 결과 popup을 같은 사용자 클릭에서 함께 연다.
- `RdiiResultPopupPage` row click은 storage 이벤트용 payload를 함께 남기고, `useRdiiResultMap`은 storage listener를 추가해 opener가 map이 아닌 경우에도 highlight 흐름을 이어가게 했다.
- `map/page.tsx`와 `AnalysisPopupMenu.tsx`는 query 기준으로 `RDII 분석결과` 메뉴 활성 상태를 맞춘다.
- 같은 TypeScript 검증에서 이번 RDII 결과보기 버튼 연계 파일 기준 신규 오류는 확인되지 않았다.
## 10. 2026-07-09 버튼 순서 미세조정
- 사용자 요청에 따라 RDII 분석 화면 액션 버튼 순서를 `양식 받기 -> RDII분석 실행 -> 결과보기`로 조정했다.
- 변경은 공통 `ii/BaseLayout.tsx` 렌더 순서만 수정해 침입수 분석 화면과 동일한 배치 규칙을 사용하도록 맞췄다.
@@ -100,3 +100,15 @@
- 사용자 요청 기준으로 좌측 `I/I 분석` 메뉴의 `침입수 분석결과` 항목은 현재 화면에서 숨겼다.
- 숨김 방식은 공통 LNB 데이터 구조를 다시 건드리지 않고, `FE_APP_ROOT/src/app/styles/components.css`에서 해당 서브메뉴 항목만 표시하지 않도록 제한했다.
- 이 보정은 메뉴 노출만 감추는 수준이며, 기존 `ii/result` route와 화면 코드는 유지한다.
- 따라서 추후 필요 시 스타일 규칙만 제거하면 기존 업무형 결과 화면 진입선을 다시 살릴 수 있다.
## 15. 2026-07-09 분석 화면 결과보기 버튼 연계
- `ii/BaseLayout.tsx`에 `결과보기` 버튼 슬롯을 추가하고, `ii/irsetting/page.tsx`에서 `inftwtResultPopup` 전용 진입 함수를 연결했다.
- 새 helper `ii/analysisResultView.ts`는 사용자 클릭 1회에서 `/map?analysisPopupKey=inftwtResultPopup` 창과 결과 popup을 함께 열어 popup 차단 가능성을 줄인다.
- `InftwtResultPopupPage` row click은 기존 opener postMessage 외에 `localStorage`에도 선택 row를 기록하고, `useInftwtResultMap`은 storage 이벤트를 함께 구독해 map 창이 opener가 아니어도 지도 연계를 이어가게 했다.
- `map/page.tsx`와 `AnalysisPopupMenu.tsx`는 `analysisPopupKey`가 있을 때 `침입수 분석결과` 메뉴를 활성 상태로 보여 주도록 보강했다.
- `node node_modules\typescript\bin\tsc --noEmit --ignoreDeprecations 5.0 -p tsconfig.json` 재실행 결과, 이번 변경 파일 기준 신규 오류는 없고 기존 선행 오류만 남았다.
## 16. 2026-07-09 버튼 순서 미세조정
- 사용자 요청에 따라 침입수 분석 화면 액션 버튼 순서를 `양식 받기 -> 침입수분석 실행 -> 결과보기`로 조정했다.