모달창 윈도우 팝업 전면 수정

This commit is contained in:
kiminchan
2026-04-27 13:30:59 +09:00
parent f507afdfe9
commit 0b7a264f06
48 changed files with 1708 additions and 477 deletions
+1
View File
@@ -12,6 +12,7 @@ NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver
NEXT_PUBLIC_NGII_URL = https://map.ngii.go.kr
NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&version=1.0.0&format=image/png&layer=korean_map&style=korean&tilematrixset=korean&tilematrix={z}&tilecol={x}&tilerow={y}
NEXT_PUBLIC_SERVER_API_URL = http://dev.onasys.co.kr:8000
NEXT_PUBLIC_SEWERAGE_API_URL = http://localhost:8883
NEXT_PUBLIC_DC_URL = https://apigateway.apps.rsonesoft.com
# NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = /auth/login
@@ -13,7 +13,8 @@
* - 위경도로 강우시 미처리 하수 목록 조회: GET /api/v1/onsemiro/getSwgRainsByGeomety (sewerage-service)
* - 강우시 월류 하수 관리 모니터링 조회: GET /api/v1/onsemiro/swgRain/{adstDstrCd}/{manageNo}/mntrngs (sewerage-service)
*/
import { UiModal, UiTable } from '@common/business_components/ui';
'use client';
import { UiTable } from '@common/business_components/ui';
import { Button, TableHead, TableRow } from '@common/components/ui';
import React, { useState, useEffect } from 'react';
import { untrtSwgRainCol3, untrtSwgRainCol4, untrtSwgRainCol5 } from '../common/table';
@@ -21,21 +22,21 @@ import { VerticalTable } from '@/components/ui/verticalTable';
import { getRawJson } from "@/components/lib/api";
import { formatDisplayData } from '@/components/lib/utils/formatDisplayValue';
import { Label } from '@radix-ui/react-label';
import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup';
const inputColumn = [
{ id: "prrtymngYn", title: "중점여부", type: "select", options: ["우수토실", "중점우수토실"] },
{ id: "swtrmtZoneNm", title: "처리구역", type: "input" },
];
const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick }: {
const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick, renderMode = 'inline' }: {
featureData: any,
isMainSsc?: boolean,
chmbrXcnts?: string,
chmbrYdnts?: string,
onSaveClick?: () => void
onSaveClick?: () => void,
renderMode?: 'inline' | 'popup'
}) => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [isDataLoaded, setIsDataLoaded] = useState(false);
const [formData, setFormData] = useState<any>({});
const [rainMeasureData, setRainMeasureData] = useState<any>({}); // 강우, 측정 정보
const [mntrngsData, setMntrngsData] = useState<any[]>([]); // 수량/수질측정 정보
@@ -151,14 +152,15 @@ const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick }: {
}
}, [chmbrXcnts, chmbrYdnts]);
const openModal = () => {
setIsModalOpen(true);
};
const openDetailPopup = () => {
const popupWindow = openWindowPopup("sscDetail", {
chmbrXcnts,
chmbrYdnts,
sourcePath: window.location.pathname,
});
const closeModal = () => {
setIsModalOpen(false);
if (!isDataLoaded) {
setTimeout(() => setIsDataLoaded(true), 100);
if (!popupWindow) {
alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.');
}
};
@@ -292,33 +294,24 @@ const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick }: {
/>
)}
{isMainSsc && (
{isMainSsc && renderMode !== 'popup' && (
<div>
<div className="card-header">
<h3 className="text-2xl mx-3 font-medium text-label">중점우수토실 운영정보</h3>
</div>
<Button
className='mx-auto block w-64 mt-3 py-2 text-white rounded-sm text-sm font-medium hover:bg-blue-700 bg-blue-600'
onClick={openModal}
onClick={openDetailPopup}
>
상세보기
</Button>
</div>
)}
<UiModal
open={isModalOpen}
onOpenChange={setIsModalOpen}
bodyClass={`
h-[100vh]
w-[60vw]
max-w-none
max-h-none
overflow-y-auto
z-[101]
`}
body={renderModalBody()}
noHeaderYn='Y'
noFooterYn='Y'
/>
{isMainSsc && renderMode === 'popup' && (
<div className="min-h-dvh bg-white p-4">
<h1 className="mb-3 text-2xl font-semibold text-gray-900">중점우수토실 운영정보</h1>
{renderModalBody()}
</div>
)}
</div>
@@ -9,11 +9,10 @@
* - 조회 버튼을 통한 모달 표시 및 ReportTab 연동
*/
'use client'
import React, { useState } from 'react'
import React from 'react'
import { VerticalTable } from '@/components/ui/verticalTable';
import { UiModal } from '@common/business_components/ui';
import { Button } from '@common/components/ui';
import ReportTab from '../../searchModal/reportTab';
import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup';
// 하수처리시설
const headList = [
@@ -21,16 +20,26 @@ const headList = [
];
const SwtrmtFclt = ({ gisObject }: { gisObject: any }) => {
const [isModalOpen, setIsModalOpen] = useState(false);
const ctpvNm = gisObject?.ctpv_nm;
const sggNm = gisObject?.sgg_nm;
const handleOpenReportPopup = () => {
const popupWindow = openWindowPopup("reportTab", {
ctpvNm,
sggNm,
sourcePath: window.location.pathname,
});
if (!popupWindow) {
alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.');
}
};
return (
<div>
<VerticalTable
children={{
title: <Button className="gray" onClick={() => setIsModalOpen(true)}>조회</Button>
title: <Button className="gray" onClick={handleOpenReportPopup}>조회</Button>
}}
headName='디지털 자료관 연계정보'
tableData={[]}
@@ -43,31 +52,8 @@ const SwtrmtFclt = ({ gisObject }: { gisObject: any }) => {
throw new Error("Function not implemented.");
}}
/>
<UiModal
open={isModalOpen}
onOpenChange={setIsModalOpen}
headerTitle="하수도정비 기본계획보고서"
headerTitleClass="text-3xl font-semibold text-gray-700"
bodyClass={`
h-[100vh]
w-[60vw]
max-w-[60vw]
max-h-[100vh]
overflow-hidden
z-[101]
`}
body={
<ReportTab
open={isModalOpen}
isSearch={false}
ctpvNm={ctpvNm}
sggNm={sggNm}
/>
}
useDirectRender={true}
/>
</div >
)
}
export default SwtrmtFclt
export default SwtrmtFclt
@@ -7,14 +7,12 @@ import {
CHART_FORM,
type ChartConfig,
MainChart,
UiModal,
} from '@common/business_components';
import { SelectBox } from '@common/business_components/ui';
import { Button, Input, Label } from '@common/components';
import type { TInftwtAnls } from './aplcnYms';
import * as fn from './aplcnYms';
import { useInfilChartModalStore } from '../chartModalStore';
type ModalOpenProp = {
pplMngNo?: string; // 관로관리번호
@@ -28,7 +26,6 @@ type DataType = {
const INITIAL_ANALYSIS_OPTIONS = [{ val: 0, text: '선택' }];
const InfilChart = ({ pplMngNo = '' }: ModalOpenProp) => {
const { isOpen: isModalOpen, setIsOpen: setIsModalOpen } = useInfilChartModalStore();
const [selectedAplcnYm, setSelectedAplcnYm] = useState('');
const [inftwtAnlss, setInftwtAnlss] = useState<TInftwtAnls[]>([]);
@@ -66,12 +63,6 @@ const InfilChart = ({ pplMngNo = '' }: ModalOpenProp) => {
setInftwtQty('0');
};
useEffect(() => {
if (!isModalOpen) {
resetState(); // 모달이 닫힐 때 리셋
}
}, [isModalOpen]);
const handleAplcnYmChange = async (val: string | number) => {
const selectedValue = String(val);
@@ -276,21 +267,10 @@ const InfilChart = ({ pplMngNo = '' }: ModalOpenProp) => {
);
return (
<UiModal
open={isModalOpen}
onOpenChange={setIsModalOpen}
headerTitle=""
bodyClass={`
w-[78vw]
max-w-none
min-w-[1000px]
min-h-[72vh]
overflow-visible
z-[50]
`}
body={renderBody()}
noFooterYn="Y"
/>
<div className="min-h-dvh bg-white p-4">
<h1 className="mb-3 text-2xl font-semibold text-gray-900">침입수 분석</h1>
{renderBody()}
</div>
);
};
@@ -7,14 +7,12 @@ import {
CHART_FORM,
type ChartConfig,
MainChart,
UiModal,
} from '@common/business_components';
import { SelectBox } from '@common/business_components/ui';
import { Button, Input, Label } from '@common/components';
import type { TRdiiAnls } from './aplcnYms';
import * as fn from './aplcnYms';
import { useRdiiChartModalStore } from '../chartModalStore';
type ModalOpenProp = {
pplMngNo?: string; // 관로관리번호
@@ -29,7 +27,6 @@ type LineBarChartData = {
const INITIAL_ANALYSIS_OPTIONS = [{ val: 0, text: '선택' }];
const RdiiChart = ({ pplMngNo = '' }: ModalOpenProp) => {
const { isOpen: isModalOpen, setIsOpen: setIsModalOpen } = useRdiiChartModalStore();
const [aplcnYm, setAplcnYm] = useState('');
const [rdiiAnlss, setRdiiAnlss] = useState<TRdiiAnls[]>([]);
@@ -72,12 +69,6 @@ const RdiiChart = ({ pplMngNo = '' }: ModalOpenProp) => {
setTotalVolume('0');// 총체적 m³/mm 초기화
};
useEffect(() => {
if (!isModalOpen) {
resetState();
}
}, [isModalOpen]);
const handleAplcnYmChange = async (val: string | number) => {
const selectedValue = String(val);
@@ -336,21 +327,10 @@ const RdiiChart = ({ pplMngNo = '' }: ModalOpenProp) => {
);
return (
<UiModal
open={isModalOpen}
onOpenChange={setIsModalOpen}
headerTitle=""
bodyClass={`
w-[78vw]
max-w-none
min-w-[1100px]
min-h-[72vh]
overflow-visible
z-[50]
`}
body={renderBody()}
noFooterYn="Y"
/>
<div className="min-h-dvh bg-white p-4">
<h1 className="mb-3 text-2xl font-semibold text-gray-900">RDII 분석</h1>
{renderBody()}
</div>
);
};
@@ -1,16 +0,0 @@
import { create } from 'zustand';
interface ModalStore {
isOpen: boolean;
setIsOpen: (open: boolean) => void;
}
export const useInfilChartModalStore = create<ModalStore>((set) => ({
isOpen: false,
setIsOpen: (open: boolean) => set({ isOpen: open }),
}));
export const useRdiiChartModalStore = create<ModalStore>((set) => ({
isOpen: false,
setIsOpen: (open: boolean) => set({ isOpen: open }),
}));
@@ -23,7 +23,6 @@
*/
'use client'
import { getData, postData } from "@/components/lib/api";
import Modal from "@/components/ui/modal";
import { VerticalTable } from "@/components/ui/verticalTable";
import { useGisContext } from "@common/business_components/ui/map/Gis";
import { Button } from "@common/components/ui";
@@ -34,13 +33,11 @@ import Stroke from "ol/style/Stroke.js";
import CircleStyle from "ol/style/Circle.js";
import { getLayerContentComponent } from "../common";
import { SwtrmtFcltCol, SwtrmtFcltCol2, SwtrmtPrcsZoneCol, SwtrmtPrcsZoneCol2, untrtSwgRainCol1, untrtSwgRainCol2, MsSwagePplCol, MsSwagePplCol2, ExclnSewagePumpCol, ExclnSewagePumpCol2, DrtwSewagePumpCol, DrtwSewagePumpCol2, MsSwageMhlCol, MsSwageMhlCol2, stofcbrCol, stofcbrCol2, MS_SWAGE_MHL_CODES, DRTW_SEWAGE_PUMP_USE_CODES } from "../common/table";
import InfilChart from './chart/infilChart';
import RdiiChart from './chart/rdiiChart';
import { useInfilChartModalStore, useRdiiChartModalStore } from "./chartModalStore";
import { formatDisplayData } from '@/components/lib/utils/formatDisplayValue';
import { codeMapper } from "../common/codeMapper";
import LoadingOverlay from '@/components/ui/LoadingBar';
import { searchUpperPpls } from "./smh";
import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup';
const linkedFacilityHighlightStyle = new Style({
image: new CircleStyle({
@@ -74,12 +71,9 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
const [buttonChk, setButtonChk] = useState(true)
const [fetchData, setFetchData] = useState<any>(); // api 호출여부
const contentRef = useRef<HTMLDivElement>(null);
const [isModalOpen, setIsModalOpen] = useState(false); // 모달 열기 상태
const [isMainSsc, setIsMainSsc] = useState(false); // 중점우수토실 여부
const [isDrtwSewagePump, setIsDrtwSewagePump] = useState(false); // 오수펌프장 여부
// 🎯 InfilChart 및 RdiiChart 관련 상태 추가
const { setIsOpen: setIsModalInfil } = useInfilChartModalStore();
const { setIsOpen: setIsModalRdii } = useRdiiChartModalStore();
const [isClicked, setIsClicked] = useState(true); // 클릭 여부 상태
const [isLoading, setIsLoading] = useState(false); // 로딩 상태
const [chmbrXcnts, setChmbrXcnts] = useState<string>(''); // 로딩 상태
@@ -461,16 +455,9 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
// 저장 시 모달 띄우기
const handleSaveClick = () => {
setIsModalOpen(true); // 모달 열기
};
const handleModalSave = () => {
setIsModalOpen(false); // 모달 닫기
btnClickEvent(); // 저장 이벤트 실행
};
const handleModalClose = () => {
setIsModalOpen(false); // 모달 닫기
const shouldSave = window.confirm('정보를 저장하시겠습니까?');
if (!shouldSave) return;
btnClickEvent();
};
// 저장 버튼 이벤트
@@ -557,14 +544,26 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
{/* 하수관로일 때만 Infil/RDII 버튼 표시 */}
{layerNm === '하수관로' && (
<>
<Button onClick={() => setIsModalInfil(true)}
<Button
onClick={() => {
const popupWindow = openWindowPopup("infilChart", { pplMngNo });
if (!popupWindow) {
alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.');
}
}}
className="py-1 text-xl text-blue-600 border border-blue-600 rounded hover:bg-gray-700"
color="white"
size="sm"
>
침입수분석
</Button>
<Button onClick={() => setIsModalRdii(true)}
<Button
onClick={() => {
const popupWindow = openWindowPopup("rdiiChart", { pplMngNo });
if (!popupWindow) {
alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.');
}
}}
className="py-1 text-xl text-gray-600 border border-gray-600 rounded hover:bg-gray-700 mr-4"
color="white"
size="sm"
@@ -617,7 +616,6 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
total2={0}
isEditable2={buttonChk}
/>
<Modal isModalOpen={isModalOpen} title="자체입력정보 저장" setIsModalOpen={setIsModalOpen} closeModal={handleModalClose} saveModal={handleModalSave}><div className="text-lg">정보를 저장하시겠습니까?</div></Modal>
{ContentComponent &&
<ContentComponent
featureData={fetchData}
@@ -630,9 +628,6 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
/>
}
</div>
<InfilChart pplMngNo={pplMngNo} />
<RdiiChart pplMngNo={pplMngNo} />
</div>
)
}
+50 -3
View File
@@ -26,11 +26,13 @@ import FeatureClickEventPopup from "./components/popupComponent/featureClickEven
import { WaterDepth, WATER_DEPTH_LAYER_TYPES } from "./waterDepth/waterDepth";
import { ProcSearchBar } from "@common/business_components/ui/map/searchBar";
import useSearchModalStore from "@/store/useSearchModalStore";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
const Map = () => {
const { isSearchModalOpen, setIsSearchModalOpen } = useSearchModalStore();
const [treeData, setTreeData] = useState([]); // 초기값을 객체 또는 null로 설정
const [layerLoadError, setLayerLoadError] = useState<string | null>(null);
const gisMaps = useRef<any | null>(null);
const [isOpen, setIsOpen] = useState(false); // 팝업 열기/닫기 상태
const [featureData, setFeatureData] = useState(null); // 선택된 피쳐 데이터
@@ -41,14 +43,54 @@ const Map = () => {
fetchData();
}, []);
useEffect(() => {
const handlePopupMessage = async (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
const data = event.data;
const core = GisApp.Module.layer as any;
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SELECT_FEATURE) {
const layerNm = data.payload?.layerNm;
const fid = data.payload?.fid;
if (!layerNm || !fid || !core?.showSearchResultsFromGeoJSON) return;
await core.showSearchResultsFromGeoJSON(layerNm, fid);
return;
}
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_MULTIPLE) {
const layerNm = data.payload?.layerNm;
const fidsRaw = data.payload?.fids;
if (!layerNm || !fidsRaw || !core?.showMultipleSearchResultsFromGeoJSON) return;
let fids: string[] = [];
try {
fids = JSON.parse(fidsRaw);
} catch (error) {
console.error("다중 시설 메시지 파싱 실패:", error);
return;
}
await core.showMultipleSearchResultsFromGeoJSON(layerNm, fids);
}
};
window.addEventListener("message", handlePopupMessage);
return () => window.removeEventListener("message", handlePopupMessage);
}, []);
const fetchData = async () => {
try {
const data = await getRawJson("/sewerage-service/api/v1/map/lyrStngs/SN_NETWORK");
setTreeData(data);
setLayerLoadError(null);
} catch (error) {
console.error('JSON parsing error:', error);
// JSON 파싱에 실패한 경우, 원본 텍스트 또는 null 반환
throw new Error('Invalid JSON response from server');
const message =
error instanceof Error ? error.message : "레이어 설정 조회 중 알 수 없는 오류가 발생했습니다.";
console.error("레이어 설정 조회 실패:", error);
setTreeData([]);
setLayerLoadError(message);
}
};
@@ -135,6 +177,11 @@ const Map = () => {
return (
<>
<div className="bg-container">
{layerLoadError ? (
<div className="absolute left-4 top-4 z-[60] max-w-[520px] rounded border border-red-200 bg-white px-4 py-3 text-sm text-red-700 shadow">
레이어 설정 조회 실패: {layerLoadError}
</div>
) : null}
<Gis
treeData={treeData}
isSearch={true}
@@ -1,108 +0,0 @@
/**
* ➡️ 경로 : 하수관망도 메인 페이지 (오른쪽 돋보기 모양 클릭) - 검색 모달 창 내 하수도정비기본계획보고서 탭 (셀 클릭)
*
* 📄 Report Files Modal
* Next.js App Router (app/) 기반 보고서 파일 모달 컴포넌트
*
* - 하수도정비 기본계획 보고서 파일 목록 표시
* - 파일명 및 등록일자 정보 테이블 출력
* - 페이지네이션 및 로딩 오버레이 지원
* - 파일 클릭 이벤트 처리 및 상세 조회
* - Zustand를 통한 모달 상태 및 데이터 관리
* - 모달 열림/닫힘 시 데이터 초기화 기능
*/
'use client'
import React, { useEffect } from 'react';
import LoadingOverlay from '@/components/ui/LoadingBar';
import useReportTab from './useReportTab';
import { useReportTabStore } from '@/store/useReportTabStore';
import { UiTable } from '@common/business_components/ui';
import { UiModal } from "@common/business_components";
import useSearchModalStore from '@/store/useSearchModalStore';
type ModalOpenProp = { clsfSystmSn: string } // 자료관 기본계획 일련번호
const ReportFilesModal: React.FC<ModalOpenProp> = ({ clsfSystmSn }: ModalOpenProp) => {
// 하수도정비 기본계획 보고서 파일목록
const REPORT_FILES_HEADLIST = [
{ id: "no", title: "NO" },
{ id: "fileTitle", title: "파일명" },
{ id: "regDt", title: "등록일자" },
];
const {
curPage, setCurPage,
isLoading,
isResetPage,
isResetSelection,
setIsResetPage,
setIsResetSelection
} = useSearchModalStore();
const {
isOpen: isModalOpen,
files,
dcFiles: filesOrigin,
setIsOpen: setIsModalOpen,
setFiles,
fetchDcFiles
} = useReportTabStore();
const { getFiles, handleCellClick } = useReportTab();
useEffect(() => {
fetchDcFiles(clsfSystmSn);
}, [clsfSystmSn]);
useEffect(() => {
if (isModalOpen === true) {
getFiles(); // 파일 목록 조회
} else {
setFiles([]); // 모달이 닫힐 때 파일 목록 초기화
setCurPage(1); // 페이지를 1로 초기화
setIsResetPage(true); // 페이지를 초기화
setIsResetSelection(true); // 선택 초기화
}
}, [filesOrigin, isModalOpen, curPage]);
const renderBody = () => (
<>
<div className="flex-1 px-2 pb-2">
<LoadingOverlay isLoading={isLoading} />
<UiTable
headName={(() => {
return (<>조회 결과 ({filesOrigin.length.toLocaleString('ko-KR')})건</>);
})()
}
tableData={files}
headlist={REPORT_FILES_HEADLIST}
pageSize={10}
total={filesOrigin.length}
cellClick={handleCellClick}
onPageChange={setCurPage}
resetPageNo={isResetPage}
resetSelection={isResetSelection}
/>
</div>
</>
);
return (
<UiModal
open={isModalOpen}
onOpenChange={setIsModalOpen}
headerTitle=""
bodyClass={`
min-h-[50vh] max-h-[90dvh]
max-w-[750px]
h-auto w-auto
overflow-auto
z-[5000]
flex items-start
`}
body={renderBody()}
noFooterYn='Y'
/>
);
};
export default React.memo(ReportFilesModal);
@@ -22,7 +22,7 @@ import useStore from '@/store/useStore';
import { CtpvSggPrcsFcltSelectBox } from '@/components/ui/ctpvSggPrcsFcltSelectBox';
import useSearchModalStore from '@/store/useSearchModalStore';
import { useReportTabStore } from '@/store/useReportTabStore';
import ReportFilesModal from './reportFilesModal'; // 파일목록모달
import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup';
// 하수도정비 기본계획 보고서
@@ -56,7 +56,6 @@ const ReportTab: React.FC<ReportTabProps> = ({ open, isSearch = true, ctpvNm, sg
const [ctpvCd, setCtpvCd] = useState('0');
const [sggCd, setSggCd] = useState('0');
const [reportGubun, setReportGubun] = useState("0");
const [clsfSystmSn, setClsfSystmSn] = useState(''); // 자료관 기본계획 일련번호
const {
tableData,
@@ -72,8 +71,6 @@ const ReportTab: React.FC<ReportTabProps> = ({ open, isSearch = true, ctpvNm, sg
} = useSearchModalStore();
const { ctpv, sgg } = useStore();
const {
isOpen: isReportFilesModalOpen,
setIsOpen: setIsReportFilesModalOpen,
dcTree: reportOriginData,
fetchDcTree
} = useReportTabStore(); // 모달 상태 관리 스토어
@@ -139,8 +136,14 @@ const ReportTab: React.FC<ReportTabProps> = ({ open, isSearch = true, ctpvNm, sg
if (typeof index === "number" && tableData[index]) {
const reportData = tableData[index];
if (reportData.fileSn !== "") {
setClsfSystmSn(reportData.clsfSystmSn); // 자료관 기본계획 일련번호 설정
setIsReportFilesModalOpen(true); // 모달 열기
const popupWindow = openWindowPopup("reportFiles", {
clsfSystmSn: reportData.clsfSystmSn,
sourcePath: window.location.pathname,
});
if (!popupWindow) {
alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.');
}
}
}
};
@@ -263,10 +266,8 @@ const ReportTab: React.FC<ReportTabProps> = ({ open, isSearch = true, ctpvNm, sg
/>
</div>
</div>
{/* 모달 컴포넌트 */}
{isReportFilesModalOpen && <ReportFilesModal clsfSystmSn={clsfSystmSn} />}
</>
);
};
export default React.memo(ReportTab);
export default React.memo(ReportTab);
@@ -11,21 +11,26 @@
* - Zustand를 통한 검색 모달 상태 관리
* - 로딩 오버레이 및 선택 초기화 지원
*/
'use client';
import LoadingOverlay from '@/components/ui/LoadingBar';
import { UiModal, UITab, UiTable } from '@common/business_components/ui';
import { UITab, UiTable } from '@common/business_components/ui';
import { Button } from '@common/components/ui';
import React, { useEffect, useMemo } from 'react';
import React, { useEffect, useMemo, useRef } from 'react';
import { Toaster } from "react-hot-toast";
import useSearchModalStore from '@/store/useSearchModalStore';
import { ExclnSewagePumpCol2, MsSwageMhlCol2, MsSwagePplCol2, stofcbrCol2, SwtrmtFcltCol2, SwtrmtPrcsZoneCol2 } from '../components/common/table';
import BasicTab, { BASIC_TYPES } from './basicTab';
import OperationTab from './operationTab';
import ReportTab from './reportTab';
import useSearchModal from './useSearchModal';
import useBasicTab from './useBasicTab';
import useOperationTab from './useOperationTab';
import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup';
const SearchModal: React.FC = () => {
type SearchModalProps = {
renderMode?: "launcher" | "popup";
};
const SearchModal: React.FC<SearchModalProps> = ({ renderMode = "launcher" }) => {
const {
showTable,
isSearchModalOpen, setIsSearchModalOpen,
@@ -59,6 +64,7 @@ const SearchModal: React.FC = () => {
operationResultExcelDownload,
handleOperationPageNation
} = useOperationTab();
const hasAutoOpenedReportRef = useRef(false);
const dynamicHeadlist: { id: string, title: string }[] = useMemo(() => {
@@ -87,6 +93,42 @@ const SearchModal: React.FC = () => {
return [];
}, [activeTab, workType]);
useEffect(() => {
if (renderMode !== "launcher" || !isSearchModalOpen) return;
const popupWindow = openWindowPopup("mapSearch", {
sourcePath: window.location.pathname,
});
if (!popupWindow) {
alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.');
}
setIsSearchModalOpen(false);
}, [renderMode, isSearchModalOpen]);
const openReportTabPopup = () => {
const popupWindow = openWindowPopup("reportTab", {
sourcePath: window.location.pathname,
});
if (!popupWindow) {
alert('?앹뾽??李⑤떒?섏뿀?듬땲?? 釉뚮씪?곗? ?앹뾽 ?덉슜 ?ㅼ젙???뺤씤?댁<?몄슂.');
}
};
useEffect(() => {
if (activeTab !== 'report') {
hasAutoOpenedReportRef.current = false;
return;
}
if (hasAutoOpenedReportRef.current) return;
openReportTabPopup();
hasAutoOpenedReportRef.current = true;
}, [activeTab]);
useEffect(() => {
resetFilters();
}, [activeTab, basicType, workType]);
@@ -119,7 +161,23 @@ const SearchModal: React.FC = () => {
/>
{activeTab === 'basic' && (<BasicTab />)} {/* 기본정보 탭 */}
{activeTab === 'operation' && (<OperationTab />)} {/* 유지관리 정보 탭 */}
{activeTab === 'report' && (<ReportTab open={isSearchModalOpen} />)} {/* 하수도정비기본계획보고서 탭 */}
{activeTab === 'report' && (
<div className="mt-4 flex h-[79dvh] w-full items-center justify-center rounded-lg border bg-white p-6">
<div className="flex max-w-[520px] flex-col items-center text-center">
<h2 className="mb-3 text-2xl font-semibold text-gray-900">
하수도정비 기본계획보고서
</h2>
<p className="mb-5 text-lg leading-8 text-gray-600">
보고서 상세는 모달 내부 탭 대신
<br />
전용 윈도우 팝업으로 열리도록 전환되었습니다.
</p>
<Button className="px-6 py-2 text-white" onClick={openReportTabPopup}>
전용 팝업 열기
</Button>
</div>
</div>
)} {/* 하수도정비기본계획보고서 탭 */}
{/* 📋 결과 리스트 */}
@@ -214,22 +272,16 @@ const SearchModal: React.FC = () => {
</>
);
if (renderMode === "launcher") {
return null;
}
return (
<div className='overflow-x-auto overflow-y-hidden'>
<UiModal
open={isSearchModalOpen}
onOpenChange={setIsSearchModalOpen}
bodyClass={`
h-[100dvh]
w-[90dvw]
max-w-[1220px]
max-h-[970px]
overflow-y-hidden
z-[101]
`}
body={renderBody()}
useDirectRender={true}
/>
<div className='h-dvh overflow-x-auto overflow-y-hidden bg-white p-4'>
<h1 className="mb-3 text-2xl font-semibold text-gray-900">관망도 검색</h1>
<div className="h-[calc(100dvh-5.6rem)] w-full overflow-y-hidden rounded-lg border bg-white p-4">
{renderBody()}
</div>
<Toaster position="top-center"></Toaster>
</div>
);
@@ -31,6 +31,13 @@ import { swagePrcsZoneCqlFilters } from './swagePrcsZone';
import { swageMhlCqlFilters } from './swageMhl';
import { swagePmstnCqlFilters } from './swagePmstn';
import { stofcbrCqlFilters } from './stofcbr';
import { postPopupMessageAndClose } from '@/features/windowPopup/lib/popupMessage';
import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types';
const isPopupWindow = () =>
typeof window !== "undefined" &&
!!window.opener &&
window.location.pathname.startsWith("/popup/");
export default function useBasicTab() {
@@ -115,9 +122,21 @@ export default function useBasicTab() {
// 기본정보 테이블 cellClick 핸들러
const handleBasicCellClick = async (index?: number | undefined, headId?: String) => {
if (typeof index === "number") {
const row = tableData[index];
if (isPopupWindow()) {
postPopupMessageAndClose(
WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SELECT_FEATURE,
{
layerNm: row.lyrTypeNm,
fid: String(row.fid),
},
);
return;
}
setIsLoading(true);
const row = tableData[index];
const layerNm = row.lyrTypeNm;
const fid = row.fid;
const core = GisApp.Module.layer as any;
@@ -23,6 +23,13 @@ import { confirmToast } from '@/components/ui/toast'
import { toast } from "react-hot-toast";
import useSearchModalStore from '@/store/useSearchModalStore';
import useStore from '@/store/useStore';
import { postPopupMessageAndClose } from '@/features/windowPopup/lib/popupMessage';
import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types';
const isPopupWindow = () =>
typeof window !== "undefined" &&
!!window.opener &&
window.location.pathname.startsWith("/popup/");
export default function useOperationTab() {
@@ -200,9 +207,21 @@ export default function useOperationTab() {
// 유지관리 정보 디테일 테이블 cellClick 핸들러
const handleOperationResultCellClick = async (index?: number | undefined, headId?: String) => {
if (typeof index === "number") {
const row = detailTableData[index];
if (isPopupWindow()) {
postPopupMessageAndClose(
WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SELECT_FEATURE,
{
layerNm: row.lyrTypeNm,
fid: String(row.fid),
},
);
return;
}
setIsLoading(true);
const row = detailTableData[index];
const layerNm = row.lyrTypeNm;
const fid = row.fid;
const core = GisApp.Module.layer as any;
@@ -434,6 +453,20 @@ export default function useOperationTab() {
}
};
const emitMultipleFeaturesToMap = () => {
if (detailTableData.length === 0 || !isPopupWindow()) return false;
const row = detailTableData[0];
postPopupMessageAndClose(
WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_MULTIPLE,
{
layerNm: row.lyrTypeNm,
fids: JSON.stringify(detailTableData.map((item) => item.fid)),
},
);
return true;
};
return {
WORK_TYPES,
@@ -446,7 +479,8 @@ export default function useOperationTab() {
handleOperationCellClick,
handleOperationResultCellClick,
handleOperationPageNation,
emitMultipleFeaturesToMap,
operationExcelDownload,
operationResultExcelDownload
};
};
};
@@ -19,6 +19,13 @@ import GisApp from "@common/business_components/ui/map/lib/gis.core";
import useSearchModalStore from '../../../store/useSearchModalStore';
import useBasicTab from './useBasicTab';
import useOperationTab from './useOperationTab';
import { postPopupMessageAndClose } from '@/features/windowPopup/lib/popupMessage';
import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types';
const isPopupWindow = () =>
typeof window !== "undefined" &&
!!window.opener &&
window.location.pathname.startsWith("/popup/");
export default function useSearchModal() {
const {
@@ -132,6 +139,18 @@ export default function useSearchModal() {
const viewMultipleFeature = async () => {
if (activeTab === 'basic') {
if (!(tableData.length === 0)) {
if (isPopupWindow()) {
const row = tableData[0];
postPopupMessageAndClose(
WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_MULTIPLE,
{
layerNm: row.lyrTypeNm,
fids: JSON.stringify(tableData.map((item) => item.fid)),
},
);
return;
}
setIsLoading(true);
const row = tableData[0];
@@ -144,6 +163,18 @@ export default function useSearchModal() {
}
} else if (activeTab === 'operation') {
if (!(detailTableData.length === 0)) {
if (isPopupWindow()) {
const row = detailTableData[0];
postPopupMessageAndClose(
WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_MULTIPLE,
{
layerNm: row.lyrTypeNm,
fids: JSON.stringify(detailTableData.map((item) => item.fid)),
},
);
return;
}
setIsLoading(true);
const row = detailTableData[0];
+11
View File
@@ -0,0 +1,11 @@
export default function PopupLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<main className="min-h-dvh bg-white text-foreground">
{children}
</main>
);
}
@@ -0,0 +1,30 @@
import { isWindowPopupKey } from "@/features/windowPopup/model/popupDefinitions";
import { windowPopupRegistry } from "@/features/windowPopup/registry";
type PopupPageProps = {
params: Promise<{
popupKey: string;
}>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
};
export default async function PopupPage({
params,
searchParams,
}: PopupPageProps) {
const { popupKey } = await params;
const resolvedSearchParams = await searchParams;
if (!isWindowPopupKey(popupKey)) {
return (
<section className="flex min-h-dvh flex-col items-center justify-center gap-4 p-8 text-center">
<h1 className="text-2xl font-semibold">등록되지 않은 팝업입니다.</h1>
<p className="text-base text-gray-600">popupKey: {popupKey}</p>
</section>
);
}
const PopupComponent = windowPopupRegistry[popupKey].component;
return <PopupComponent searchParams={resolvedSearchParams} />;
}
@@ -17,22 +17,20 @@
* - 첨부파일 삭제및 Soft Delete 삭제: DELETE /api/v1/atfl/attachments/{atflId} (attach-service)
*/
'use client'
import { deleteData, postData, useCookieAuth } from '@/components/lib/api'
import { deleteData, useCookieAuth } from '@/components/lib/api'
import { searchData } from '@/components/lib/common/apiHelpers'
import { formatDate, handleDownload } from '@/components/lib/utils'
import LoadingOverlay from '@/components/ui/LoadingBar'
import { SearchDiv, SearchFormLeft, SearchFormRight, UiTable, UiModal } from '@common/business_components/ui'
import { SearchDiv, SearchFormLeft, SearchFormRight, UiTable } from '@common/business_components/ui'
import ListDiv from '@common/business_components/ui/list_div'
import { Button, DatePicker, Input, Label } from '@common/components/ui'
import { useEffect, useState } from 'react'
import { tableHeadlist } from '..'
import OperFileUploadForm from '../../../components/ui/operFileUploadForm'
import { tokenManager } from '@/entities/auth/lib/token-manager';
import { AUTH_USER_ID } from "@/shared/config";
import { useQuery } from "@tanstack/react-query";
import { ENDPOINT } from "@common/components/config";
import { getUserInfo } from "@common/components/api";
import TableModal from './tableModal';
import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup';
import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types';
const Page = () => {
@@ -51,8 +49,6 @@ const Page = () => {
const [curPage, setCurPage] = useState(1) //현재 페이지
const [totalData, setTotalData] = useState(0) //총 데이터 수
const [isLoading, setIsLoading] = useState(true) //로딩 상태
const [isUploadModalOpen, setIsUploadModalOpen] = useState(false) // 등록 모달 상태
const [isTableModalOpen, setIsTableModalOpen] = useState(false) // 테이블 모달 상태
const [resetSelection, setResetSelection] = useState<boolean>(false); // 선택 초기화 상태
const [isResetPage, setIsResetPage] = useState<boolean>(false);
@@ -67,6 +63,17 @@ const Page = () => {
fetchData()
}, [])
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
if (event.data?.type !== WINDOW_POPUP_MESSAGE_TYPE.FLOOD_PRE_UPLOAD_COMPLETED) return;
handleSearch();
};
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}, [fromDate, toDate, pjNm, curPage]);
// 페이지 변경 시마다 데이터 fetch
useEffect(() => {
setIsResetPage(false);
@@ -140,27 +147,24 @@ const Page = () => {
}
}
// 현황 등록
const registFile = async (fileName: string, year: string, quater: string, cd: string, id: string) => {
const json = {
mngYr: year,
mngQu: quater,
atflId: id,
atflCd: cd,
rgtr: tokenManager.getToken(AUTH_USER_ID) || null,
sbmrgPrevntBizNm: fileName
}
const data = await postData('v1/sbmrgPrevnt', json)
if (data) {
alert('파일이 등록되었습니다.');
setIsUploadModalOpen(false);
handleSearch();
// 등록 버튼 클릭 핸들러
const handleRegisterClick = () => {
const popupWindow = openWindowPopup("floodPreUpload");
if (!popupWindow) {
alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.');
}
}
// 등록 버튼 클릭 핸들러
const handleRegisterClick = () => {
setIsUploadModalOpen(true);
const handleTablePopupOpen = (fileName: string, id: string) => {
const popupWindow = openWindowPopup("floodPreTable", {
fileNm: fileName,
atflId: id,
sourcePath: window.location.pathname,
});
if (!popupWindow) {
alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.');
}
}
@@ -246,7 +250,7 @@ const Page = () => {
setFileNm(fileName);
setAtflId(id);
setSbmrgPrevntBizSn(tableData[index]?.sbmrgPrevntBizSn);
setIsTableModalOpen(true);
handleTablePopupOpen(fileName, id);
}
}}
onPageChange={setCurPage}
@@ -254,40 +258,8 @@ const Page = () => {
resetPageNo={isResetPage}
/>
</ListDiv>
{/* 파일 업로드 모달 */}
<UiModal
open={isUploadModalOpen}
onOpenChange={setIsUploadModalOpen}
bodyClass="w-auto max-w-[90vw] min-w-[600px] h-auto"
noHeaderYn="N"
noFooterYn="Y"
body={
<OperFileUploadForm
title='도시침수 예방사업 추진현황 등록'
onSubmit={async (file, year, quater, cd, id) => {
await registFile(file, year, quater, cd, id)
}}
/>
}
/>
{/* 엑셀 데이터 테이블 모달 */}
<UiModal
open={isTableModalOpen}
onOpenChange={setIsTableModalOpen}
bodyClass={`
h-[100dvh]
w-[90dvw]
max-w-[100dvw]
max-h-[100dvh]
overflow-hidden
z-[101]
flex flex-col gap-[10px]
`}
body={<TableModal fileNm={fileNm} atflId={atflId} />}
useDirectRender={true}
/>
</>
)
}
export default Page
export default Page
@@ -55,6 +55,12 @@ const TableModal: React.FC<TableModalProps> = ({ fileNm, atflId }) => {
return false
}
if (atflId === '' || atflId === undefined) {
alert('첨부파일 ID가 없어 파일을 불러올 수 없습니다.');
setIsLoading(false);
return false;
}
const blob = await getBlobData(`/attach-service/api/v1/download/${atflId}`);
const file = new File([blob], fileNm, {
@@ -110,6 +116,8 @@ const TableModal: React.FC<TableModalProps> = ({ fileNm, atflId }) => {
const filteredResultData = filterEmptyRows(resultData);
setResultTableData(filteredResultData);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error('FloodPre attachment fetch details:', { atflId, fileNm, message });
console.error('파일 처리 중 오류 발생:', error);
alert('파일 처리 중 오류가 발생했습니다.');
} finally {
@@ -17,22 +17,20 @@
* - 첨부파일 삭제및 Soft Delete 삭제: DELETE /api/v1/atfl/attachments/{atflId} (attach-service)
*/
'use client'
import { deleteData, postData, useCookieAuth } from '@/components/lib/api'
import { deleteData, useCookieAuth } from '@/components/lib/api'
import { searchData } from '@/components/lib/common/apiHelpers'
import { formatDate, handleDownload } from '@/components/lib/utils'
import LoadingOverlay from '@/components/ui/LoadingBar'
import { SearchDiv, SearchFormLeft, SearchFormRight, UiTable, UiModal } from '@common/business_components/ui'
import { SearchDiv, SearchFormLeft, SearchFormRight, UiTable } from '@common/business_components/ui'
import ListDiv from '@common/business_components/ui/list_div'
import { Button, DatePicker, Input, Label } from '@common/components/ui'
import { useEffect, useState } from 'react'
import { tableHeadlistOldPp } from '..'
import OperFileUploadForm from '../../../components/ui/operFileUploadForm'
import { tokenManager } from '@/entities/auth/lib/token-manager';
import { AUTH_USER_ID } from "@/shared/config";
import { useQuery } from "@tanstack/react-query";
import { ENDPOINT } from "@common/components/config";
import { getUserInfo } from "@common/components/api";
import TableModal from './tableModal';
import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup';
import { WINDOW_POPUP_MESSAGE_TYPE } from '@/features/windowPopup/model/types';
const Page = () => {
@@ -49,8 +47,6 @@ const Page = () => {
const [curPage, setCurPage] = useState(1)
const [totalData, setTotalData] = useState(0) //총 데이터 수
const [isLoading, setIsLoading] = useState(true) //로딩 상태
const [isModalOpen, setIsModalOpen] = useState(false) // 등록 모달 상태
const [isTableModalOpen, setIsTableModalOpen] = useState(false) // 테이블 모달 상태
const [resetSelection, setResetSelection] = useState<boolean>(false); // 선택 초기화 상태
const [isResetPage, setIsResetPage] = useState<boolean>(false);
@@ -65,6 +61,17 @@ const Page = () => {
fetchData()
}, [])
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
if (event.data?.type !== WINDOW_POPUP_MESSAGE_TYPE.OLD_PP_UPLOAD_COMPLETED) return;
handleSearch();
};
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}, [fromDate, toDate, pjNm, curPage]);
// 페이지 변경 시마다 데이터 fetch
useEffect(() => {
setIsResetPage(false);
@@ -137,27 +144,25 @@ const Page = () => {
}
}
// 파일 등록
const registFile = async (fileNm: string, year: string, quater: string, cd: string, id: string) => {
const json = {
mngYr: year,
mngQu: quater,
atflCd: cd,
atflId: id,
rgtr: tokenManager.getToken(AUTH_USER_ID) || null,
dcrpPplMtncBizNm: fileNm
}
const data = await postData('v1/dcrpPpl', json);
if (data) {
alert('파일이 등록되었습니다.');
setIsModalOpen(false);
handleSearch();
// 등록 버튼 클릭 핸들러
const handleRegisterClick = () => {
const popupWindow = openWindowPopup("oldPpUpload");
if (!popupWindow) {
alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.');
}
}
// 등록 버튼 클릭 핸들러
const handleRegisterClick = () => {
setIsModalOpen(true);
// 엑셀 데이터는 브라우저 윈도우 팝업에서 독립 조회
const handleTablePopupOpen = (fileName: string, id: string) => {
const popupWindow = openWindowPopup("oldPpTable", {
fileNm: fileName,
atflId: id,
sourcePath: window.location.pathname,
});
if (!popupWindow) {
alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.');
}
}
@@ -242,47 +247,15 @@ const Page = () => {
setFileNm(fileName);
setAtflId(id);
setDcrpPplMtncBizSn(tableData[index]?.dcrpPplMtncBizSn);
setIsTableModalOpen(true);
handleTablePopupOpen(fileName, id);
}
}}
onPageChange={setCurPage}
resetSelection={resetSelection}
/>
</ListDiv>
{/* 파일 업로드 모달 */}
<UiModal
open={isModalOpen}
onOpenChange={setIsModalOpen}
bodyClass="w-auto max-w-[90vw] min-w-[600px] h-auto"
noHeaderYn="N"
noFooterYn="Y"
body={
<OperFileUploadForm
title='노후 하수관로 정비사업 추진현황 등록'
onSubmit={async (file, year, quater, cd, id) => {
await registFile(file, year, quater, cd, id)
}}
/>
}
/>
{/* 엑셀 데이터 테이블 모달 */}
<UiModal
open={isTableModalOpen}
onOpenChange={setIsTableModalOpen}
bodyClass={`
h-[100dvh]
w-[90dvw]
max-w-[100dvw]
max-h-[100dvh]
overflow-hidden
z-[101]
flex flex-col gap-[10px]
`}
body={<TableModal fileNm={fileNm} atflId={atflId} />}
useDirectRender={true}
/>
</>
)
}
export default Page
export default Page
@@ -64,6 +64,12 @@ const TableModal: React.FC<TableModalProps> = ({ fileNm, atflId }) => {
return false;
}
if (atflId === '' || atflId === undefined) {
alert('첨부파일 ID가 없어 파일을 불러올 수 없습니다.');
setIsLoading(false);
return false;
}
const blob = await getBlobData(`/attach-service/api/v1/download/${atflId}`);
const file = new File([blob], fileNm, {
@@ -242,6 +248,8 @@ const TableModal: React.FC<TableModalProps> = ({ fileNm, atflId }) => {
}));
setTableData6(filteredData6);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error('OldPp attachment fetch details:', { atflId, fileNm, message });
console.error('파일 처리 중 오류 발생:', error);
alert('파일 처리 중 오류가 발생했습니다.');
} finally {
+9 -3
View File
@@ -153,7 +153,10 @@ export const getRawJson = async (url: string): Promise<any> => {
}
if (!res.ok) {
throw new Error('Failed to fetch data');
const errorText = await res.text().catch(() => "");
throw new Error(
`Failed to fetch data: ${res.status} ${res.statusText}${errorText ? ` - ${errorText.slice(0, 200)}` : ""}`,
);
}
const contentLength = res.headers.get('content-length');
@@ -170,7 +173,7 @@ export const getRawJson = async (url: string): Promise<any> => {
return JSON.parse(text);
} catch (error) {
console.error('JSON parsing error:', error);
throw new Error('Invalid JSON response from server');
throw new Error(`Invalid JSON response from server: ${url}`);
}
};
@@ -184,7 +187,10 @@ export const getBlobData = async (url: string): Promise<Blob> => {
}
if (!res.ok) {
throw new Error('Failed to fetch data');
const errorText = await res.text().catch(() => "");
throw new Error(
`Failed to fetch blob: ${res.status} ${res.statusText}${errorText ? ` - ${errorText.slice(0, 200)}` : ""} (${url})`,
);
}
return res.blob();
-45
View File
@@ -1,45 +0,0 @@
/**
* 📄 Modal Component
* 모달 컴포넌트
*
* - 모달 열기/닫기 상태 관리
* - 제목 및 내용 표시
* - 저장 및 닫기 버튼 지원
* - 반응형 크기 조정 (최대/최소 너비 및 높이)
* - 오버레이 및 z-index를 통한 최상위 레이어 배치
* - 접근성 지원 (aria-describedby)
*/
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogOverlay } from '@common/components/ui'
import React from 'react'
interface ModalProps {
isModalOpen: boolean;
setIsModalOpen: (open: boolean) => void;
title?: string;
children: React.ReactNode;
closeModal: () => void;
saveModal?: () => void;
description?: string;
}
const Modal: React.FC<ModalProps> = ({ isModalOpen, setIsModalOpen, title, children, closeModal, saveModal }) => {
return (
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogOverlay className='z-[200]' />
<DialogContent className="w-auto max-w-[1200px] min-w-[400px] min-h-[150px] h-auto max-h-[450px] overflow-auto z-[1000]" aria-describedby={undefined}>
{title ? <DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader> : null}
{children}
<DialogFooter>
<div className='flex justify-end gap-2'>
{saveModal ? <button className="px-4 py-2 bg-primary text-white rounded" onClick={saveModal}>저장</button> : null}
<button className="px-4 py-2 bg-gray-600 text-white rounded" onClick={closeModal}>닫기</button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export default Modal
@@ -23,7 +23,6 @@
*/
'use client'
import { getData, postData } from "@/components/lib/api";
import Modal from "@/components/ui/modal";
import { VerticalTable } from "@/components/ui/verticalTable";
import { useGisContext } from "@common/business_components/ui/map/Gis";
import { Button } from "@common/components/ui";
@@ -74,7 +73,6 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
const [buttonChk, setButtonChk] = useState(true)
const [fetchData, setFetchData] = useState<any>(); // api 호출여부
const contentRef = useRef<HTMLDivElement>(null);
const [isModalOpen, setIsModalOpen] = useState(false); // 모달 열기 상태
const [isMainSsc, setIsMainSsc] = useState(false); // 중점우수토실 여부
const [isDrtwSewagePump, setIsDrtwSewagePump] = useState(false); // 오수펌프장 여부
// 🎯 InfilChart 및 RdiiChart 관련 상태 추가
@@ -459,18 +457,11 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
};
}, [featureData, gisMap]);
// 저장 시 모달 띄우기
// Use the browser confirm dialog for lightweight save confirmation.
const handleSaveClick = () => {
setIsModalOpen(true); // 모달 열기
};
const handleModalSave = () => {
setIsModalOpen(false); // 모달 닫기
btnClickEvent(); // 저장 이벤트 실행
};
const handleModalClose = () => {
setIsModalOpen(false); // 모달 닫기
const shouldSave = window.confirm('정보를 저장하시겠습니까?');
if (!shouldSave) return;
void btnClickEvent();
};
// 저장 버튼 이벤트
@@ -617,7 +608,6 @@ const FeatureClickEventPopup = ({ onClose, featureData }: { onClose: () => void,
total2={0}
isEditable2={buttonChk}
/>
<Modal isModalOpen={isModalOpen} title="자체입력정보 저장" setIsModalOpen={setIsModalOpen} closeModal={handleModalClose} saveModal={handleModalSave}><div className="text-lg">정보를 저장하시겠습니까?</div></Modal>
{ContentComponent &&
<ContentComponent
featureData={fetchData}
+18 -2
View File
@@ -12,7 +12,11 @@
* - 앱 전체의 공통 스타일 및 스크립트 적용
*/
import type { Metadata } from "next";
import { App } from "@/app";
import "@fontsource-variable/noto-sans-kr";
import { cookies } from "next/headers";
import { AppProvider } from "./providers";
import "../src/app/styles/base.css";
import "../src/app/styles/components.css";
export const metadata: Metadata = {
title: "디지털 하수관로",
@@ -24,4 +28,16 @@ export const metadata: Metadata = {
}
};
export default App;
export default async function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
const theme = (await cookies()).get("site-theme-key");
return (
<html lang="ko" data-theme={theme?.value}>
<body>
<AppProvider>{children}</AppProvider>
</body>
</html>
);
}
+1
View File
@@ -0,0 +1 @@
export { AppProvider } from "../src/app/providers";
+44
View File
@@ -0,0 +1,44 @@
# 팝업 레이아웃 전환 문서
## 목적
현재 `sn` 앱의 주요 팝업은 화면 내부 `UiModal` 또는 지도 내부 패널로 렌더링된다. 이번 작업의 목적은 모달로 구현된 팝업 중 업무상 독립 화면이 필요한 기능을 브라우저 윈도우 팝업으로 전환하기 위한 공통 구조를 설계하고, 작은 기능 하나를 기준으로 먼저 검증하는 것이다.
## 적용 범위
- 작업 앱: `apps/sn`
- 신규 라우트 그룹명: `app/(popupLayout)`
- 실제 팝업 URL 기본 형태: `/popup/{popupKey}`
- 1차 테스트 대상: `app/(with-base-layout)/oper/oldPp`의 첨부 엑셀 데이터 테이블 모달
## 문서 구성
- `설계/001-popupLayout-라우팅-구조.md`
- Next.js App Router 레이아웃 적용 순서
- `(popupLayout)` 디렉터리 구조
- 팝업 registry와 open helper 설계
- `features/windowPopup/model|lib|ui` 구조 기준
- `작업계획/001-oldPp-테이블모달-윈도우팝업-전환.md`
- 첫 테스트 대상 선정 이유
- 기존 모달 흐름과 전환 후 흐름
- 파일 단위 작업 계획
- `oldPp` 도메인 파라미터와 팝업 UI 분리 기준
- `검증/001-popupLayout-검증시나리오.md`
- 핵심 검증 항목 요약
- 구현 결과 요약
- 선행 차단 이슈와 현재 상태
## 결정 사항
1. `aged-pipe-popup`처럼 특정 기능 팝업을 `map` 하위에 직접 추가하지 않는다.
2. 팝업 화면은 지도 레이아웃 또는 기본 레이아웃의 하위 개념이 아니므로 별도 route group인 `(popupLayout)`에 둔다.
3. 각 화면에서 팝업 URL을 직접 문자열로 하드코딩하지 않고, `popupKey` 기반 helper를 통해 연다.
4. 첫 구현 검증은 영향이 작은 `oldPp` 테이블 상세 모달을 대상으로 한다.
## 보류 사항
- `@common/components`의 LNB 새 창 처리 방식은 현재 작업 범위 밖이다.
- 지도 검색 모달과 지도 상세 패널 전환은 첫 테스트 성공 후 별도 작업으로 진행한다.
- 저장/등록형 팝업의 부모 창 갱신은 `postMessage` 방식으로 설계하되, 첫 테스트 대상은 조회형 팝업이므로 최소 구현에서 제외한다.
@@ -0,0 +1,133 @@
# popupLayout 검증 요약
## 검증 대상
- 신규 route group: `app/(popupLayout)`
- 신규 실제 URL: `/popup/{popupKey}`
- 호출 화면
- `/oper/oldPp`
- `/oper/floodPre`
- `/map`
- 주요 popup key
- `oldPpUpload`
- `oldPpTable`
- `floodPreUpload`
- `floodPreTable`
- `mapSearch`
- `reportTab`
- `reportFiles`
- `infilChart`
- `rdiiChart`
- `sscDetail`
## 핵심 확인 항목
### 1. 라우팅
- `/popup/[popupKey]` 경로가 정상 라우트로 인식되어야 한다.
- 등록되지 않은 `popupKey`는 안내 화면을 보여야 한다.
### 2. 레이아웃
- 팝업 창에는 헤더, LNB, 푸터가 없어야 한다.
- 부모 화면은 기존 레이아웃을 그대로 유지해야 한다.
- `/oper/**`: `with-base-layout`
- `/map`: `map-layout`
### 3. 기능
- `oldPp`, `floodPre` 등록/상세 진입이 `window.open` 기반 popupView로 열려야 한다.
- 지도 검색은 `mapSearch` popupView에서 동작해야 한다.
- 지도 검색 popup은 부모 지도와 `postMessage`로 연동되어야 한다.
- `MAP_SEARCH_SELECT_FEATURE`
- `MAP_SEARCH_SHOW_MULTIPLE`
- 보고서 조회, 보고서 파일 목록, 관로 분석 차트, 중점우수토실 상세가 popupView로 열려야 한다.
### 4. 확인/오류 처리
- 팝업 차단 시 사용자 안내가 있어야 한다.
- 필수 파라미터 없이 팝업 URL에 직접 접근하면 누락 안내가 보여야 한다.
- 지도 우측 상세의 저장 확인은 별도 커스텀 모달 없이 브라우저 `window.confirm`으로 처리되어야 한다.
## 구현 결과 요약
### 완료
- `(popupLayout)` route group 추가
- `/popup/[popupKey]` 동적 팝업 라우트 추가
- `windowPopupDefinitions`, `windowPopupRegistry`, `openWindowPopup` 추가
- `oldPp` 등록/상세 popupView 전환
- `floodPre` 등록/상세 popupView 전환
- 지도 검색 `searchModal`을 `mapSearch` popupView로 전환
- 지도 검색 popup과 부모 `/map` 페이지 간 `postMessage` 연동 추가
- `SwtrmtFclt` 보고서 조회를 `reportTab` popupView로 전환
- 보고서 파일 목록을 `reportFiles` popupView로 전환
- 관로 분석 차트 `infilChart`, `rdiiChart`를 popupView로 전환
- 중점우수토실 상세 `sscDetail` popupView 전환
- 지도 우측 상세 `featureClickEventPopup.tsx`의 저장 확인을 `window.confirm`으로 치환
- 실서비스에서 더 이상 사용하지 않는 `app/components/ui/modal.tsx` 제거
### 현재 확인됨
- 로컬 `sewerage-service` 포트는 `8883` 기준으로 맞춰져 있다.
- `.env.local`의 `NEXT_PUBLIC_SEWERAGE_API_URL=http://localhost:8883` 기준에서 `/map` 초기 API 호출이 가능하다.
- `featureClickEventPopup.tsx`는 더 이상 `Modal`, `chartModalStore`, 내부 차트 모달 컴포넌트에 의존하지 않는다.
- `app/components/ui/modal.tsx` 참조는 실서비스 경로뿐 아니라 `app/html/**` 샘플 경로에서도 제거했다.
### 실서비스 기준 남은 모달 상태
- 실서비스 경로(`app/**`, 단 `app/html/**` 제외) 기준으로 popupLayout 전환 대상 모달은 정리되었다.
- 지도 우측 상세 저장 확인도 경량 confirm으로 치환되어 별도 모달 의존성이 없다.
### 샘플/퍼블리싱 영역 참고
- `app/html/**`는 실서비스 전환 기준에서 제외한다.
- `app/html/**` 내부에는 여전히 예전 구조를 보여주는 샘플 코드가 일부 남아 있을 수 있다.
- 현재 남은 대표 예시는 `reportFilesModal.tsx`, `chartModalStore.ts`이며, 실서비스 동작과는 분리해서 본다.
## 선행 차단 이슈
### 1. oldPp 목록이 비어 보이는 경우
로컬 API에 데이터가 있어도 화면 조회 기간이 최근 7일로 잡혀 있으면 기존 등록 데이터가 목록에 보이지 않을 수 있다.
예시:
- 화면 조회 기간: `2026-04-17 ~ 2026-04-24`
- 로컬 API 데이터 등록일 예시: `2025-12-19`
따라서 popup 검증 전에는 조회 기간을 넓히거나 새 데이터를 등록해야 한다.
### 2. 빌드 최종 단계
`pnpm --filter sn run build`는 코드/라우팅 문제와 별개로 `next.config.ts`의 `output: "standalone"` 단계에서 Windows `EPERM symlink` 문제로 마지막에 실패할 수 있다.
이 이슈는 popupLayout 구조나 모달 제거 로직의 문제라기보다 로컬 Windows standalone 산출물 생성 환경 이슈다.
## 현재 기준 수동 검증 방법
1. `sn` 개발 서버를 재시작한다.
2. `/map`이 정상 로드되는지 확인한다.
3. `/oper/oldPp`, `/oper/floodPre`에서 조회 기간을 넓혀 목록이 보이게 한다.
4. 등록 버튼과 목록 행 클릭으로 각각 popupView가 열리는지 확인한다.
5. `/map`에서 아래를 확인한다.
- 지도 검색 popup 열기
- popup에서 단건 선택 후 부모 지도 반영
- popup에서 다건 보기 후 부모 지도 반영
- 보고서 조회 popup 열기
- 보고서 파일 목록 popup 열기
- 침입수분석/RDII 분석 popup 열기
- 중점우수토실 상세 popup 열기
6. 지도 우측 상세에서 저장 버튼 클릭 시 브라우저 confirm이 뜨고, 취소/확인 흐름이 정상 동작하는지 확인한다.
## 자동/빌드 검증 메모
- import 점검 기준으로 `@/components/ui/modal` 참조는 제거되었다.
- 실서비스 경로에서 `chartModalStore`, `reportFilesModal` 참조는 제거되었다.
- 추가 자동 브라우저 검증은 아직 연결하지 않았고, 필요 시 별도 Playwright runner로 이어서 구성할 수 있다.
## 남은 확인 항목
- `/popup/**` 경로에서 `MenuWrapper` 권한 흐름이 실제 브라우저에서 완전히 문제없는지
- 한글 파일명/특수문자 query string encode/decode가 실브라우저에서 안정적인지
- `oldPp`, `floodPre` 상세 popup content를 장기적으로 route 폴더 밖 공용 UI로 더 분리할지 여부
@@ -0,0 +1,228 @@
# popupLayout 라우팅 구조 설계
## 배경
현재 `sn` 앱은 Next.js App Router 구조를 사용한다. 최상위 `app/layout.tsx`가 모든 페이지에 먼저 적용되고, 그 아래 route group인 `(map-layout)`, `(with-base-layout)`의 `layout.tsx`가 추가로 적용된다.
현재 구조의 의미는 다음과 같다.
```text
app/layout.tsx
모든 라우트에 공통 적용
AppProvider, AuthWrapper, MenuWrapper, QueryClient, StoreProvider 적용
app/(map-layout)/layout.tsx
/map 같은 지도 중심 화면에 적용
NoFooterLayout 사용
app/(with-base-layout)/layout.tsx
/oper/oldPp 같은 기본 업무 화면에 적용
BaseLayout, PageLayout 사용
```
Next.js route group 폴더명은 괄호로 감싸므로 URL에는 포함되지 않는다. 예를 들어 `app/(with-base-layout)/oper/oldPp/page.tsx`의 실제 URL은 `/oper/oldPp`이다.
## 문제
기능 팝업을 `app/(map-layout)/map/aged-pipe-popup`처럼 특정 레이아웃 하위에 직접 추가하면 다음 문제가 생긴다.
1. 팝업이 지도 화면의 하위 개념처럼 배치되어 구조가 흐려진다.
2. 신규 팝업이 생길 때마다 특정 화면 또는 특정 URL 문자열을 직접 추가해야 한다.
3. 기본 화면 팝업, 지도 팝업, 업무 상세 팝업이 한 레이아웃 아래에 섞인다.
4. 윈도우 팝업에서 헤더, 푸터, LNB가 다시 렌더링될 가능성이 있다.
## 목표 구조
팝업 전용 route group을 만든다.
```text
app
layout.tsx
(map-layout)
layout.tsx
map
page.tsx
(with-base-layout)
layout.tsx
oper
oldPp
page.tsx
(popupLayout)
layout.tsx
popup
[popupKey]
page.tsx
components
store
src
features
windowPopup
model
popupDefinitions.ts
types.ts
lib
openWindowPopup.ts
ui
OldPpTablePopupContent.tsx
PopupMissingParams.tsx
registry.tsx
index.ts
```
실제 URL은 다음 형태가 된다.
```text
/popup/oldPpTable
/popup/mapSearch
/popup/agedPipeDetail
```
`(popupLayout)`은 URL에 포함되지 않는다.
## popupLayout 책임
`app/(popupLayout)/layout.tsx`는 팝업 창에 필요한 최소 레이아웃만 담당한다.
- 헤더, 푸터, LNB를 렌더링하지 않는다.
- `BaseLayout`, `NoFooterLayout`, `PageLayout`을 사용하지 않는다.
- 팝업 내부 스크롤과 배경 스타일만 제공한다.
- 최상위 `app/layout.tsx`의 `AppProvider/AuthWrapper/MenuWrapper`는 그대로 적용된다.
예상 형태는 다음과 같다.
```tsx
export default function PopupLayout({ children }: { children: React.ReactNode }) {
return (
<main className="min-h-dvh bg-white text-foreground">
{children}
</main>
);
}
```
## popupKey registry
각 팝업은 URL 문자열을 직접 흩뿌리지 않고 registry로 관리한다.
실제 위치:
```text
src/features/windowPopup
registry.tsx
model
popupDefinitions.ts
types.ts
lib
openWindowPopup.ts
ui
OldPpTablePopupContent.tsx
PopupMissingParams.tsx
```
registry 예시:
```tsx
export const windowPopupRegistry = {
oldPpTable: {
title: "노후 하수관로 정비사업 상세",
width: 1400,
height: 900,
component: OldPpTablePopupPage,
},
};
```
`popupKey`는 camelCase로 관리한다.
## open helper
각 화면은 `window.open`을 직접 호출하지 않고 helper를 사용한다.
```ts
openWindowPopup("oldPpTable", {
fileNm,
atflId,
sourcePath: window.location.pathname,
});
```
helper 책임:
- registry에서 팝업 기본 크기 조회
- query string 생성
- 중앙 위치 계산
- `noopener` 사용 여부 결정
- 팝업 차단 시 사용자 안내 반환
## feature 분리 기준
팝업 구조는 아래처럼 역할별로 나눈다.
- `app/(popupLayout)`
- URL과 layout
- dynamic route 진입점
- `src/features/windowPopup/model`
- popup key
- popup definition
- popup 공통 타입
- `src/features/windowPopup/lib`
- `window.open` 실행
- query string 생성
- 팝업 크기와 위치 계산
- `src/features/windowPopup/ui`
- 팝업 전용 UI
- 파라미터 누락 안내 UI
- 실제 팝업 콘텐츠 컴포넌트
이 구조를 택한 이유는 현재 단계에서 popup 관련 코드를 한 눈에 따라가기 쉽게 하기 위해서다. 아직 `oldPp` 팝업이 하나뿐이므로 파라미터 해석도 `windowPopup/ui` 안에서 처리한다. 추후 `oldPp` 전용 팝업이 여러 개로 늘어나면 그때 `src/features/oldPp/ui` 또는 `src/features/oldPp/model`로 승격한다.
## 데이터 전달 기준
윈도우 팝업은 부모 React state를 공유하지 않는다. 따라서 전달 방식은 아래 기준을 따른다.
| 데이터 유형 | 전달 방식 |
| --- | --- |
| 단순 식별자 | query string |
| 파일 ID, 관리 번호 | query string |
| 큰 객체 | ID만 전달 후 팝업에서 API 재조회 |
| 부모 갱신 필요 이벤트 | `window.opener.postMessage` |
| 지도 OpenLayers feature 객체 | 전달 금지, feature ID 또는 관리번호로 재조회 |
## 권한 처리 기준
팝업 라우트도 최상위 `AppProvider/AuthWrapper/MenuWrapper`를 통과한다. `/popup/{popupKey}` 자체는 메뉴 URL이 아니므로 권한 판단 시 원본 메뉴 기준이 필요하다.
1차 테스트에서는 조회형 팝업만 열기 때문에 기존 쿠키 기반 인증 확인만 우선 검증한다.
향후 저장/수정/삭제가 있는 팝업은 다음 중 하나를 선택해야 한다.
1. `sourceMenuCd`를 query string으로 전달한다.
2. `sourcePath`를 전달하고 `MenuWrapper`에서 원본 메뉴를 찾아 권한을 조회한다.
3. `/popup` 경로는 메뉴 매칭에서 제외하고, 호출한 부모 화면의 권한 버튼에서만 진입을 통제한다.
1차 구현에서는 3번을 우선 검토한다. `MenuWrapper` 수정은 `apps/sn/src` 내부에서 가능한지 먼저 확인하고, 공통 패키지 변경은 하지 않는다.
## 명명 기준
- route group: `(popupLayout)`
- URL segment: `popup`
- dynamic segment: `[popupKey]`
- popup key: `oldPpTable`, `mapSearch`, `agedPipeDetail`
- 컴포넌트명: `OldPpTablePopupContent`, `MapSearchPopupContent`
## 롤백 기준
첫 테스트 중 문제가 생기면 다음 순서로 되돌린다.
1. 부모 화면의 `openWindowPopup` 호출을 기존 `setIsTableModalOpen(true)`로 되돌린다.
2. 기존 `UiModal` 렌더링을 유지한다.
3. `(popupLayout)` 신규 라우트는 삭제하거나 미사용 상태로 둔다.
4. registry에서 해당 `popupKey`를 제거한다.
@@ -0,0 +1,184 @@
# oldPp 테이블 모달 윈도우 팝업 전환 작업 계획
## 테스트 대상
첫 테스트 대상은 `app/(with-base-layout)/oper/oldPp/page.tsx`의 첨부 엑셀 데이터 테이블 모달이다.
현재 흐름:
```text
/oper/oldPp
목록 행 클릭
fileNm, atflId 상태 저장
isTableModalOpen = true
UiModal 내부에서 tableModal.tsx 렌더링
```
전환 후 흐름:
```text
/oper/oldPp
목록 행 클릭
openWindowPopup("oldPpTable", { fileNm, atflId, sourcePath })
/popup/oldPpTable?fileNm=...&atflId=...
popupLayout 적용
query string에서 fileNm, atflId 읽기
기존 tableModal 화면 내용 렌더링
```
## 선정 이유
1. `oldPp` 테이블 모달은 조회형 팝업이라 부모 창 갱신이 필요 없다.
2. 팝업 데이터가 `fileNm`, `atflId` 두 값으로 충분하다.
3. 기존 `TableModal`은 내부에서 `getBlobData`를 호출하므로 새 창에서도 독립 실행이 가능하다.
4. `with-base-layout` 아래 화면에서 팝업을 여는 사례라, `(popupLayout)` 분리 효과를 확인하기 좋다.
5. 지도 팝업보다 OpenLayers 객체 공유 문제가 적어 첫 검증에 적합하다.
## 예상 수정 파일
1차 구현 시 예상 파일은 다음과 같다.
```text
app/(popupLayout)/layout.tsx
app/(popupLayout)/popup/[popupKey]/page.tsx
src/features/windowPopup/model/types.ts
src/features/windowPopup/model/popupDefinitions.ts
src/features/windowPopup/lib/openWindowPopup.ts
src/features/windowPopup/ui/OldPpTablePopupContent.tsx
src/features/windowPopup/ui/PopupMissingParams.tsx
src/features/windowPopup/registry.tsx
app/(with-base-layout)/oper/oldPp/page.tsx
app/(with-base-layout)/oper/oldPp/tableModal.tsx
```
`packages/common/components`는 수정하지 않는다.
## 세부 작업
### 1. 팝업 레이아웃 생성
`app/(popupLayout)/layout.tsx`를 추가한다.
역할:
- 팝업 전용 최소 wrapper 제공
- 헤더, 푸터, LNB 미사용
- 전체 높이와 배경만 지정
### 2. 동적 팝업 라우트 생성
`app/(popupLayout)/popup/[popupKey]/page.tsx`를 추가한다.
역할:
- `params.popupKey`를 읽는다.
- registry에서 대응 컴포넌트를 찾는다.
- query string을 props로 넘긴다.
- 등록되지 않은 `popupKey`는 안내 화면을 표시한다.
### 3. popup system feature 작성
아래 파일을 추가하거나 정리한다.
```text
src/features/windowPopup/model/popupDefinitions.ts
src/features/windowPopup/model/types.ts
src/features/windowPopup/lib/openWindowPopup.ts
src/features/windowPopup/ui/OldPpTablePopupContent.tsx
src/features/windowPopup/ui/PopupMissingParams.tsx
src/features/windowPopup/registry.tsx
```
역할:
- `model`: popup key, popup definition, popup 타입
- `lib`: `window.open` 실행 helper
- `ui`: 팝업 콘텐츠와 누락 안내 UI
- `registry`: popup key와 실제 UI 매핑
1차 등록 대상:
```text
popupKey: oldPpTable
title: 노후 하수관로 정비사업 상세
width: 1400
height: 900
requiredParams: fileNm, atflId
```
### 4. oldPp 부모 화면 변경
`app/(with-base-layout)/oper/oldPp/page.tsx`의 행 클릭 로직을 변경한다.
기존:
```ts
setFileNm(fileName);
setAtflId(id);
setDcrpPplMtncBizSn(...);
setIsTableModalOpen(true);
```
변경:
```ts
openWindowPopup("oldPpTable", {
fileNm: fileName,
atflId: id,
sourcePath: window.location.pathname,
});
```
삭제 또는 보류:
- `isTableModalOpen`
- table modal용 `UiModal`
단, 1차 작업에서는 롤백을 쉽게 하기 위해 기존 모달 코드를 한 번에 삭제하지 않고, 팝업 검증 후 제거하는 방식을 권장한다.
### 5. TableModal 독립 실행 점검
`tableModal.tsx`가 부모 모달에 의존하는지 확인한다.
현재 확인된 독립 실행 조건:
- props: `fileNm`, `atflId`
- 내부 API: `getBlobData('/attach-service/api/v1/download/{atflId}')`
- 내부 상태: activeTab, tableData, isLoading
따라서 팝업 페이지에서 바로 렌더링 가능하다.
단, 장기적으로는 `tableModal.tsx`도 route 폴더 밖 공용 UI로 승격할 수 있다. 현재 단계에서는 변경 범위를 줄이기 위해 기존 파일을 그대로 재사용한다.
## 권한과 인증
새 창도 같은 origin에서 열리므로 쿠키는 공유된다. `AuthWrapper`의 인증 확인은 그대로 동작해야 한다.
주의할 점:
- `/popup/oldPpTable`은 메뉴 URL이 아니므로 메뉴 매칭이 안 될 수 있다.
- 현재 테스트 대상은 조회형 팝업이므로, 부모 화면에서 행 클릭이 가능한 권한을 전제로 한다.
- 팝업 내부에서 별도 저장/삭제 권한 판단은 하지 않는다.
## 완료 기준
1. `/oper/oldPp`에서 목록 행 클릭 시 새 브라우저 창이 열린다.
2. 새 창 URL은 `/popup/oldPpTable?...` 형태다.
3. 새 창에는 헤더, 푸터, LNB가 없다.
4. 기존 `tableModal.tsx`의 탭과 테이블이 정상 표시된다.
5. 팝업 차단 시 부모 화면이 깨지지 않는다.
6. 기존 파일 다운로드, 삭제, 목록 조회 동작은 유지된다.
## 롤백 방법
1. `oldPp/page.tsx`의 행 클릭 로직을 기존 `setIsTableModalOpen(true)`로 되돌린다.
2. 기존 `UiModal` 렌더링을 다시 사용한다.
3. 신규 `(popupLayout)` 라우트와 `windowPopup` registry는 미사용 상태로 둔다.
## 남은 리스크
- `MenuWrapper`가 `/popup` 경로에서 메뉴 권한을 어떻게 처리하는지 브라우저에서 확인해야 한다.
- 브라우저 설정에 따라 팝업이 차단될 수 있다.
- 파일명이 한글 또는 특수문자를 포함할 때 query string encode/decode 검증이 필요하다.
- `tableModal.tsx` 파일명 자체가 모달 개념을 담고 있어, 장기적으로는 `oldPp` 도메인 UI로 다시 정리할 여지가 있다.
-4
View File
@@ -1,7 +1,3 @@
import "./styles/base.css";
import "./styles/components.css";
import "@fontsource-variable/noto-sans-kr";
import { AppProvider } from "./providers";
import { cookies } from "next/headers";
+14
View File
@@ -0,0 +1,14 @@
export { openWindowPopup } from "./lib/openWindowPopup";
export {
isWindowPopupKey,
windowPopupDefinitions,
type WindowPopupKey,
} from "./model/popupDefinitions";
export { windowPopupRegistry } from "./registry";
export type {
OpenWindowPopupParams,
WindowPopupContentProps,
WindowPopupMessageType,
WindowPopupSearchParams,
} from "./model/types";
export { WINDOW_POPUP_MESSAGE_TYPE } from "./model/types";
@@ -0,0 +1,61 @@
import {
isWindowPopupKey,
windowPopupDefinitions,
type WindowPopupKey,
} from "../model/popupDefinitions";
import type { OpenWindowPopupParams } from "../model/types";
const buildQueryString = (params: OpenWindowPopupParams = {}) => {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value === null || value === undefined || value === "") return;
searchParams.set(key, String(value));
});
const queryString = searchParams.toString();
return queryString ? `?${queryString}` : "";
};
const buildPopupFeatures = (width: number, height: number) => {
const screenLeft = window.screenX ?? window.screenLeft ?? 0;
const screenTop = window.screenY ?? window.screenTop ?? 0;
const outerWidth = window.outerWidth || window.screen.width;
const outerHeight = window.outerHeight || window.screen.height;
const left = Math.max(0, screenLeft + Math.round((outerWidth - width) / 2));
const top = Math.max(0, screenTop + Math.round((outerHeight - height) / 2));
return [
`width=${width}`,
`height=${height}`,
`left=${left}`,
`top=${top}`,
"resizable=yes",
"scrollbars=yes",
"status=no",
"location=no",
"menubar=no",
"toolbar=no",
].join(",");
};
export const openWindowPopup = (
popupKey: WindowPopupKey,
params?: OpenWindowPopupParams,
) => {
if (typeof window === "undefined") return null;
if (!isWindowPopupKey(popupKey)) return null;
const definition = windowPopupDefinitions[popupKey];
const popupUrl = `/popup/${popupKey}${buildQueryString(params)}`;
const popupFeatures = buildPopupFeatures(definition.width, definition.height);
const popupWindow = window.open(
popupUrl,
`sn-popup-${popupKey}`,
popupFeatures,
);
popupWindow?.focus();
return popupWindow;
};
@@ -0,0 +1,24 @@
import type { WindowPopupMessageType } from "../model/types";
export const postPopupMessage = (
type: WindowPopupMessageType,
payload?: Record<string, string>,
) => {
if (typeof window === "undefined" || !window.opener) return;
window.opener.postMessage(
{
type,
payload,
},
window.location.origin,
);
};
export const postPopupMessageAndClose = (
type: WindowPopupMessageType,
payload?: Record<string, string>,
) => {
postPopupMessage(type, payload);
window.close();
};
@@ -0,0 +1,76 @@
import type { WindowPopupDefinition } from "./types";
export const windowPopupDefinitions = {
oldPpUpload: {
key: "oldPpUpload",
title: "노후 하수관로 정비사업 등록",
width: 860,
height: 360,
},
oldPpTable: {
key: "oldPpTable",
title: "노후 하수관로 정비사업 상세",
width: 1400,
height: 900,
requiredParams: ["fileNm", "atflId"],
},
floodPreUpload: {
key: "floodPreUpload",
title: "도시침수 예방사업 등록",
width: 860,
height: 360,
},
floodPreTable: {
key: "floodPreTable",
title: "도시침수 예방사업 상세",
width: 1400,
height: 900,
requiredParams: ["fileNm", "atflId"],
},
reportTab: {
key: "reportTab",
title: "하수도정비 기본계획보고서",
width: 1280,
height: 920,
},
reportFiles: {
key: "reportFiles",
title: "보고서 파일 목록",
width: 860,
height: 720,
requiredParams: ["clsfSystmSn"],
},
mapSearch: {
key: "mapSearch",
title: "관망도 검색",
width: 1280,
height: 980,
},
infilChart: {
key: "infilChart",
title: "침입수 분석",
width: 1360,
height: 980,
requiredParams: ["pplMngNo"],
},
rdiiChart: {
key: "rdiiChart",
title: "RDII 분석",
width: 1360,
height: 980,
requiredParams: ["pplMngNo"],
},
sscDetail: {
key: "sscDetail",
title: "중점우수토실 운영정보",
width: 1180,
height: 980,
requiredParams: ["chmbrXcnts", "chmbrYdnts"],
},
} as const satisfies Record<string, WindowPopupDefinition>;
export type WindowPopupKey = keyof typeof windowPopupDefinitions;
export const isWindowPopupKey = (key: string): key is WindowPopupKey => {
return key in windowPopupDefinitions;
};
@@ -0,0 +1,37 @@
import type { ComponentType } from "react";
export type WindowPopupSearchParams = Record<
string,
string | string[] | undefined
>;
export type WindowPopupContentProps = {
searchParams: WindowPopupSearchParams;
};
export type WindowPopupDefinition = {
key: string;
title: string;
width: number;
height: number;
requiredParams?: string[];
};
export type WindowPopupRegistryItem = WindowPopupDefinition & {
component: ComponentType<WindowPopupContentProps>;
};
export type OpenWindowPopupParams = Record<
string,
string | number | boolean | null | undefined
>;
export const WINDOW_POPUP_MESSAGE_TYPE = {
OLD_PP_UPLOAD_COMPLETED: "sn:popup:oldPpUploadCompleted",
FLOOD_PRE_UPLOAD_COMPLETED: "sn:popup:floodPreUploadCompleted",
MAP_SEARCH_SELECT_FEATURE: "sn:popup:mapSearchSelectFeature",
MAP_SEARCH_SHOW_MULTIPLE: "sn:popup:mapSearchShowMultiple",
} as const;
export type WindowPopupMessageType =
(typeof WINDOW_POPUP_MESSAGE_TYPE)[keyof typeof WINDOW_POPUP_MESSAGE_TYPE];
@@ -0,0 +1,55 @@
import { windowPopupDefinitions, type WindowPopupKey } from "./model/popupDefinitions";
import type { WindowPopupRegistryItem } from "./model/types";
import FloodPreTablePopupContent from "./ui/FloodPreTablePopupContent";
import FloodPreUploadPopupContent from "./ui/FloodPreUploadPopupContent";
import InfilChartPopupContent from "./ui/InfilChartPopupContent";
import OldPpUploadPopupContent from "./ui/OldPpUploadPopupContent";
import OldPpTablePopupContent from "./ui/OldPpTablePopupContent";
import RdiiChartPopupContent from "./ui/RdiiChartPopupContent";
import ReportFilesPopupContent from "./ui/ReportFilesPopupContent";
import ReportTabPopupContent from "./ui/ReportTabPopupContent";
import SearchModalPopupContent from "./ui/SearchModalPopupContent";
import SscDetailPopupContent from "./ui/SscDetailPopupContent";
export const windowPopupRegistry = {
oldPpUpload: {
...windowPopupDefinitions.oldPpUpload,
component: OldPpUploadPopupContent,
},
oldPpTable: {
...windowPopupDefinitions.oldPpTable,
component: OldPpTablePopupContent,
},
floodPreUpload: {
...windowPopupDefinitions.floodPreUpload,
component: FloodPreUploadPopupContent,
},
floodPreTable: {
...windowPopupDefinitions.floodPreTable,
component: FloodPreTablePopupContent,
},
reportTab: {
...windowPopupDefinitions.reportTab,
component: ReportTabPopupContent,
},
reportFiles: {
...windowPopupDefinitions.reportFiles,
component: ReportFilesPopupContent,
},
mapSearch: {
...windowPopupDefinitions.mapSearch,
component: SearchModalPopupContent,
},
infilChart: {
...windowPopupDefinitions.infilChart,
component: InfilChartPopupContent,
},
rdiiChart: {
...windowPopupDefinitions.rdiiChart,
component: RdiiChartPopupContent,
},
sscDetail: {
...windowPopupDefinitions.sscDetail,
component: SscDetailPopupContent,
},
} as const satisfies Record<WindowPopupKey, WindowPopupRegistryItem>;
@@ -0,0 +1,36 @@
import TableModal from "@/app/(with-base-layout)/oper/floodPre/tableModal";
import { windowPopupDefinitions } from "../model/popupDefinitions";
import type { WindowPopupContentProps } from "../model/types";
import PopupMissingParams from "./PopupMissingParams";
import { getPopupParamValue } from "./popupContentUtils";
export default function FloodPreTablePopupContent({
searchParams,
}: WindowPopupContentProps) {
const fileNm = getPopupParamValue(searchParams.fileNm);
const atflId = getPopupParamValue(searchParams.atflId);
const missingParams = [
!fileNm ? "fileNm" : "",
!atflId ? "atflId" : "",
].filter(Boolean);
if (missingParams.length > 0) {
return (
<PopupMissingParams
title={windowPopupDefinitions.floodPreTable.title}
missingParams={missingParams}
/>
);
}
return (
<div className="flex h-dvh flex-col overflow-hidden bg-white p-4">
<h1 className="mb-3 shrink-0 text-2xl font-semibold">
{windowPopupDefinitions.floodPreTable.title}
</h1>
<div className="min-h-0 flex-1 overflow-hidden">
<TableModal fileNm={fileNm} atflId={atflId} />
</div>
</div>
);
}
@@ -0,0 +1,42 @@
"use client";
import { postData } from "@/components/lib/api";
import OperFileUploadForm from "@/components/ui/operFileUploadForm";
import { tokenManager } from "@/entities/auth/lib/token-manager";
import { AUTH_USER_ID } from "@/shared/config";
import { postPopupMessageAndClose } from "../lib/popupMessage";
import { windowPopupDefinitions } from "../model/popupDefinitions";
import { WINDOW_POPUP_MESSAGE_TYPE } from "../model/types";
export default function FloodPreUploadPopupContent() {
const handleSubmit = async (
fileNm: string,
year: string,
quater: string,
cd: string,
id: string,
) => {
const data = await postData("v1/sbmrgPrevnt", {
mngYr: year,
mngQu: quater,
atflId: id,
atflCd: cd,
rgtr: tokenManager.getToken(AUTH_USER_ID) || null,
sbmrgPrevntBizNm: fileNm,
});
if (data) {
alert("파일이 등록되었습니다.");
postPopupMessageAndClose(WINDOW_POPUP_MESSAGE_TYPE.FLOOD_PRE_UPLOAD_COMPLETED);
}
};
return (
<div className="min-h-dvh bg-white p-4">
<OperFileUploadForm
title={windowPopupDefinitions.floodPreUpload.title}
onSubmit={handleSubmit}
/>
</div>
);
}
@@ -0,0 +1,22 @@
import InfilChart from "@/app/(map-layout)/map/components/popupComponent/chart/infilChart";
import { windowPopupDefinitions } from "../model/popupDefinitions";
import type { WindowPopupContentProps } from "../model/types";
import PopupMissingParams from "./PopupMissingParams";
import { getPopupParamValue } from "./popupContentUtils";
export default function InfilChartPopupContent({
searchParams,
}: WindowPopupContentProps) {
const pplMngNo = getPopupParamValue(searchParams.pplMngNo);
if (!pplMngNo) {
return (
<PopupMissingParams
title={windowPopupDefinitions.infilChart.title}
missingParams={["pplMngNo"]}
/>
);
}
return <InfilChart pplMngNo={pplMngNo} />;
}
@@ -0,0 +1,36 @@
import TableModal from "@/app/(with-base-layout)/oper/oldPp/tableModal";
import { windowPopupDefinitions } from "../model/popupDefinitions";
import type { WindowPopupContentProps } from "../model/types";
import PopupMissingParams from "./PopupMissingParams";
import { getPopupParamValue } from "./popupContentUtils";
export default function OldPpTablePopupContent({
searchParams,
}: WindowPopupContentProps) {
const fileNm = getPopupParamValue(searchParams.fileNm);
const atflId = getPopupParamValue(searchParams.atflId);
const missingParams = [
!fileNm ? "fileNm" : "",
!atflId ? "atflId" : "",
].filter(Boolean);
if (missingParams.length > 0) {
return (
<PopupMissingParams
title={windowPopupDefinitions.oldPpTable.title}
missingParams={missingParams}
/>
);
}
return (
<div className="flex h-dvh flex-col overflow-hidden bg-white p-4">
<h1 className="mb-3 shrink-0 text-2xl font-semibold">
{windowPopupDefinitions.oldPpTable.title}
</h1>
<div className="min-h-0 flex-1 overflow-hidden">
<TableModal fileNm={fileNm} atflId={atflId} />
</div>
</div>
);
}
@@ -0,0 +1,42 @@
"use client";
import { postData } from "@/components/lib/api";
import OperFileUploadForm from "@/components/ui/operFileUploadForm";
import { tokenManager } from "@/entities/auth/lib/token-manager";
import { AUTH_USER_ID } from "@/shared/config";
import { postPopupMessageAndClose } from "../lib/popupMessage";
import { windowPopupDefinitions } from "../model/popupDefinitions";
import { WINDOW_POPUP_MESSAGE_TYPE } from "../model/types";
export default function OldPpUploadPopupContent() {
const handleSubmit = async (
fileNm: string,
year: string,
quater: string,
cd: string,
id: string,
) => {
const data = await postData("v1/dcrpPpl", {
mngYr: year,
mngQu: quater,
atflCd: cd,
atflId: id,
rgtr: tokenManager.getToken(AUTH_USER_ID) || null,
dcrpPplMtncBizNm: fileNm,
});
if (data) {
alert("파일이 등록되었습니다.");
postPopupMessageAndClose(WINDOW_POPUP_MESSAGE_TYPE.OLD_PP_UPLOAD_COMPLETED);
}
};
return (
<div className="min-h-dvh bg-white p-4">
<OperFileUploadForm
title={windowPopupDefinitions.oldPpUpload.title}
onSubmit={handleSubmit}
/>
</div>
);
}
@@ -0,0 +1,19 @@
export default function PopupMissingParams({
title,
missingParams,
}: {
title: string;
missingParams: string[];
}) {
return (
<section className="flex min-h-dvh flex-col items-center justify-center gap-4 p-8 text-center">
<h1 className="text-2xl font-semibold">{title}</h1>
<p className="text-base text-gray-600">
팝업을 여는 데 필요한 값이 누락되었습니다.
</p>
<p className="text-sm text-gray-500">
누락된 값: {missingParams.join(", ")}
</p>
</section>
);
}
@@ -0,0 +1,22 @@
import RdiiChart from "@/app/(map-layout)/map/components/popupComponent/chart/rdiiChart";
import { windowPopupDefinitions } from "../model/popupDefinitions";
import type { WindowPopupContentProps } from "../model/types";
import PopupMissingParams from "./PopupMissingParams";
import { getPopupParamValue } from "./popupContentUtils";
export default function RdiiChartPopupContent({
searchParams,
}: WindowPopupContentProps) {
const pplMngNo = getPopupParamValue(searchParams.pplMngNo);
if (!pplMngNo) {
return (
<PopupMissingParams
title={windowPopupDefinitions.rdiiChart.title}
missingParams={["pplMngNo"]}
/>
);
}
return <RdiiChart pplMngNo={pplMngNo} />;
}
@@ -0,0 +1,121 @@
"use client";
import { useEffect, useState } from "react";
import LoadingOverlay from "@/components/ui/LoadingBar";
import { getDcData } from "@/components/lib/api";
import { UiTable } from "@common/business_components/ui";
import { windowPopupDefinitions } from "../model/popupDefinitions";
import type { WindowPopupContentProps } from "../model/types";
import PopupMissingParams from "./PopupMissingParams";
import { getPopupParamValue } from "./popupContentUtils";
type ReportDataFile = {
no?: number;
fileTitle: string;
regDt: string;
fileSn: string;
filePath: string;
};
const REPORT_FILES_HEADLIST = [
{ id: "no", title: "NO" },
{ id: "fileTitle", title: "파일명" },
{ id: "regDt", title: "등록일자" },
];
export default function ReportFilesPopupContent({
searchParams,
}: WindowPopupContentProps) {
const clsfSystmSn = getPopupParamValue(searchParams.clsfSystmSn);
const [files, setFiles] = useState<ReportDataFile[]>([]);
const [allFiles, setAllFiles] = useState<ReportDataFile[]>([]);
const [curPage, setCurPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (!clsfSystmSn) return;
const fetchFiles = async () => {
setIsLoading(true);
try {
const result = await getDcData(`/api/v1/file?clsfSystmSn=${clsfSystmSn}`);
const normalized = (result ?? []).map((item: ReportDataFile, index: number) => ({
...item,
no: index + 1,
}));
setAllFiles(normalized);
} catch (error) {
console.error("보고서 파일 목록 조회 실패:", error);
setAllFiles([]);
} finally {
setIsLoading(false);
}
};
fetchFiles();
}, [clsfSystmSn]);
useEffect(() => {
const paged = allFiles.slice((curPage - 1) * 10, curPage * 10);
setFiles(paged);
}, [allFiles, curPage]);
const handleDownload = async (fileSn: string, fileTitle: string, filePath: string) => {
if (!filePath) {
alert("filePath 정보가 없으므로 다운로드가 불가합니다.");
return;
}
try {
const responseBlob = await getDcData(
`/api/v1/fileDownload?filePath=${encodeURIComponent(filePath)}`,
"blob",
);
const objectUrl = URL.createObjectURL(responseBlob);
const anchor = document.createElement("a");
anchor.href = objectUrl;
anchor.download = fileTitle;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(objectUrl);
} catch (error) {
console.error("보고서 파일 다운로드 실패:", error);
alert("파일 다운로드 중 오류가 발생했습니다.");
}
};
if (!clsfSystmSn) {
return (
<PopupMissingParams
title={windowPopupDefinitions.reportFiles.title}
missingParams={["clsfSystmSn"]}
/>
);
}
return (
<div className="flex h-dvh flex-col overflow-hidden bg-white p-4">
<h1 className="mb-3 shrink-0 text-2xl font-semibold">
{windowPopupDefinitions.reportFiles.title}
</h1>
<div className="min-h-0 flex-1 overflow-auto">
<LoadingOverlay isLoading={isLoading} />
<UiTable
headName={`조회 결과 (${allFiles.length.toLocaleString("ko-KR")})건`}
tableData={files}
headlist={REPORT_FILES_HEADLIST}
pageSize={10}
total={allFiles.length}
onPageChange={setCurPage}
cellClick={(index) => {
if (typeof index !== "number" || !files[index]) return;
const target = files[index];
handleDownload(target.fileSn, target.fileTitle, target.filePath);
}}
/>
</div>
</div>
);
}
@@ -0,0 +1,27 @@
import ReportTab from "@/app/(map-layout)/map/searchModal/reportTab";
import { windowPopupDefinitions } from "../model/popupDefinitions";
import type { WindowPopupContentProps } from "../model/types";
import { getPopupParamValue } from "./popupContentUtils";
export default function ReportTabPopupContent({
searchParams,
}: WindowPopupContentProps) {
const ctpvNm = getPopupParamValue(searchParams.ctpvNm);
const sggNm = getPopupParamValue(searchParams.sggNm);
return (
<div className="flex h-dvh flex-col overflow-hidden bg-white p-4">
<h1 className="mb-3 shrink-0 text-2xl font-semibold">
{windowPopupDefinitions.reportTab.title}
</h1>
<div className="min-h-0 flex-1 overflow-hidden">
<ReportTab
open={true}
isSearch={false}
ctpvNm={ctpvNm || undefined}
sggNm={sggNm || undefined}
/>
</div>
</div>
);
}
@@ -0,0 +1,5 @@
import SearchModal from "@/app/(map-layout)/map/searchModal/searchModal";
export default function SearchModalPopupContent() {
return <SearchModal renderMode="popup" />;
}
@@ -0,0 +1,36 @@
import Ssc from "@/app/(map-layout)/map/components/popupComponent/Ssc";
import { windowPopupDefinitions } from "../model/popupDefinitions";
import type { WindowPopupContentProps } from "../model/types";
import PopupMissingParams from "./PopupMissingParams";
import { getPopupParamValue } from "./popupContentUtils";
export default function SscDetailPopupContent({
searchParams,
}: WindowPopupContentProps) {
const chmbrXcnts = getPopupParamValue(searchParams.chmbrXcnts);
const chmbrYdnts = getPopupParamValue(searchParams.chmbrYdnts);
const missingParams = [
!chmbrXcnts ? "chmbrXcnts" : "",
!chmbrYdnts ? "chmbrYdnts" : "",
].filter(Boolean);
if (missingParams.length > 0) {
return (
<PopupMissingParams
title={windowPopupDefinitions.sscDetail.title}
missingParams={missingParams}
/>
);
}
return (
<Ssc
featureData={null}
isMainSsc={true}
chmbrXcnts={chmbrXcnts}
chmbrYdnts={chmbrYdnts}
renderMode="popup"
/>
);
}
@@ -0,0 +1,6 @@
export const getPopupParamValue = (
value: string | string[] | undefined,
): string => {
if (Array.isArray(value)) return value[0] ?? "";
return value ?? "";
};