diff --git a/apps/sn/.env.local b/apps/sn/.env.local index 2bf41d1d9..33b8ef842 100644 --- a/apps/sn/.env.local +++ b/apps/sn/.env.local @@ -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 diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/Ssc.tsx b/apps/sn/app/(map-layout)/map/components/popupComponent/Ssc.tsx index 683fb9df0..6a11df513 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/Ssc.tsx +++ b/apps/sn/app/(map-layout)/map/components/popupComponent/Ssc.tsx @@ -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({}); const [rainMeasureData, setRainMeasureData] = useState({}); // 강우, 측정 정보 const [mntrngsData, setMntrngsData] = useState([]); // 수량/수질측정 정보 @@ -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' && (

중점우수토실 운영정보

+
+ )} - + {isMainSsc && renderMode === 'popup' && ( +
+

중점우수토실 운영정보

+ {renderModalBody()}
)} diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/SwtrmtFclt.tsx b/apps/sn/app/(map-layout)/map/components/popupComponent/SwtrmtFclt.tsx index 8b029673f..78e082414 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/SwtrmtFclt.tsx +++ b/apps/sn/app/(map-layout)/map/components/popupComponent/SwtrmtFclt.tsx @@ -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 (
setIsModalOpen(true)}>조회 + title: }} headName='디지털 자료관 연계정보' tableData={[]} @@ -43,31 +52,8 @@ const SwtrmtFclt = ({ gisObject }: { gisObject: any }) => { throw new Error("Function not implemented."); }} /> - - } - useDirectRender={true} - />
) } -export default SwtrmtFclt \ No newline at end of file +export default SwtrmtFclt diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/chart/infilChart.tsx b/apps/sn/app/(map-layout)/map/components/popupComponent/chart/infilChart.tsx index 3836b87f6..91495f412 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/chart/infilChart.tsx +++ b/apps/sn/app/(map-layout)/map/components/popupComponent/chart/infilChart.tsx @@ -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([]); @@ -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 ( - +
+

침입수 분석

+ {renderBody()} +
); }; diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/chart/rdiiChart.tsx b/apps/sn/app/(map-layout)/map/components/popupComponent/chart/rdiiChart.tsx index aa622be1b..ec1cec8b1 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/chart/rdiiChart.tsx +++ b/apps/sn/app/(map-layout)/map/components/popupComponent/chart/rdiiChart.tsx @@ -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([]); @@ -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 ( - +
+

RDII 분석

+ {renderBody()} +
); }; diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/chartModalStore.ts b/apps/sn/app/(map-layout)/map/components/popupComponent/chartModalStore.ts deleted file mode 100644 index c84fa48a7..000000000 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/chartModalStore.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { create } from 'zustand'; - -interface ModalStore { - isOpen: boolean; - setIsOpen: (open: boolean) => void; -} - -export const useInfilChartModalStore = create((set) => ({ - isOpen: false, - setIsOpen: (open: boolean) => set({ isOpen: open }), -})); - -export const useRdiiChartModalStore = create((set) => ({ - isOpen: false, - setIsOpen: (open: boolean) => set({ isOpen: open }), -})); \ No newline at end of file diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx b/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx index 11e61b006..e199e7a0d 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx +++ b/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx @@ -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(); // api 호출여부 const contentRef = useRef(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(''); // 로딩 상태 @@ -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 === '하수관로' && ( <> - - + + + )} {/* 하수도정비기본계획보고서 탭 */} {/* 📋 결과 리스트 */} @@ -214,22 +272,16 @@ const SearchModal: React.FC = () => { ); + if (renderMode === "launcher") { + return null; + } + return ( -
- +
+

관망도 검색

+
+ {renderBody()} +
); diff --git a/apps/sn/app/(map-layout)/map/searchModal/useBasicTab.ts b/apps/sn/app/(map-layout)/map/searchModal/useBasicTab.ts index 9184286ed..d914d2dbb 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/useBasicTab.ts +++ b/apps/sn/app/(map-layout)/map/searchModal/useBasicTab.ts @@ -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; diff --git a/apps/sn/app/(map-layout)/map/searchModal/useOperationTab.ts b/apps/sn/app/(map-layout)/map/searchModal/useOperationTab.ts index 669dd4cb5..3d80b54d9 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/useOperationTab.ts +++ b/apps/sn/app/(map-layout)/map/searchModal/useOperationTab.ts @@ -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 }; -}; \ No newline at end of file +}; diff --git a/apps/sn/app/(map-layout)/map/searchModal/useSearchModal.ts b/apps/sn/app/(map-layout)/map/searchModal/useSearchModal.ts index 1d317e3d4..585885521 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/useSearchModal.ts +++ b/apps/sn/app/(map-layout)/map/searchModal/useSearchModal.ts @@ -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]; diff --git a/apps/sn/app/(popupLayout)/layout.tsx b/apps/sn/app/(popupLayout)/layout.tsx new file mode 100644 index 000000000..d02ca99ac --- /dev/null +++ b/apps/sn/app/(popupLayout)/layout.tsx @@ -0,0 +1,11 @@ +export default function PopupLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+ {children} +
+ ); +} diff --git a/apps/sn/app/(popupLayout)/popup/[popupKey]/page.tsx b/apps/sn/app/(popupLayout)/popup/[popupKey]/page.tsx new file mode 100644 index 000000000..8231f4f4d --- /dev/null +++ b/apps/sn/app/(popupLayout)/popup/[popupKey]/page.tsx @@ -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>; +}; + +export default async function PopupPage({ + params, + searchParams, +}: PopupPageProps) { + const { popupKey } = await params; + const resolvedSearchParams = await searchParams; + + if (!isWindowPopupKey(popupKey)) { + return ( +
+

등록되지 않은 팝업입니다.

+

popupKey: {popupKey}

+
+ ); + } + + const PopupComponent = windowPopupRegistry[popupKey].component; + + return ; +} diff --git a/apps/sn/app/(with-base-layout)/oper/floodPre/page.tsx b/apps/sn/app/(with-base-layout)/oper/floodPre/page.tsx index 4b1db105b..3df6a1541 100644 --- a/apps/sn/app/(with-base-layout)/oper/floodPre/page.tsx +++ b/apps/sn/app/(with-base-layout)/oper/floodPre/page.tsx @@ -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(false); // 선택 초기화 상태 const [isResetPage, setIsResetPage] = useState(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} /> - {/* 파일 업로드 모달 */} - { - await registFile(file, year, quater, cd, id) - }} - /> - } - /> - {/* 엑셀 데이터 테이블 모달 */} - } - useDirectRender={true} - /> ) } -export default Page \ No newline at end of file +export default Page diff --git a/apps/sn/app/(with-base-layout)/oper/floodPre/tableModal.tsx b/apps/sn/app/(with-base-layout)/oper/floodPre/tableModal.tsx index f4acc2056..4b35069af 100644 --- a/apps/sn/app/(with-base-layout)/oper/floodPre/tableModal.tsx +++ b/apps/sn/app/(with-base-layout)/oper/floodPre/tableModal.tsx @@ -55,6 +55,12 @@ const TableModal: React.FC = ({ 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 = ({ 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 { diff --git a/apps/sn/app/(with-base-layout)/oper/oldPp/page.tsx b/apps/sn/app/(with-base-layout)/oper/oldPp/page.tsx index a078a9795..86d5d4853 100644 --- a/apps/sn/app/(with-base-layout)/oper/oldPp/page.tsx +++ b/apps/sn/app/(with-base-layout)/oper/oldPp/page.tsx @@ -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(false); // 선택 초기화 상태 const [isResetPage, setIsResetPage] = useState(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} /> - {/* 파일 업로드 모달 */} - { - await registFile(file, year, quater, cd, id) - }} - /> - } - /> - {/* 엑셀 데이터 테이블 모달 */} - } - useDirectRender={true} - /> ) } -export default Page \ No newline at end of file +export default Page diff --git a/apps/sn/app/(with-base-layout)/oper/oldPp/tableModal.tsx b/apps/sn/app/(with-base-layout)/oper/oldPp/tableModal.tsx index 13921b2c1..f22df820c 100644 --- a/apps/sn/app/(with-base-layout)/oper/oldPp/tableModal.tsx +++ b/apps/sn/app/(with-base-layout)/oper/oldPp/tableModal.tsx @@ -64,6 +64,12 @@ const TableModal: React.FC = ({ 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 = ({ 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 { diff --git a/apps/sn/app/components/lib/api/index.ts b/apps/sn/app/components/lib/api/index.ts index 73025226b..aba7a3092 100644 --- a/apps/sn/app/components/lib/api/index.ts +++ b/apps/sn/app/components/lib/api/index.ts @@ -153,7 +153,10 @@ export const getRawJson = async (url: string): Promise => { } 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 => { 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 => { } 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(); diff --git a/apps/sn/app/components/ui/modal.tsx b/apps/sn/app/components/ui/modal.tsx deleted file mode 100644 index dce0ee2b5..000000000 --- a/apps/sn/app/components/ui/modal.tsx +++ /dev/null @@ -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 = ({ isModalOpen, setIsModalOpen, title, children, closeModal, saveModal }) => { - return ( - - - - {title ? - {title} - : null} - {children} - -
- {saveModal ? : null} - -
-
-
-
- ) -} - -export default Modal \ No newline at end of file diff --git a/apps/sn/app/html/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx b/apps/sn/app/html/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx index 11e61b006..d507a4342 100644 --- a/apps/sn/app/html/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx +++ b/apps/sn/app/html/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx @@ -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(); // api 호출여부 const contentRef = useRef(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} /> -
정보를 저장하시겠습니까?
{ContentComponent && ) { + const theme = (await cookies()).get("site-theme-key"); + + return ( + + + {children} + + + ); +} diff --git a/apps/sn/app/providers.tsx b/apps/sn/app/providers.tsx new file mode 100644 index 000000000..4bbe9f864 --- /dev/null +++ b/apps/sn/app/providers.tsx @@ -0,0 +1 @@ +export { AppProvider } from "../src/app/providers"; diff --git a/apps/sn/docs/팝업레이아웃/README.md b/apps/sn/docs/팝업레이아웃/README.md new file mode 100644 index 000000000..7a3bcf9ec --- /dev/null +++ b/apps/sn/docs/팝업레이아웃/README.md @@ -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` 방식으로 설계하되, 첫 테스트 대상은 조회형 팝업이므로 최소 구현에서 제외한다. diff --git a/apps/sn/docs/팝업레이아웃/검증/001-popupLayout-검증시나리오.md b/apps/sn/docs/팝업레이아웃/검증/001-popupLayout-검증시나리오.md new file mode 100644 index 000000000..5887559a9 --- /dev/null +++ b/apps/sn/docs/팝업레이아웃/검증/001-popupLayout-검증시나리오.md @@ -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로 더 분리할지 여부 diff --git a/apps/sn/docs/팝업레이아웃/설계/001-popupLayout-라우팅-구조.md b/apps/sn/docs/팝업레이아웃/설계/001-popupLayout-라우팅-구조.md new file mode 100644 index 000000000..d5875d894 --- /dev/null +++ b/apps/sn/docs/팝업레이아웃/설계/001-popupLayout-라우팅-구조.md @@ -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 ( +
+ {children} +
+ ); +} +``` + +## 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`를 제거한다. diff --git a/apps/sn/docs/팝업레이아웃/작업계획/001-oldPp-테이블모달-윈도우팝업-전환.md b/apps/sn/docs/팝업레이아웃/작업계획/001-oldPp-테이블모달-윈도우팝업-전환.md new file mode 100644 index 000000000..dedb66b79 --- /dev/null +++ b/apps/sn/docs/팝업레이아웃/작업계획/001-oldPp-테이블모달-윈도우팝업-전환.md @@ -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로 다시 정리할 여지가 있다. diff --git a/apps/sn/src/app/index.tsx b/apps/sn/src/app/index.tsx index 692b55ef0..29d73b03f 100644 --- a/apps/sn/src/app/index.tsx +++ b/apps/sn/src/app/index.tsx @@ -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"; diff --git a/apps/sn/src/features/windowPopup/index.ts b/apps/sn/src/features/windowPopup/index.ts new file mode 100644 index 000000000..8424dcc1d --- /dev/null +++ b/apps/sn/src/features/windowPopup/index.ts @@ -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"; diff --git a/apps/sn/src/features/windowPopup/lib/openWindowPopup.ts b/apps/sn/src/features/windowPopup/lib/openWindowPopup.ts new file mode 100644 index 000000000..dbb90794a --- /dev/null +++ b/apps/sn/src/features/windowPopup/lib/openWindowPopup.ts @@ -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; +}; diff --git a/apps/sn/src/features/windowPopup/lib/popupMessage.ts b/apps/sn/src/features/windowPopup/lib/popupMessage.ts new file mode 100644 index 000000000..6ae7ad7f9 --- /dev/null +++ b/apps/sn/src/features/windowPopup/lib/popupMessage.ts @@ -0,0 +1,24 @@ +import type { WindowPopupMessageType } from "../model/types"; + +export const postPopupMessage = ( + type: WindowPopupMessageType, + payload?: Record, +) => { + if (typeof window === "undefined" || !window.opener) return; + + window.opener.postMessage( + { + type, + payload, + }, + window.location.origin, + ); +}; + +export const postPopupMessageAndClose = ( + type: WindowPopupMessageType, + payload?: Record, +) => { + postPopupMessage(type, payload); + window.close(); +}; diff --git a/apps/sn/src/features/windowPopup/model/popupDefinitions.ts b/apps/sn/src/features/windowPopup/model/popupDefinitions.ts new file mode 100644 index 000000000..b3184650d --- /dev/null +++ b/apps/sn/src/features/windowPopup/model/popupDefinitions.ts @@ -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; + +export type WindowPopupKey = keyof typeof windowPopupDefinitions; + +export const isWindowPopupKey = (key: string): key is WindowPopupKey => { + return key in windowPopupDefinitions; +}; diff --git a/apps/sn/src/features/windowPopup/model/types.ts b/apps/sn/src/features/windowPopup/model/types.ts new file mode 100644 index 000000000..8387e03c1 --- /dev/null +++ b/apps/sn/src/features/windowPopup/model/types.ts @@ -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; +}; + +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]; diff --git a/apps/sn/src/features/windowPopup/registry.tsx b/apps/sn/src/features/windowPopup/registry.tsx new file mode 100644 index 000000000..f8b247744 --- /dev/null +++ b/apps/sn/src/features/windowPopup/registry.tsx @@ -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; diff --git a/apps/sn/src/features/windowPopup/ui/FloodPreTablePopupContent.tsx b/apps/sn/src/features/windowPopup/ui/FloodPreTablePopupContent.tsx new file mode 100644 index 000000000..bbc342b70 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/FloodPreTablePopupContent.tsx @@ -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 ( + + ); + } + + return ( +
+

+ {windowPopupDefinitions.floodPreTable.title} +

+
+ +
+
+ ); +} diff --git a/apps/sn/src/features/windowPopup/ui/FloodPreUploadPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/FloodPreUploadPopupContent.tsx new file mode 100644 index 000000000..629c6be53 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/FloodPreUploadPopupContent.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/apps/sn/src/features/windowPopup/ui/InfilChartPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/InfilChartPopupContent.tsx new file mode 100644 index 000000000..f6a739f53 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/InfilChartPopupContent.tsx @@ -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 ( + + ); + } + + return ; +} diff --git a/apps/sn/src/features/windowPopup/ui/OldPpTablePopupContent.tsx b/apps/sn/src/features/windowPopup/ui/OldPpTablePopupContent.tsx new file mode 100644 index 000000000..3d3439d9c --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/OldPpTablePopupContent.tsx @@ -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 ( + + ); + } + + return ( +
+

+ {windowPopupDefinitions.oldPpTable.title} +

+
+ +
+
+ ); +} diff --git a/apps/sn/src/features/windowPopup/ui/OldPpUploadPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/OldPpUploadPopupContent.tsx new file mode 100644 index 000000000..e8e1b1e52 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/OldPpUploadPopupContent.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/apps/sn/src/features/windowPopup/ui/PopupMissingParams.tsx b/apps/sn/src/features/windowPopup/ui/PopupMissingParams.tsx new file mode 100644 index 000000000..32b8bb088 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/PopupMissingParams.tsx @@ -0,0 +1,19 @@ +export default function PopupMissingParams({ + title, + missingParams, +}: { + title: string; + missingParams: string[]; +}) { + return ( +
+

{title}

+

+ 팝업을 여는 데 필요한 값이 누락되었습니다. +

+

+ 누락된 값: {missingParams.join(", ")} +

+
+ ); +} diff --git a/apps/sn/src/features/windowPopup/ui/RdiiChartPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/RdiiChartPopupContent.tsx new file mode 100644 index 000000000..8a591e004 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/RdiiChartPopupContent.tsx @@ -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 ( + + ); + } + + return ; +} diff --git a/apps/sn/src/features/windowPopup/ui/ReportFilesPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/ReportFilesPopupContent.tsx new file mode 100644 index 000000000..de3064383 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/ReportFilesPopupContent.tsx @@ -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([]); + const [allFiles, setAllFiles] = useState([]); + 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 ( + + ); + } + + return ( +
+

+ {windowPopupDefinitions.reportFiles.title} +

+
+ + { + if (typeof index !== "number" || !files[index]) return; + const target = files[index]; + handleDownload(target.fileSn, target.fileTitle, target.filePath); + }} + /> +
+
+ ); +} diff --git a/apps/sn/src/features/windowPopup/ui/ReportTabPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/ReportTabPopupContent.tsx new file mode 100644 index 000000000..d2913df8e --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/ReportTabPopupContent.tsx @@ -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 ( +
+

+ {windowPopupDefinitions.reportTab.title} +

+
+ +
+
+ ); +} diff --git a/apps/sn/src/features/windowPopup/ui/SearchModalPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/SearchModalPopupContent.tsx new file mode 100644 index 000000000..0bbb50bbe --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/SearchModalPopupContent.tsx @@ -0,0 +1,5 @@ +import SearchModal from "@/app/(map-layout)/map/searchModal/searchModal"; + +export default function SearchModalPopupContent() { + return ; +} diff --git a/apps/sn/src/features/windowPopup/ui/SscDetailPopupContent.tsx b/apps/sn/src/features/windowPopup/ui/SscDetailPopupContent.tsx new file mode 100644 index 000000000..9e0ff6464 --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/SscDetailPopupContent.tsx @@ -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 ( + + ); + } + + return ( + + ); +} diff --git a/apps/sn/src/features/windowPopup/ui/popupContentUtils.ts b/apps/sn/src/features/windowPopup/ui/popupContentUtils.ts new file mode 100644 index 000000000..5b2c1152f --- /dev/null +++ b/apps/sn/src/features/windowPopup/ui/popupContentUtils.ts @@ -0,0 +1,6 @@ +export const getPopupParamValue = ( + value: string | string[] | undefined, +): string => { + if (Array.isArray(value)) return value[0] ?? ""; + return value ?? ""; +};