diff --git a/apps/as/src/pages/design-cnstn/model/columns.tsx b/apps/as/src/pages/design-cnstn/model/columns.tsx index 93a453314..0a050f515 100644 --- a/apps/as/src/pages/design-cnstn/model/columns.tsx +++ b/apps/as/src/pages/design-cnstn/model/columns.tsx @@ -17,8 +17,8 @@ const MAIN_STEP_LABELS = { } as const; export const PROGRESS_STATUS_COLUMNS = [ - { label: "설계자문 신청 (시군)", sttsComCd: "ER" }, - { label: "기술검토", sttsComCd: "TE" }, + { label: "설계자문 신청 (시군)", sttsComCd: "CT" }, + { label: "기술검토", sttsComCd: "CS" }, { label: "기술검토 회신", sttsComCd: "CP" }, ]; @@ -38,7 +38,7 @@ function buildProgressBadges(prgrsSttsComCd: string): ProgressBadge[] { ]; } - if (prgrsSttsComCd === "ER" || prgrsSttsComCd === "CS" || prgrsSttsComCd === "SL") { + if (prgrsSttsComCd === "CT" || prgrsSttsComCd === "CS" || prgrsSttsComCd === "SL") { return [ { label: MAIN_STEP_LABELS.apply, state: "completed" }, { label: MAIN_STEP_LABELS.techReview, state: "current" }, diff --git a/apps/common/features/src/pages/(post)/notice/ui/notice-page.tsx b/apps/common/features/src/pages/(post)/notice/ui/notice-page.tsx index 4128d6107..5456e0c3a 100644 --- a/apps/common/features/src/pages/(post)/notice/ui/notice-page.tsx +++ b/apps/common/features/src/pages/(post)/notice/ui/notice-page.tsx @@ -1,3 +1,7 @@ "use client"; -export { PostPage as NoticePage } from "@/widgets/post"; +import { PostPage } from "@/widgets/post"; + +export function NoticePage({ bbsId }: { bbsId: string }) { + return ; +} diff --git a/apps/common/features/src/widgets/post/ui/post-page.tsx b/apps/common/features/src/widgets/post/ui/post-page.tsx index 922a0e910..39157facb 100644 --- a/apps/common/features/src/widgets/post/ui/post-page.tsx +++ b/apps/common/features/src/widgets/post/ui/post-page.tsx @@ -35,7 +35,13 @@ import { columns as tableColumns } from "../model/post-table-columns"; import { createColumns as createGridViewColumns } from "../model/grid-view-table-columns"; import { SortSelect } from "./sort-select"; -function PostPage({ bbsId }: { bbsId: string }) { +function PostPage({ + bbsId, + tableCaption, +}: { + bbsId: string; + tableCaption?: string; +}) { const router = useRouter(); const pathname = usePathname(); const { confirm } = useConfirm(); @@ -116,8 +122,6 @@ function PostPage({ bbsId }: { bbsId: string }) { meta: { bbsTypeCd: bbsInfo?.bbsTypeCd }, }); - const DataTableComponent = isGridViewPage ? GridViewDataTable : DataTable; - const onPostDelete = async () => { const selectedPosts = table .getFilteredSelectedRowModel() @@ -245,11 +249,20 @@ function PostPage({ bbsId }: { bbsId: string }) { {isLoading ? ( ) : ( - + isGridViewPage ? ( + + ) : ( + + ) )} diff --git a/apps/cs/src/entities/authorization/model/authorization-interface.ts b/apps/cs/src/entities/authorization/model/authorization-interface.ts index 8c79a4a69..f44a6318c 100644 --- a/apps/cs/src/entities/authorization/model/authorization-interface.ts +++ b/apps/cs/src/entities/authorization/model/authorization-interface.ts @@ -1,10 +1,12 @@ import { z } from "zod"; + import type { PageableList, PageableParams } from "@/shared/model"; export interface AuthorizationsParams extends PageableParams { keywordType?: string; keyword?: string; authorizationName?: string; + urlPatternValue?: string; httpMethodCode?: string; useYn?: string; } diff --git a/apps/cs/src/pages/module/ui/module-page.tsx b/apps/cs/src/pages/module/ui/module-page.tsx index d97f213fb..ae5a8b8e2 100644 --- a/apps/cs/src/pages/module/ui/module-page.tsx +++ b/apps/cs/src/pages/module/ui/module-page.tsx @@ -23,6 +23,7 @@ import { columns } from "../model/authorizations-table-columns"; const DEFAULT_PARAMS = { authorizationName: "", + urlPatternValue: "", httpMethodCode: "", sort: "regDt,desc", page: 0, @@ -36,6 +37,9 @@ function ModulePage() { const [authorizationName, setAuthorizationName] = useState( params.authorizationName, ); + const [urlPatternValue, setUrlPatternValue] = useState( + params.urlPatternValue, + ); const [httpMethodCode, setHttpMethodCode] = useState(params.httpMethodCode); const query = useQuery({ @@ -43,6 +47,7 @@ function ModulePage() { queryFn: () => AuthorizationsApi.getAuthorizationsList({ authorizationName: params.authorizationName, + urlPatternValue: params.urlPatternValue, httpMethodCode: params.httpMethodCode, sort: params.sort, page: params.page, @@ -63,11 +68,12 @@ function ModulePage() { const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - setParams({ authorizationName, httpMethodCode, page: 0 }); + setParams({ authorizationName, urlPatternValue, httpMethodCode, page: 0 }); }; const handleReset = () => { setAuthorizationName(""); + setUrlPatternValue(""); setHttpMethodCode(""); resetParams(); }; @@ -113,12 +119,23 @@ function ModulePage() { API명 setAuthorizationName(e.target.value)} /> +
+ + setUrlPatternValue(e.target.value)} + /> +
- - - )} - - - - ); -}; - -export default Map; +/** 하수관망도 route 진입점. 지도 상호작용은 Client 조립 컴포넌트가 소유한다. */ +export default function Page() { + return ; +} diff --git a/apps/sn/app/(map-layout)/map/searchModal/basicTab.tsx b/apps/sn/app/(map-layout)/map/search-modal/basic-tab.tsx similarity index 87% rename from apps/sn/app/(map-layout)/map/searchModal/basicTab.tsx rename to apps/sn/app/(map-layout)/map/search-modal/basic-tab.tsx index 0d1ebe18d..8e066466c 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/basicTab.tsx +++ b/apps/sn/app/(map-layout)/map/search-modal/basic-tab.tsx @@ -11,15 +11,15 @@ import React from "react"; import { SelectBox } from "@common/business_components/ui/select"; import { Label } from "@common/components"; -import SwagePpl from "./swagePpl"; -import SwagePrcsFclt from "./swagePrcsFclt"; -import SwagePrcsZone from "./swagePrcsZone"; -import FldngWtlvMap from "./fldngWtlvMap"; -import SwageMhl from "./swageMhl"; -import SwagePmstn from "./swagePmstn"; +import SwagePpl from "./swage-ppl"; +import SwagePrcsFclt from "./swage-prcs-fclt"; +import SwagePrcsZone from "./swage-prcs-zone"; +import FldngWtlvMap from "./fldng-wtlv-map"; +import SwageMhl from "./swage-mhl"; +import SwagePmstn from "./swage-pmstn"; import Stofcbr from "./stofcbr"; -import useSearchModalStore from "@/store/useSearchModalStore"; -import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel"; +import useSearchModalStore from "@/features/map-search/model/search-store"; +import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel"; // 기본정보 '구분' 선택 목록 export const BASIC_TYPES = [ diff --git a/apps/sn/app/(map-layout)/map/searchModal/fldngWtlvMap.tsx b/apps/sn/app/(map-layout)/map/search-modal/fldng-wtlv-map.tsx similarity index 92% rename from apps/sn/app/(map-layout)/map/searchModal/fldngWtlvMap.tsx rename to apps/sn/app/(map-layout)/map/search-modal/fldng-wtlv-map.tsx index 47248ab07..659791312 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/fldngWtlvMap.tsx +++ b/apps/sn/app/(map-layout)/map/search-modal/fldng-wtlv-map.tsx @@ -1,8 +1,8 @@ import React from "react"; import { Button, Input, Label } from "@common/components"; -import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox"; -import useSearchModalStore from "../../../store/useSearchModalStore"; -import useSearchModal from "./useSearchModal"; +import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpv-sgg-prcs-fclt-select-box"; +import useSearchModalStore from "@/features/map-search/model/search-store"; +import useSearchModal from "./use-search-modal"; // 침수흔적도용 CQL 필터 생성 export const fldngWtlvMapCqlFilters = () => { diff --git a/apps/sn/app/(map-layout)/map/searchModal/operationTab.tsx b/apps/sn/app/(map-layout)/map/search-modal/operation-tab.tsx similarity index 89% rename from apps/sn/app/(map-layout)/map/searchModal/operationTab.tsx rename to apps/sn/app/(map-layout)/map/search-modal/operation-tab.tsx index ea8a432af..80f04b3d9 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/operationTab.tsx +++ b/apps/sn/app/(map-layout)/map/search-modal/operation-tab.tsx @@ -12,14 +12,14 @@ * - Enter 키 입력 시 자동 조회 지원 */ import React from "react"; -import { YearRangeSelectBox } from "@/components/ui/YearRangeSelectBox"; +import { YearRangeSelectBox } from "@/components/ui/year-range-select-box"; import { SelectBox } from "@common/business_components/ui/select"; import { Button, Input, Label } from "@common/components"; -import useStore from "@/store/useStore"; -import useSearchModalStore from "@/store/useSearchModalStore"; -import useSearchModal from "./useSearchModal"; -import useOperationTab from "./useOperationTab"; -import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel"; +import useStore from "@/entities/reference-data/model/reference-data-store"; +import useSearchModalStore from "@/features/map-search/model/search-store"; +import useSearchModal from "./use-search-modal"; +import useOperationTab from "./use-operation-tab"; +import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel"; const OperationTab: React.FC = () => { const { BIZ_TY_CD } = useStore(); diff --git a/apps/sn/app/(map-layout)/map/searchModal/reportTab.tsx b/apps/sn/app/(map-layout)/map/search-modal/report-tab.tsx similarity index 95% rename from apps/sn/app/(map-layout)/map/searchModal/reportTab.tsx rename to apps/sn/app/(map-layout)/map/search-modal/report-tab.tsx index 385fa99c7..b3751f439 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/reportTab.tsx +++ b/apps/sn/app/(map-layout)/map/search-modal/report-tab.tsx @@ -15,16 +15,16 @@ "use client"; import React, { useEffect, useMemo, useRef, useState } from "react"; import { Button, Label } from "@common/components"; -import { YearRangeSelectBox } from "@/components/ui/YearRangeSelectBox"; +import { YearRangeSelectBox } from "@/components/ui/year-range-select-box"; import { SelectBox } from "@common/business_components/ui/select"; -import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable"; -import useReportTab from "./useReportTab"; -import useStore from "@/store/useStore"; -import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox"; -import useSearchModalStore from "@/store/useSearchModalStore"; -import { useReportTabStore } from "@/store/useReportTabStore"; -import type { FlattenedFolder, ReportDataFile } from "./useReportTab"; -import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel"; +import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table"; +import useReportTab from "./use-report-tab"; +import useStore from "@/entities/reference-data/model/reference-data-store"; +import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpv-sgg-prcs-fclt-select-box"; +import useSearchModalStore from "@/features/map-search/model/search-store"; +import { useReportTabStore } from "@/features/map-search/model/report-store"; +import type { FlattenedFolder, ReportDataFile } from "@/features/map-search/model/report-types"; +import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel"; // 하수도정비 기본계획 보고서 export const reportTableHeadlist = [ diff --git a/apps/sn/app/(map-layout)/map/searchModal/searchModal.tsx b/apps/sn/app/(map-layout)/map/search-modal/search-modal.tsx similarity index 88% rename from apps/sn/app/(map-layout)/map/searchModal/searchModal.tsx rename to apps/sn/app/(map-layout)/map/search-modal/search-modal.tsx index 7e063073f..41cabc741 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/searchModal.tsx +++ b/apps/sn/app/(map-layout)/map/search-modal/search-modal.tsx @@ -12,33 +12,30 @@ * - 로딩 오버레이 및 선택 초기화 지원 */ "use client"; -import LoadingOverlay from "@/components/ui/LoadingBar"; +import LoadingOverlay from "@/components/ui/loading-bar"; import { UITab } from "@common/business_components/ui/tab"; import { StandardPopupLayout } from "@common/business_components"; import { Button } from "@common/components/ui"; import React, { useEffect, useMemo, useRef } from "react"; -import useSearchModalStore from "@/store/useSearchModalStore"; +import useSearchModalStore from "@/features/map-search/model/search-store"; import { useAlertStore } from "@/shared/lib"; -import { windowPopupDefinitions } from "@/features/windowPopup/model/popupDefinitions"; -import { - ExclnSewagePumpCol2, - FldngWtlvMapCol2, - FldngDpthMapCol2, - 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"; +import { windowPopupDefinitions } from "@/features/window-popup/model/popup-definitions"; +import { ExclnSewagePumpCol2 } from "@/entities/facility/model/pump-columns"; +import { FldngWtlvMapCol2, FldngDpthMapCol2 } from "@/entities/facility/model/flood-columns"; +import { MsSwageMhlCol2 } from "@/entities/facility/model/manhole-columns"; +import { MsSwagePplCol2 } from "@/entities/facility/model/pipe-columns"; +import { stofcbrCol2 } from "@/entities/facility/model/overflow-columns"; +import { SwtrmtFcltCol2 } from "@/entities/facility/model/treatment-facility-columns"; +import { SwtrmtPrcsZoneCol2 } from "@/entities/facility/model/treatment-zone-columns"; +import BasicTab, { BASIC_TYPES } from "./basic-tab"; +import OperationTab from "./operation-tab"; +import ReportTab from "./report-tab"; +import useSearchModal from "./use-search-modal"; +import useBasicTab from "./use-basic-tab"; +import useOperationTab from "./use-operation-tab"; +import { openWindowPopup } from "@/features/window-popup/lib/open-window-popup"; import type { UiHeadProps } from "@common/business_components/ui/table"; -import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable"; +import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table"; type SearchModalProps = { renderMode?: "launcher" | "popup"; diff --git a/apps/sn/app/(map-layout)/map/searchModal/stofcbr.tsx b/apps/sn/app/(map-layout)/map/search-modal/stofcbr.tsx similarity index 89% rename from apps/sn/app/(map-layout)/map/searchModal/stofcbr.tsx rename to apps/sn/app/(map-layout)/map/search-modal/stofcbr.tsx index 610735a2b..b5c17ad31 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/stofcbr.tsx +++ b/apps/sn/app/(map-layout)/map/search-modal/stofcbr.tsx @@ -10,12 +10,12 @@ * - Zustand를 통한 상태 관리 및 필터 값 저장 * - Enter 키 입력 시 자동 조회 지원 */ -import { YearRangeSelectBox } from "@/components/ui/YearRangeSelectBox"; +import { YearRangeSelectBox } from "@/components/ui/year-range-select-box"; import { Button } from "@common/components"; import React from "react"; -import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox"; -import useSearchModalStore from "../../../store/useSearchModalStore"; -import useSearchModal from "./useSearchModal"; +import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpv-sgg-prcs-fclt-select-box"; +import useSearchModalStore from "@/features/map-search/model/search-store"; +import useSearchModal from "./use-search-modal"; export const stofcbrCqlFilters = () => { const { diff --git a/apps/sn/app/(map-layout)/map/searchModal/swageMhl.tsx b/apps/sn/app/(map-layout)/map/search-modal/swage-mhl.tsx similarity index 92% rename from apps/sn/app/(map-layout)/map/searchModal/swageMhl.tsx rename to apps/sn/app/(map-layout)/map/search-modal/swage-mhl.tsx index e13164602..6920cc3e7 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/swageMhl.tsx +++ b/apps/sn/app/(map-layout)/map/search-modal/swage-mhl.tsx @@ -12,13 +12,13 @@ * - Enter 키 입력 시 자동 조회 지원 */ import React from "react"; -import { YearRangeSelectBox } from "@/components/ui/YearRangeSelectBox"; +import { YearRangeSelectBox } from "@/components/ui/year-range-select-box"; import { Button, Input, Label } from "@common/components"; -import { MS_SWAGE_MHL_USE_CODES } from "../components/common/table"; +import { useFacilityCodeCatalog } from "@/entities/facility/lib/use-facility-code-catalog"; import { SelectBox } from "@common/business_components/ui/select"; -import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox"; -import useSearchModalStore from "../../../store/useSearchModalStore"; -import useSearchModal from "./useSearchModal"; +import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpv-sgg-prcs-fclt-select-box"; +import useSearchModalStore from "@/features/map-search/model/search-store"; +import useSearchModal from "./use-search-modal"; export const swageMhlCqlFilters = () => { const { @@ -60,6 +60,7 @@ export const swageMhlCqlFilters = () => { }; const SwageMhl: React.FC = () => { + const { MS_SWAGE_MHL_USE_CODES } = useFacilityCodeCatalog(); const { var1, setVar1, diff --git a/apps/sn/app/(map-layout)/map/searchModal/swagePmstn.tsx b/apps/sn/app/(map-layout)/map/search-modal/swage-pmstn.tsx similarity index 95% rename from apps/sn/app/(map-layout)/map/searchModal/swagePmstn.tsx rename to apps/sn/app/(map-layout)/map/search-modal/swage-pmstn.tsx index 4d18aeef0..7a9be8110 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/swagePmstn.tsx +++ b/apps/sn/app/(map-layout)/map/search-modal/swage-pmstn.tsx @@ -11,12 +11,12 @@ * - Zustand를 통한 상태 관리 및 필터 값 저장 * - Enter 키 입력 시 자동 조회 지원 */ -import { YearRangeSelectBox } from "@/components/ui/YearRangeSelectBox"; +import { YearRangeSelectBox } from "@/components/ui/year-range-select-box"; import { Button, Input, Label } from "@common/components"; import React from "react"; -import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox"; -import useSearchModalStore from "../../../store/useSearchModalStore"; -import useSearchModal from "./useSearchModal"; +import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpv-sgg-prcs-fclt-select-box"; +import useSearchModalStore from "@/features/map-search/model/search-store"; +import useSearchModal from "./use-search-modal"; export const swagePmstnCqlFilters = () => { const { diff --git a/apps/sn/app/(map-layout)/map/searchModal/swagePpl.tsx b/apps/sn/app/(map-layout)/map/search-modal/swage-ppl.tsx similarity index 94% rename from apps/sn/app/(map-layout)/map/searchModal/swagePpl.tsx rename to apps/sn/app/(map-layout)/map/search-modal/swage-ppl.tsx index f694dc590..39f8c7cd5 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/swagePpl.tsx +++ b/apps/sn/app/(map-layout)/map/search-modal/swage-ppl.tsx @@ -11,14 +11,14 @@ * - Zustand를 통한 상태 관리 및 필터 값 저장 * - Enter 키 입력 시 자동 조회 지원 */ -import { YearRangeSelectBox } from "@/components/ui/YearRangeSelectBox"; +import { YearRangeSelectBox } from "@/components/ui/year-range-select-box"; import { SelectBox } from "@common/business_components/ui/select"; import { Button, Input, Label } from "@common/components"; import React from "react"; -import { MS_SWAGE_PPL_USE_CODES } from "../components/common/table"; -import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox"; -import useSearchModalStore from "../../../store/useSearchModalStore"; -import useSearchModal from "./useSearchModal"; +import { useFacilityCodeCatalog } from "@/entities/facility/lib/use-facility-code-catalog"; +import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpv-sgg-prcs-fclt-select-box"; +import useSearchModalStore from "@/features/map-search/model/search-store"; +import useSearchModal from "./use-search-modal"; // 하수관로용 CQL 필터 생성 export const swagePplCqlFilters = () => { @@ -67,6 +67,7 @@ export const swagePplCqlFilters = () => { }; const SwagePpl: React.FC = () => { + const { MS_SWAGE_PPL_USE_CODES } = useFacilityCodeCatalog(); const { var1, setVar1, diff --git a/apps/sn/app/(map-layout)/map/searchModal/swagePrcsFclt.tsx b/apps/sn/app/(map-layout)/map/search-modal/swage-prcs-fclt.tsx similarity index 94% rename from apps/sn/app/(map-layout)/map/searchModal/swagePrcsFclt.tsx rename to apps/sn/app/(map-layout)/map/search-modal/swage-prcs-fclt.tsx index 1f97b567e..bff16a463 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/swagePrcsFclt.tsx +++ b/apps/sn/app/(map-layout)/map/search-modal/swage-prcs-fclt.tsx @@ -12,11 +12,11 @@ * - Enter 키 입력 시 자동 조회 지원 */ import React from "react"; -import { YearRangeSelectBox } from "@/components/ui/YearRangeSelectBox"; +import { YearRangeSelectBox } from "@/components/ui/year-range-select-box"; import { Button, Input, Label } from "@common/components"; -import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox"; -import useSearchModalStore from "../../../store/useSearchModalStore"; -import useSearchModal from "./useSearchModal"; +import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpv-sgg-prcs-fclt-select-box"; +import useSearchModalStore from "@/features/map-search/model/search-store"; +import useSearchModal from "./use-search-modal"; // 하수처리시설용 CQL 필터 생성 export const swagePrcsFcltCqlFilters = () => { diff --git a/apps/sn/app/(map-layout)/map/searchModal/swagePrcsZone.tsx b/apps/sn/app/(map-layout)/map/search-modal/swage-prcs-zone.tsx similarity index 94% rename from apps/sn/app/(map-layout)/map/searchModal/swagePrcsZone.tsx rename to apps/sn/app/(map-layout)/map/search-modal/swage-prcs-zone.tsx index 9993a6c4c..5c2d1bbdc 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/swagePrcsZone.tsx +++ b/apps/sn/app/(map-layout)/map/search-modal/swage-prcs-zone.tsx @@ -11,10 +11,10 @@ * - Enter 키 입력 시 자동 조회 지원 */ import React from "react"; -import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox"; +import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpv-sgg-prcs-fclt-select-box"; import { Button, Input, Label } from "@common/components"; -import useSearchModalStore from "../../../store/useSearchModalStore"; -import useSearchModal from "./useSearchModal"; +import useSearchModalStore from "@/features/map-search/model/search-store"; +import useSearchModal from "./use-search-modal"; // 하수처리구역용 CQL 필터 생성 export const swagePrcsZoneCqlFilters = () => { diff --git a/apps/sn/app/(map-layout)/map/searchModal/useBasicTab.ts b/apps/sn/app/(map-layout)/map/search-modal/use-basic-tab.ts similarity index 89% rename from apps/sn/app/(map-layout)/map/searchModal/useBasicTab.ts rename to apps/sn/app/(map-layout)/map/search-modal/use-basic-tab.ts index eb7c07465..ba922734b 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/useBasicTab.ts +++ b/apps/sn/app/(map-layout)/map/search-modal/use-basic-tab.ts @@ -13,30 +13,24 @@ * - 관망도 시설물 목록 조회: GET /api/v1/fclts (sewerage-service) */ import { requestGridExcel } from "@/shared/api/grid-export"; -import { - MS_SWAGE_PPL_USE_CODES, - MS_SWAGE_PPL_CODES, - MS_SWAGE_PPL_SHAPE, - MS_SWAGE_MHL_CODES, - MS_SWAGE_MHL_USE_CODES, -} from "../components/common/table"; -import { getData } from "@/components/lib/api"; -import { BASIC_TYPES } from "./basicTab"; -import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue"; -import useStore from "@/store/useStore"; +import { useFacilityCodeCatalog } from "@/entities/facility/lib/use-facility-code-catalog"; +import { getData } from "@/shared/api/sewerage-api"; +import { BASIC_TYPES } from "./basic-tab"; +import { formatDisplayData } from "@/components/lib/utils/format-display-value"; +import useStore from "@/entities/reference-data/model/reference-data-store"; import GisApp from "@common/business_components/ui/map/lib/gis.core"; import { useConfirm } from "@frontend-opensource/use-react-hooks"; import { useAlertStore } from "@/shared/lib"; -import useSearchModalStore from "@/store/useSearchModalStore"; -import { swagePplCqlFilters } from "./swagePpl"; -import { swagePrcsFcltCqlFilters } from "./swagePrcsFclt"; -import { swagePrcsZoneCqlFilters } from "./swagePrcsZone"; -import { fldngWtlvMapCqlFilters } from "./fldngWtlvMap"; -import { swageMhlCqlFilters } from "./swageMhl"; -import { swagePmstnCqlFilters } from "./swagePmstn"; +import useSearchModalStore from "@/features/map-search/model/search-store"; +import { swagePplCqlFilters } from "./swage-ppl"; +import { swagePrcsFcltCqlFilters } from "./swage-prcs-fclt"; +import { swagePrcsZoneCqlFilters } from "./swage-prcs-zone"; +import { fldngWtlvMapCqlFilters } from "./fldng-wtlv-map"; +import { swageMhlCqlFilters } from "./swage-mhl"; +import { swagePmstnCqlFilters } from "./swage-pmstn"; import { stofcbrCqlFilters } from "./stofcbr"; -import { postPopupMessageAndClose } from "@/features/windowPopup/lib/popupMessage"; -import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types"; +import { postPopupMessageAndClose } from "@/features/window-popup/lib/popup-message"; +import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types"; const isPopupWindow = () => typeof window !== "undefined" && @@ -44,6 +38,7 @@ const isPopupWindow = () => window.location.pathname.startsWith("/popup/"); export default function useBasicTab() { + const { MS_SWAGE_PPL_USE_CODES, MS_SWAGE_PPL_CODES, MS_SWAGE_PPL_SHAPE, MS_SWAGE_MHL_CODES, MS_SWAGE_MHL_USE_CODES } = useFacilityCodeCatalog(); const alert = useAlertStore((state) => state.setMessage); const { confirm } = useConfirm(); const { diff --git a/apps/sn/app/(map-layout)/map/searchModal/useOperationTab.ts b/apps/sn/app/(map-layout)/map/search-modal/use-operation-tab.ts similarity index 96% rename from apps/sn/app/(map-layout)/map/searchModal/useOperationTab.ts rename to apps/sn/app/(map-layout)/map/search-modal/use-operation-tab.ts index 11130a4a8..50ce10c23 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/useOperationTab.ts +++ b/apps/sn/app/(map-layout)/map/search-modal/use-operation-tab.ts @@ -16,19 +16,16 @@ */ import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel"; import { fetchAllPages } from "@/shared/lib/fetch-all-pages"; -import { - MS_SWAGE_PPL_CODES, - MS_SWAGE_PPL_USE_CODES, -} from "../components/common/table"; -import { getData } from "@/components/lib/api"; -import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue"; +import { useFacilityCodeCatalog } from "@/entities/facility/lib/use-facility-code-catalog"; +import { getData } from "@/shared/api/sewerage-api"; +import { formatDisplayData } from "@/components/lib/utils/format-display-value"; import GisApp from "@common/business_components/ui/map/lib/gis.core"; import { useConfirm } from "@frontend-opensource/use-react-hooks"; import { useAlertStore } from "@/shared/lib"; -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"; +import useSearchModalStore from "@/features/map-search/model/search-store"; +import useStore from "@/entities/reference-data/model/reference-data-store"; +import { postPopupMessageAndClose } from "@/features/window-popup/lib/popup-message"; +import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types"; import { useRef } from "react"; const isPopupWindow = () => @@ -37,6 +34,8 @@ const isPopupWindow = () => window.location.pathname.startsWith("/popup/"); export default function useOperationTab() { + const { MS_SWAGE_PPL_CODES, MS_SWAGE_PPL_USE_CODES } = + useFacilityCodeCatalog(); const OPERATION_TABLE_SIZE = 5; const alert = useAlertStore((state) => state.setMessage); const { confirm } = useConfirm(); @@ -173,8 +172,7 @@ export default function useOperationTab() { ); return { ...formattedItem, - rowNumber: - (curPage - 1) * OPERATION_TABLE_SIZE + index + 1, + rowNumber: (curPage - 1) * OPERATION_TABLE_SIZE + index + 1, bizTypeCd: matched ? matched.lwpoCdNm : item.bizTypeCd, }; }, diff --git a/apps/sn/app/(map-layout)/map/searchModal/useReportTab.ts b/apps/sn/app/(map-layout)/map/search-modal/use-report-tab.ts similarity index 91% rename from apps/sn/app/(map-layout)/map/searchModal/useReportTab.ts rename to apps/sn/app/(map-layout)/map/search-modal/use-report-tab.ts index 084c41113..a8dfd4c4f 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/useReportTab.ts +++ b/apps/sn/app/(map-layout)/map/search-modal/use-report-tab.ts @@ -13,33 +13,14 @@ * - 보고서 파일 조회: GET /api/v1/file (dc-service) * - 파일 다운로드: GET /api/v1/fileDownload (dc-service) */ -import { getDcData } from '@/components/lib/api'; +import { getDcData } from "@/shared/api/external-service-api"; import { fileDownload } from '@/utils'; -import { useReportTabStore } from '@/store/useReportTabStore'; -import useSearchModalStore from '@/store/useSearchModalStore'; +import { useReportTabStore } from "@/features/map-search/model/report-store"; +import useSearchModalStore from "@/features/map-search/model/search-store"; import { useAlertStore } from '@/shared/lib'; -export type Folder = { - clsfSystmSn: string; - upClsfSystmSn: string; - clsfSystmNm: string; - children: Folder[]; -}; -export type FlattenedFolder = { - [key: string]: string | number | undefined; -}; -export type SelectDataType = { - text: string; - val: string; - sido?: string; // 시도 코드 (선택 사항) -} -export type ReportDataFile = { - no: number; - fileTitle: string; - regDt: string; - fileSn: string; - filePath: string; -} + +import type { Folder, FlattenedFolder, SelectDataType, ReportDataFile } from "@/features/map-search/model/report-types"; const getStringValue = (value: string | number | undefined) => String(value ?? ''); diff --git a/apps/sn/app/(map-layout)/map/searchModal/useSearchModal.ts b/apps/sn/app/(map-layout)/map/search-modal/use-search-modal.ts similarity index 92% rename from apps/sn/app/(map-layout)/map/searchModal/useSearchModal.ts rename to apps/sn/app/(map-layout)/map/search-modal/use-search-modal.ts index 6ee9ac429..518e7913a 100644 --- a/apps/sn/app/(map-layout)/map/searchModal/useSearchModal.ts +++ b/apps/sn/app/(map-layout)/map/search-modal/use-search-modal.ts @@ -12,18 +12,15 @@ * 🔗 사용 API * - 유지관리 정보 목록 조회: GET /api/v1/bizInfo (sewerage-service) */ -import { getData } from "@/components/lib/api"; -import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue"; -import useStore from "../../../store/useStore"; +import { getData } from "@/shared/api/sewerage-api"; +import { formatDisplayData } from "@/components/lib/utils/format-display-value"; +import useStore from "@/entities/reference-data/model/reference-data-store"; import GisApp from "@common/business_components/ui/map/lib/gis.core"; -import useSearchModalStore, { - SEARCH_MODAL_DEFAULT_BASIC_TYPE, - SEARCH_MODAL_DEFAULT_WORK_TYPE, -} 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"; +import useSearchModalStore, { SEARCH_MODAL_DEFAULT_BASIC_TYPE, SEARCH_MODAL_DEFAULT_WORK_TYPE } from "@/features/map-search/model/search-store"; +import useBasicTab from "./use-basic-tab"; +import useOperationTab from "./use-operation-tab"; +import { postPopupMessageAndClose } from "@/features/window-popup/lib/popup-message"; +import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types"; const isPopupWindow = () => typeof window !== "undefined" && diff --git a/apps/sn/app/(map-layout)/map/ui/map-control-styles.tsx b/apps/sn/app/(map-layout)/map/ui/map-control-styles.tsx new file mode 100644 index 000000000..37b05ee4b --- /dev/null +++ b/apps/sn/app/(map-layout)/map/ui/map-control-styles.tsx @@ -0,0 +1,61 @@ +"use client"; + +/** 지도 route에만 적용하는 기존 전역 컨트롤 CSS. 선택자와 스타일 값은 유지한다. */ +export default function MapControlStyles() { + return ( + + ); +} diff --git a/apps/sn/app/(map-layout)/map/ui/map-page-client.tsx b/apps/sn/app/(map-layout)/map/ui/map-page-client.tsx new file mode 100644 index 000000000..7642a284b --- /dev/null +++ b/apps/sn/app/(map-layout)/map/ui/map-page-client.tsx @@ -0,0 +1,3369 @@ +/** + * ➡️ 경로 : 하수관망도 메인 페이지 + * + * 📄 Map Page + * 지도 페이지 컴포넌트 + * + * - OpenLayers 기반 GIS 지도 렌더링 및 상호작용 + * - 레이어 트리 데이터 관리 및 동적 레이어 표시/숨김 + * - 피처 검색 및 선택 기능 (SearchModal, ProcSearchBar) + * - 피처 클릭 이벤트 처리 및 상세 정보 팝업 표시 + * - 수심(WaterDepth) 레이어 애니메이션 재생 기능 + * - Zustand를 통한 검색 모달 상태 관리 + * + * 🔗 사용 API + * - 레이어 설정 조회: GET /api/v1/map/lyrStngs/{lyrGrp} (sewerage-service) + */ + +"use client"; +import { useFloodPredictionMap } from "@/features/fldng-predc-prst/lib/use-flood-prediction-map"; +import { + FLOOD_DNGR_MAP_LAYER_NAME, + FLOOD_DNGR_MAP_LAYER_TYPE, +} from "../components/flood-dngr-map/flood-dngr-map-types"; +import { + findAgedPipeLayer, + prepareAgedPipeLayerRows, + validateAgedPipeLayer, +} from "../components/aged-pipe/aged-pipe-layer"; +import { useFloodHistoryClick } from "../lib/use-flood-history-click"; +import { useFacilityCodeCatalog } from "@/entities/facility/lib/use-facility-code-catalog"; +import { useFloodHistoryVisibility } from "../lib/use-flood-history-visibility"; +import { useFloodHistorySelection } from "../lib/use-flood-history-selection"; +import { useSinkholeLayers } from "../lib/use-sinkhole-layers"; +import { useExmnResultMap } from "../components/exmn/use-exmn-result-map"; +import { + createExmnPipeStyle, + createExmnManholeStyle, +} from "../lib/analysis-layer-styles"; +import { useGisEditSelection } from "../lib/use-gis-edit-selection"; +import { useGisEditDraw } from "../lib/use-gis-edit-draw"; +import { useGisEditTransform } from "../lib/use-gis-edit-transform"; +import { useGisEditPreview } from "../lib/use-gis-edit-preview"; +import { useGisEditPipeSnap } from "../lib/use-gis-edit-pipe-snap"; +import { useGisEditOriginalMask } from "../lib/use-gis-edit-original-mask"; +import { useWaterRiskLayer } from "../lib/use-water-risk-layer"; +import { useFlowDirectionLayer } from "@/features/map-layers/lib/use-flow-direction-layer"; +import dynamic from "next/dynamic"; +import { type VertexHandleMeta } from "@/features/gis-edit/model/vertex-handles"; +import { + type RightPanelTab, + findRightPanelTabOverflowCandidate, + removeRightPanelTabGroup, + RightPanelBookmarkTabs, +} from "../components/right-panel/right-panel-bookmark-tabs"; +import { + FLOW_DIRECTION_DEFAULT_MIN_ZOOM, + getFlowDirectionModeByPathname, + SWAGE_PRCS_FCLT_LAYER_TYPE, + type SwagePrcsFcltLayerMode, + getSwagePrcsFcltLayerMode, + normalizeFlowDirectionLayerInfos, + normalizeRainObservationLayerInteraction, + getFlowDirectionMinZoom, +} from "@/features/map-layers"; +import { ListTree } from "lucide-react"; +import { + Fragment, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, + type UIEvent, +} from "react"; +import { usePathname, useSearchParams } from "next/navigation"; +import GeoJSON from "ol/format/GeoJSON.js"; +import { openWindowPopup } from "@/features/window-popup/lib/open-window-popup"; +import { + isWindowPopupKey, + type WindowPopupKey, +} from "@/features/window-popup/model/popup-definitions"; +import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types"; +import Gis from "@common/business_components/ui/map/Gis"; +import GisApp from "@common/business_components/ui/map/lib/gis.core"; +import MapSearchBar from "./map-search-bar"; +import { Button } from "@common/components"; +import { getData, getRawJson, postRawJson } from "@/shared/api/sewerage-api"; +import useSearchModalStore from "@/features/map-search/model/search-store"; +import useStore from "@/entities/reference-data/model/reference-data-store"; +import { useAlertStore } from "@/shared/lib"; +import { useUserStore } from "@/shared/lib/use-user-store"; +import AgedPipeLayerSettingPanel from "../components/aged-pipe/aged-pipe-layer-setting-panel"; +import GisEditSessionPanel from "../components/gis-edit/gis-edit-session-panel"; +import GisEditToolbar from "../components/gis-edit/gis-edit-toolbar"; +import GisEditPipeSessionPopup from "../components/gis-edit/gis-edit-pipe-session-popup"; +import { useGisEdit } from "../components/gis-edit/use-gis-edit"; +import type { + GisEditPipeBasicInfo, + GisEditSessionSnapshot, +} from "../components/gis-edit/gis-edit-types"; +import { + PIPE_ANALYSIS_DETAIL_ROW_SELECT_MESSAGE, + type PipeAnalysisDetailRow, +} from "../components/pipe-analysis/pipe-analysis-utils"; +import { + AGED_PIPE_DEFAULT_FILTERS, + AGED_PIPE_GUIDE_MIN_ZOOM, + AGED_PIPE_LAYER_TYPE, + AGED_PIPE_PAGE_SIZE, + type AgedPipeFilters, + type AgedPipeRow, + buildAgedPipeCqlFilters, + isAgedPipeLayerNode, + normalizeAgedPipeFilters, + toDisplayPipeRow, + validateElapsedYears, +} from "@/entities/facility/lib/aged-pipe-utils"; +import { downloadAgedPipeExcel } from "../components/aged-pipe/aged-pipe-export"; +import { useInftwtResultMap } from "../components/inftwt-result/use-inftwt-result-map"; +import { useRdiiResultMap } from "../components/rdii-result/use-rdii-result-map"; +import SinkholeDetailPanel from "../components/sinkhole/sinkhole-detail-panel"; +import SinkholeLayerSettingPanel from "../components/sinkhole/sinkhole-layer-setting-panel"; +import FldngHstryDetailPanel from "../components/fldng-hstry/fldng-hstry-detail-panel"; +import FldngHstryLayerSettingPanel from "../components/fldng-hstry/fldng-hstry-layer-setting-panel"; +import ExmnLayerSettingPanel from "../components/exmn/exmn-layer-setting-panel"; +import LayerSettingCard from "../components/layer-setting/layer-setting-card"; +import { + EMPTY_EXMN_MAP_FILTERS, + appendExmnResultControlNodes, + buildExmnMapEntryContext, + isExmnResultMnhlNode, + isExmnResultPplNode, + type ExmnMapFilters, +} from "../components/exmn/exmn-map-types"; +import { + buildSinkholeDefaultYearRange, + buildSinkholeFiltersFromYearRange, + buildSinkholeScope, + isSinkholeLayerNode, + isSinkholeRootNode, + normalizeSinkholeFilters, + normalizeSinkholeYearRange, + SINKHOLE_DEFAULT_RADIUS, + SINKHOLE_FILTER_SYNC_MESSAGE, + SINKHOLE_LAYER_TYPE, + SINKHOLE_POPUP_KEY, + SINKHOLE_ROW_SELECT_MESSAGE, + SINKHOLE_SYNC_COMPLETED_MESSAGE, + type SinkholeDetail, + type SinkholeFilters, + type SinkholeListItem, + type SinkholeNearbyPipe, + type SinkholeSyncSummary, + type SinkholeYearRange, +} from "../components/sinkhole/sinkhole-types"; +import { + FLDNG_HSTRY_LAYER_TYPE, + FLDNG_HSTRY_POPUP_KEY, + FLDNG_HSTRY_ROW_SELECT_MESSAGE, + type FldngHstryYearRange, + type FldngHstryItem, +} from "@/entities/flood-history/model/flood-history-types"; +import { + buildFldngHstryDefaultYearRange, + isFldngHstryDepthNode, + isFldngHstryWtlvNode, + isFldngHstryRootNode, +} from "@/entities/flood-history/lib/flood-history-utils"; +import { + SBMRG_DNGR_MAP_POPUP_KEY, + isSbmrgDngrMapRootNode, +} from "../components/sbmrg-dngr-map/sbmrg-dngr-map-types"; +import { + FLOOD_DNGR_MAP_POPUP_KEY, + isFloodDngrMapRootNode, +} from "../components/flood-dngr-map/flood-dngr-map-types"; +import FeatureClickEventPopup from "@/features/facility-detail/ui/feature-click-event-popup"; +import SearchModal from "../search-modal/search-modal"; +import { + WATER_DEPTH_LAYER_TYPES, + extractWaterDepthLegendItems, +} from "../water-depth/water-depth"; +import { buildWaterRiskStatusLabel } from "../components/water-risk/water-risk-types"; + +const AnalysisPopupMenu = dynamic( + () => import("../components/analysis-menu/analysis-popup-menu"), + { ssr: false }, +); +import { + AGED_PIPE_ROW_SELECT_MESSAGE, + AGED_PIPE_POPUP_MINIMIZE_MESSAGE, + AGED_PIPE_FILTER_SYNC_MESSAGE, + EXMN_PROC_SEARCH_EVENT, + SOUTH_KOREA_VIEW_EXTENT, + FLOW_DIRECTION_SRS, + SHORT_TERM_FORECAST_GRID_LAYER_NAME, + RIGHT_PANEL_TAB_LIMIT, + RIGHT_PANEL_SINKHOLE_PIPE_TAB_ID, + AGED_PIPE_FOCUS_PADDING, + DEFAULT_FEATURE_FIT_PADDING, +} from "../model/map-constants"; +import { getFeatureCollection } from "../lib/map-values"; +import { + getSinkholeAccidentId, + getSinkholeFallbackDetailFromSelection, + getSinkholePointFromSelection, + createSinkholeFocusedPipeStyle, + appendSinkholeControlNode, +} from "../lib/sinkhole-features"; +import { + getGisEditFeatureFid, + isGisEditPreviewFeatureData, + highlightGisEditSelectedFeature, + isVisibleGisEditSessionChange, + getLatestNumericEditSn, + findInvalidPipeRequiredChange, +} from "../lib/gis-edit-features"; +import { + getSelectedFeatureLayerTypeName, + getRightPanelFeatureIdentifier, + getRightPanelFeatureProperty, + getRightPanelTabCategory, + getRightPanelFeatureDisplayLabel, +} from "../lib/right-panel-features"; +import { normalizeTreeRowSpacing } from "../lib/layer-tree"; +import { useMapRuntimeBridge } from "../lib/use-map-runtime-bridge"; +import MapControlStyles from "./map-control-styles"; + +const MapPageClient = () => { + const facilityCodes = useFacilityCodeCatalog(); + const searchParams = useSearchParams(); + const pathname = usePathname(); + const alert = useAlertStore((state) => state.setMessage); + const exmnMapEntryContext = useMemo( + () => buildExmnMapEntryContext(searchParams), + [searchParams], + ); + const initialAnalysisPopupKey = useMemo(() => { + const popupKey = searchParams.get("analysisPopupKey"); + if (!popupKey || !isWindowPopupKey(popupKey)) return null; + if (popupKey !== "inftwtResultPopup" && popupKey !== "rdiiResultPopup") + return null; + return popupKey; + }, [searchParams]); + const flowDirectionMode = useMemo( + () => getFlowDirectionModeByPathname(pathname), + [pathname], + ); + const { isSearchModalOpen, setIsSearchModalOpen } = useSearchModalStore(); + const userInfo = useUserStore((state) => state.userInfo); + const sinkholeScope = useMemo(() => buildSinkholeScope(userInfo), [userInfo]); + + const [treeData, setTreeData] = useState([]); // 초기값을 객체 또는 null로 설정 + // 통수위험 결과 행을 선택할 때 기존 체크를 유지하면서 강우 격자 레이어를 켜기 위한 요청이다. + const [checkedLayerRequest, setCheckedLayerRequest] = useState<{ + requestKey: number; + layerNames: string[]; + } | null>(null); + const [layerLoadError, setLayerLoadError] = useState(null); + const gisMaps = useRef(null); + const requestFloodPredictionLayer = useCallback(() => { + setCheckedLayerRequest((current) => ({ + requestKey: (current?.requestKey ?? 0) + 1, + layerNames: [FLOOD_DNGR_MAP_LAYER_NAME], + })); + }, []); + const { handleFloodPredictionPopupSelection } = useFloodPredictionMap({ + mapRef: gisMaps, + floodLayerType: FLOOD_DNGR_MAP_LAYER_TYPE, + onRequestFloodLayer: requestFloodPredictionLayer, + }); + const [isOpen, setIsOpen] = useState(false); // 팝업 열기/닫기 상태 + const [featureData, setFeatureData] = useState(null); // 선택된 피쳐 데이터 + // 오른쪽 상세 패널에 열린 상세 컴포넌트를 책갈피 탭 단위로 보관한다. + const [rightPanelTabs, setRightPanelTabs] = useState([]); + const [activeRightPanelTabId, setActiveRightPanelTabId] = useState< + string | null + >(null); + // 단일 관로 탭이 어느 지반침하 상세에서 열렸는지 비동기 피처 조회 사이에 보관한다. + const pipeParentTabIdRef = useRef(null); + const [isWaterDepthLayerActive, setIsWaterDepthLayerActive] = useState(false); // 침수심 레이어 활성 여부 + const [isPlaying, setIsPlaying] = useState(false); + const [mapReady, setMapReady] = useState(false); + const handleWaterRiskSelection = useCallback(() => { + setCheckedLayerRequest((current) => ({ + requestKey: (current?.requestKey ?? 0) + 1, + layerNames: [SHORT_TERM_FORECAST_GRID_LAYER_NAME], + })); + setIsOpen(false); + setFeatureData(null); + }, []); + const { + selectedWaterRiskResultRow, + waterRiskInfoCardStyle, + selectedWaterRiskMapView, + selectedWaterRiskDisplayStatus, + watchPopup: watchWaterRiskPopup, + } = useWaterRiskLayer({ + gisMaps, + mapReady, + onSelectResult: handleWaterRiskSelection, + }); + const [checkedLayerLegendVersion, setCheckedLayerLegendVersion] = useState(0); + const [isAgedPipeChecked, setIsAgedPipeChecked] = useState(false); + const [isSinkholeChecked, setIsSinkholeChecked] = useState(false); + const [isSinkholeSettingEnabled, setIsSinkholeSettingEnabled] = + useState(true); + const [isSinkholeSettingVisible, setIsSinkholeSettingVisible] = + useState(true); + const [isAgedPipeSettingEnabled, setIsAgedPipeSettingEnabled] = + useState(true); + const [isAgedPipeSettingVisible, setIsAgedPipeSettingVisible] = + useState(true); + const [isExmnPplSettingEnabled, setIsExmnPplSettingEnabled] = useState(true); + const [isExmnPplSettingVisible, setIsExmnPplSettingVisible] = useState(true); + const [isExmnMnhlSettingEnabled, setIsExmnMnhlSettingEnabled] = + useState(true); + const [isExmnMnhlSettingVisible, setIsExmnMnhlSettingVisible] = + useState(true); + const [isWaterDepthLegendEnabled, setIsWaterDepthLegendEnabled] = + useState(true); + const [isWaterDepthLegendVisible, setIsWaterDepthLegendVisible] = + useState(true); + const [isFldngHstryChecked, setIsFldngHstryChecked] = useState(false); + // 침수흔적위선과 심선은 하나의 연도 범위를 공유하지만 체크 상태는 독립적으로 관리한다. + const [isFldngHstryWtlvChecked, setIsFldngHstryWtlvChecked] = useState(false); + const [isFldngHstryDepthChecked, setIsFldngHstryDepthChecked] = + useState(false); + // 심선은 6개 깊이 등급 중 사용자가 실제 체크한 lyrSn만 필터 레이어로 생성한다. + const [checkedFldngHstryDepthLayerKeys, setCheckedFldngHstryDepthLayerKeys] = + useState([]); + const [fldngHstryYearRange, setFldngHstryYearRange] = + useState(() => buildFldngHstryDefaultYearRange()); + const [isFldngHstryYearFilterEnabled, setIsFldngHstryYearFilterEnabled] = + useState(true); + const [isFldngHstryYearSettingVisible, setIsFldngHstryYearSettingVisible] = + useState(true); + // 닫힌 설정 카드는 레이어가 완전히 해제된 뒤 다시 체크될 때만 자동으로 연다. + const fldngHstryLayerCheckedRef = useRef(false); + const sinkholeLayerCheckedRef = useRef(false); + const agedPipeLayerCheckedRef = useRef(false); + const exmnPplLayerCheckedRef = useRef(false); + const exmnMnhlLayerCheckedRef = useRef(false); + const waterDepthLayerCheckedRef = useRef(false); + const [isExmnPplChecked, setIsExmnPplChecked] = useState(false); + const [isExmnMnhlChecked, setIsExmnMnhlChecked] = useState(false); + const [exmnPplFilters, setExmnPplFilters] = useState({ + ...EMPTY_EXMN_MAP_FILTERS, + }); + const [exmnMnhlFilters, setExmnMnhlFilters] = useState({ + ...EMPTY_EXMN_MAP_FILTERS, + }); + const [swagePrcsFcltLayerMode, setSwagePrcsFcltLayerMode] = + useState("all"); + const [activeLayerSettingOrder, setActiveLayerSettingOrder] = useState< + string[] + >([]); + const [draftAgedPipeFilters, setDraftAgedPipeFilters] = + useState(AGED_PIPE_DEFAULT_FILTERS); + const [appliedAgedPipeFilters, setAppliedAgedPipeFilters] = + useState(AGED_PIPE_DEFAULT_FILTERS); + const [sinkholeYearRange, setSinkholeYearRange] = useState( + () => buildSinkholeDefaultYearRange(), + ); + const [appliedSinkholeFilters, setAppliedSinkholeFilters] = + useState(() => + normalizeSinkholeFilters( + buildSinkholeFiltersFromYearRange( + buildSinkholeDefaultYearRange(), + sinkholeScope, + ), + sinkholeScope, + ), + ); + const [isSinkholePopupFilterActive, setIsSinkholePopupFilterActive] = + useState(false); + const [sinkholeRadiusMeters, setSinkholeRadiusMeters] = useState( + SINKHOLE_DEFAULT_RADIUS, + ); + const [selectedFldngHstryDetail, setSelectedFldngHstryDetail] = + useState(null); + const [fldngHstryDetailPopupPosition, setFldngHstryDetailPopupPosition] = + useState<{ left: number; top: number } | null>(null); + const [selectedSinkholeDetail, setSelectedSinkholeDetail] = + useState(null); + const [selectedSinkholeNearbyPipes, setSelectedSinkholeNearbyPipes] = + useState([]); + const [lastSinkholeSyncSummary, setLastSinkholeSyncSummary] = + useState(null); + const [sinkholeSelectionVersion, setSinkholeSelectionVersion] = useState(0); + const [isAgedPipeListOpen, setIsAgedPipeListOpen] = useState(false); + const [isAgedPipeListMinimized, setIsAgedPipeListMinimized] = useState(false); + const [agedPipeRows, setAgedPipeRows] = useState([]); + const [agedPipeTotalCount, setAgedPipeTotalCount] = useState(0); + const [agedPipeCurrentPage, setAgedPipeCurrentPage] = useState(1); + const [isAgedPipeListLoading, setIsAgedPipeListLoading] = useState(false); + const [agedPipeDownloadProgressText, setAgedPipeDownloadProgressText] = + useState(""); + const [isGisEditSaving, setIsGisEditSaving] = useState(false); + const { ctpv, sgg } = useStore(); + // GIS 편집은 map/page.tsx 내부 기존 지도 상태를 재사용하되, 세부 상태와 payload 조립은 별도 hook으로 분리한다. + const gisEdit = useGisEdit(treeData); + // 침입수 분석결과 popup과 지도 highlight 흐름은 별도 hook으로 묶어 기존 지도 로직과 충돌을 줄인다. + const inftwtResultMap = useInftwtResultMap({ + gisMaps, + setFeatureData, + setIsOpen, + setIsSearchModalOpen, + }); + const rdiiResultMap = useRdiiResultMap({ + gisMaps, + setFeatureData, + setIsOpen, + setIsSearchModalOpen, + }); + const shouldFocusAgedPipeOnNextSelectRef = useRef(false); + const agedPipePopupWindowRef = useRef(null); + const sinkholePopupWindowRef = useRef(null); + const sinkholePopupCloseWatcherRef = useRef(null); + const pipeAnalysisPopupWindowRef = useRef(null); + const gisEditSessionPopupWindowRef = useRef(null); + const gisEditSessionPopupCloseWatcherRef = useRef(null); + const [gisEditSessionView, setGisEditSessionView] = useState< + "inline" | "popup" + >("inline"); + const [gisEditDetailChangeId, setGisEditDetailChangeId] = useState< + string | null + >(null); + const gisEditDetailChangeRef = useRef(null); + const gisEditDetailFeatureDataRef = useRef(null); + const gisEditIsEditModeRef = useRef(false); + const gisEditMoveGripMetaRef = useRef<{ + changeId: string; + meta: VertexHandleMeta | null; + } | null>(null); + // 줌레벨 컨트롤에 현재 체크된 특수 레이어의 최소 표출 기준을 안내한다. + // 유하방향 API의 관종 필터를 만들기 위해 현재 체크된 트리 노드 목록을 ref로 보관한다. + const checkedNodesDataRef = useRef([]); + const { + isFlowDirectionChecked, + flowDirectionLayerInfo, + updateSelection: updateFlowDirectionSelection, + } = useFlowDirectionLayer({ + gisMaps, + checkedNodesDataRef, + mapReady, + flowDirectionMode, + }); + const zoomGuide = useMemo(() => { + const flowDirectionGuideZoom = flowDirectionLayerInfo + ? getFlowDirectionMinZoom(flowDirectionLayerInfo) + : FLOW_DIRECTION_DEFAULT_MIN_ZOOM; + + if (isAgedPipeChecked && isFlowDirectionChecked) { + return { + label: "노후관로/유하방향 기준", + zoom: Math.max(AGED_PIPE_GUIDE_MIN_ZOOM, flowDirectionGuideZoom), + }; + } + + if (isFlowDirectionChecked) { + return { + label: "유하방향 기준", + zoom: flowDirectionGuideZoom, + }; + } + + if (isAgedPipeChecked) { + return { + label: "노후관로 기준", + zoom: AGED_PIPE_GUIDE_MIN_ZOOM, + }; + } + + return undefined; + }, [flowDirectionLayerInfo, isAgedPipeChecked, isFlowDirectionChecked]); + const { ensureGisEditPipeSnapSource, shouldUsePipeSnapSource } = + useGisEditPipeSnap({ gisEdit, gisMaps, mapReady }); + + useGisEditOriginalMask({ gisMaps, gisEdit, mapReady }); + + const gisEditVisibleSessionChanges = useMemo( + () => gisEdit.changes.filter(isVisibleGisEditSessionChange), + [gisEdit.changes], + ); + const shouldShowGisEditSession = gisEdit.isEditMode; + const isGisEditSessionInlineVisible = + shouldShowGisEditSession && gisEditSessionView === "inline"; + const gisEditSessionSnapshot = useMemo( + () => ({ + isEditMode: gisEdit.isEditMode, + selectedLayerName: gisEdit.selectedLayer?.layerName ?? "", + activeTool: gisEdit.activeTool, + isSnapEnabled: gisEdit.isSnapEnabled, + changes: gisEditVisibleSessionChanges, + lastSavedPayload: gisEdit.lastSavedPayload, + canUndo: gisEdit.canUndo, + focusedChangeId: gisEdit.focusedChangeId, + }), + [ + gisEdit.activeTool, + gisEdit.canUndo, + gisEdit.focusedChangeId, + gisEdit.isEditMode, + gisEdit.isSnapEnabled, + gisEdit.lastSavedPayload, + gisEdit.selectedLayer, + gisEditVisibleSessionChanges, + ], + ); + const gisEditDetailChange = useMemo( + () => + gisEdit.changes.find( + (change) => change.changeId === gisEditDetailChangeId, + ) ?? null, + [gisEdit.changes, gisEditDetailChangeId], + ); + const gisEditDetailFeatureData = useMemo(() => { + if (!gisEditDetailChange) return null; + if (featureData && !isGisEditPreviewFeatureData(featureData)) + return featureData; + + if (gisEditDetailChange.layerId !== "tb_ms_swage_ppl") return null; + + const savedFeatureProperties = gisEditDetailChange.featureProperties ?? {}; + return { + ...savedFeatureProperties, + lyrTypeNm: "tb_ms_swage_ppl", + fcltMngNo: + gisEditDetailChange.featureRef?.fcltMngNo ?? + savedFeatureProperties["fclt_mng_no"] ?? + gisEditDetailChange.objectId ?? + "", + mngInstCd: + gisEditDetailChange.featureRef?.mngInstCd ?? + savedFeatureProperties["mng_inst_cd"] ?? + "", + fclt_mng_no: + gisEditDetailChange.featureRef?.fcltMngNo ?? + savedFeatureProperties["fclt_mng_no"] ?? + gisEditDetailChange.objectId ?? + "", + mng_inst_cd: + gisEditDetailChange.featureRef?.mngInstCd ?? + savedFeatureProperties["mng_inst_cd"] ?? + "", + }; + }, [featureData, gisEditDetailChange]); + gisEditDetailChangeRef.current = gisEditDetailChange; + gisEditDetailFeatureDataRef.current = gisEditDetailFeatureData; + gisEditIsEditModeRef.current = gisEdit.isEditMode; + const syncGisEditSessionPopup = useCallback(() => { + const popup = gisEditSessionPopupWindowRef.current; + if (!popup || popup.closed) { + gisEditSessionPopupWindowRef.current = null; + setGisEditSessionView("inline"); + return; + } + + popup.postMessage( + { + type: WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_SYNC, + payload: gisEditSessionSnapshot, + }, + window.location.origin, + ); + }, [gisEditSessionSnapshot]); + + const closeGisEditSessionPopup = useCallback(() => { + if (gisEditSessionPopupCloseWatcherRef.current) { + window.clearInterval(gisEditSessionPopupCloseWatcherRef.current); + gisEditSessionPopupCloseWatcherRef.current = null; + } + + const popup = gisEditSessionPopupWindowRef.current; + if (popup && !popup.closed) { + popup.close(); + } + gisEditSessionPopupWindowRef.current = null; + setGisEditSessionView("inline"); + }, []); + + useEffect( + () => () => { + if (gisEditSessionPopupCloseWatcherRef.current) { + window.clearInterval(gisEditSessionPopupCloseWatcherRef.current); + gisEditSessionPopupCloseWatcherRef.current = null; + } + }, + [], + ); + + const openGisEditPipeDetail = useCallback((changeId: string | null) => { + if (!changeId) { + setGisEditDetailChangeId(null); + return; + } + setGisEditDetailChangeId(changeId); + }, []); + + const focusGisEditChangeOnMap = useCallback( + (changeId: string) => { + const map = gisMaps.current; + const change = gisEdit.changes.find((item) => item.changeId === changeId); + const geometryObject = + change?.editType === "DELETE" + ? change?.beforeGeometry + : change?.afterGeometry; + if (!map || !geometryObject) return; + + try { + const geometry = new GeoJSON().readGeometry(geometryObject, { + dataProjection: FLOW_DIRECTION_SRS, + featureProjection: GisApp.TO_PROJ, + }); + const extent = geometry?.getExtent?.(); + if ( + !extent || + extent.length < 4 || + extent.some((value: number) => !Number.isFinite(value)) + ) { + return; + } + + const view = map.getView?.(); + const currentZoom = Number(view?.getZoom?.()); + const maxZoom = + Number.isFinite(currentZoom) && currentZoom > 18 ? currentZoom : 18; + view?.fit?.(extent, { + duration: 350, + padding: [80, 420, 80, 80], + maxZoom, + }); + } catch (error) { + console.warn("GIS edit change focus failed:", error); + } + }, + [gisEdit.changes], + ); + const { + gisEditPreviewLayerRef, + gisEditPreviewSourceRef, + setGisEditPreviewCursor, + ensureGisEditPreviewLayer, + clearGisEditPreviewLayer, + } = useGisEditPreview({ + gisMaps, + mapReady, + gisEdit, + focusGisEditChangeOnMap, + }); + + // 인라인 세션과 윈도우 팝업이 동일한 지도 focus/상세 열기 흐름을 공유한다. + const handleFocusGisEditSessionChange = useCallback( + (changeId: string) => { + if (!changeId) return; + gisEdit.focusChange(changeId); + focusGisEditChangeOnMap(changeId); + setFeatureData(null); + openGisEditPipeDetail(changeId); + setIsOpen(true); + setIsSearchModalOpen(false); + }, + [ + focusGisEditChangeOnMap, + gisEdit, + openGisEditPipeDetail, + setIsSearchModalOpen, + ], + ); + + useEffect(() => { + fetchData(); + }, []); + + useEffect(() => { + setAppliedSinkholeFilters((currentFilters) => { + if (isSinkholePopupFilterActive) { + return normalizeSinkholeFilters(currentFilters, sinkholeScope); + } + if (!isSinkholeSettingEnabled) { + return normalizeSinkholeFilters( + { + ...buildSinkholeFiltersFromYearRange( + sinkholeYearRange, + sinkholeScope, + ), + startYmd: "", + endYmd: "", + }, + sinkholeScope, + ); + } + return normalizeSinkholeFilters( + buildSinkholeFiltersFromYearRange(sinkholeYearRange, sinkholeScope), + sinkholeScope, + ); + }); + }, [ + isSinkholePopupFilterActive, + isSinkholeSettingEnabled, + sinkholeScope, + sinkholeYearRange, + ]); + + useMapRuntimeBridge({ + gisMaps, + setMapReady, + handleFloodPredictionPopupSelection, + }); + + useEffect(() => { + if (shouldShowGisEditSession) return; + + const popup = gisEditSessionPopupWindowRef.current; + if (!popup || popup.closed) { + gisEditSessionPopupWindowRef.current = null; + setGisEditSessionView("inline"); + return; + } + + popup.close(); + gisEditSessionPopupWindowRef.current = null; + setGisEditSessionView("inline"); + }, [shouldShowGisEditSession]); + + useEffect(() => { + return () => { + const popup = gisEditSessionPopupWindowRef.current; + if (popup && !popup.closed) { + popup.close(); + } + gisEditSessionPopupWindowRef.current = null; + }; + }, []); + + useEffect(() => { + if (!gisEdit.isEditMode) { + setGisEditDetailChangeId(null); + return; + } + + if (!gisEditDetailChangeId) return; + if (!gisEditDetailChange) { + setGisEditDetailChangeId(null); + setFeatureData(null); + setIsOpen(false); + return; + } + + if ( + gisEditDetailChange.layerId !== "tb_ms_swage_ppl" || + gisEditDetailChange.editType === "DELETE" + ) { + setGisEditDetailChangeId(null); + } + }, [gisEdit.isEditMode, gisEditDetailChange, gisEditDetailChangeId]); + + useEffect(() => { + if (!gisEdit.isEditMode) return; + syncGisEditSessionPopup(); + }, [ + gisEdit.isEditMode, + gisEditVisibleSessionChanges.length, + syncGisEditSessionPopup, + ]); + + useEffect(() => { + const handleGisEditSessionPopupMessage = (event: MessageEvent) => { + if (event.origin !== window.location.origin) return; + + const data = event.data; + if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_READY) { + syncGisEditSessionPopup(); + return; + } + + if ( + data?.type === WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_SHOW_INLINE + ) { + closeGisEditSessionPopup(); + return; + } + + if ( + data?.type === WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_FOCUS_CHANGE + ) { + const changeId = String(data.payload?.changeId ?? "").trim(); + handleFocusGisEditSessionChange(changeId); + return; + } + + if ( + data?.type === + WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_REMOVE_FOCUSED_CHANGE + ) { + gisEdit.removeFocusedChange(); + return; + } + + if ( + data?.type === + WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_REMOVE_LAST_CHANGE + ) { + gisEdit.removeLastChange(); + return; + } + + if ( + data?.type === WINDOW_POPUP_MESSAGE_TYPE.GIS_EDIT_SESSION_CLEAR_CHANGES + ) { + gisEdit.clearChanges(); + } + }; + + window.addEventListener("message", handleGisEditSessionPopupMessage); + return () => { + window.removeEventListener("message", handleGisEditSessionPopupMessage); + }; + }, [ + closeGisEditSessionPopup, + gisEdit, + handleFocusGisEditSessionChange, + syncGisEditSessionPopup, + ]); + + const fetchData = async () => { + try { + const data = await getRawJson( + "/sewerage-service/api/v1/map/lyrStngs/SN_NETWORK", + ); + // DB 필터 치환 실패는 노후관로에만 한정하고 다른 시설물 레이어는 유지한다. + const preparedData = prepareAgedPipeLayerRows( + data, + appliedAgedPipeFilters, + ); + const agedPipeConfigError = + findAgedPipeLayer(preparedData)?.agedPipeConfigError; + if (agedPipeConfigError) alert(agedPipeConfigError); + setTreeData( + appendExmnResultControlNodes( + appendSinkholeControlNode( + normalizeTreeRowSpacing( + normalizeRainObservationLayerInteraction( + normalizeFlowDirectionLayerInfos(preparedData), + ), + ), + ), + exmnMapEntryContext.jobType, + ), + ); + setLayerLoadError(null); + } catch (error) { + const message = + error instanceof Error + ? error.message + : "레이어 설정 조회 중 알 수 없는 오류가 발생했습니다."; + console.error("레이어 설정 조회 실패:", error); + setTreeData([]); + setLayerLoadError(message); + } + }; + + // SearchModal, GisContent 등에서 호출 + const onFeatureSelect = (data: any) => { + const clickedLayerTypeName = getSelectedFeatureLayerTypeName(data); + if (clickedLayerTypeName === "tb_ms_swage_ppl") { + pipeParentTabIdRef.current = null; + } + + // 지형에 있는 피처를 직접 선택하면 지반침하 반경 내에서 만든 임시 관로 강조는 해제한다. + clearSinkholeFocusedPipeLayer(); + if (rdiiResultMap.handleOverlayFeatureSelect(data)) { + setFeatureData(null); + setIsOpen(false); + setIsSearchModalOpen(false); + return; + } + if (inftwtResultMap.handleOverlayFeatureSelect(data)) { + setFeatureData(null); + setIsOpen(false); + setIsSearchModalOpen(false); + return; + } + + if ( + gisEdit.isEditMode && + (gisEdit.activeTool === "MOVE" || gisEdit.activeTool === "VERTEX_EDIT") && + gisEdit.selectedLayer?.layerId === "tb_ms_swage_ppl" && + clickedLayerTypeName !== "tb_ms_swage_ppl" + ) { + gisEdit.announceStatus("하수관로를 선택해주세요."); + setFeatureData(null); + setIsOpen(false); + setIsSearchModalOpen(false); + return; + } + + if (clickedLayerTypeName === SINKHOLE_LAYER_TYPE) { + const acdntNo = getSinkholeAccidentId(data); + if (acdntNo) { + void loadSinkholeDetail( + acdntNo, + false, + getSinkholePointFromSelection(data), + getSinkholeFallbackDetailFromSelection(data), + ); + } + return; + } + + // 편집 도구가 켜져 있을 때는 상세 팝업보다 도구별 세션 처리를 우선한다. + // ADD는 Draw interaction만 geometry를 받아야 하므로 기존 관로 선택 이벤트는 무시한다. + let editSelection: any = gisEdit.registerFeatureSelection(data); + if ( + !editSelection.handled && + gisEdit.isEditMode && + !gisEdit.activeTool && + clickedLayerTypeName === "tb_ms_swage_ppl" + ) { + editSelection = gisEdit.registerPipeDetailSelection(data); + } + if (editSelection.handled) { + const closeGisEditDetail = () => { + const select = GisApp.Module.select as any; + select?.resetSelectedFeature?.(); + setFeatureData(null); + setGisEditDetailChangeId(null); + setIsOpen(false); + setIsSearchModalOpen(false); + }; + const loadLatestEditSn = (changeId: string) => { + const fid = + editSelection.isPreviewSelection === true + ? "" + : getGisEditFeatureFid(data); + if (!fid) return; + getRawJson( + `/sewerage-service/api/v1/map/gisEdit/swagePpl/histories?fid=${encodeURIComponent(fid)}`, + ) + .then((historyResponse) => { + const latestEditSn = getLatestNumericEditSn( + historyResponse?.histories ?? [], + ); + if (latestEditSn !== null) { + gisEdit.updateChangeBaseEditSn(changeId, String(latestEditSn)); + } + }) + .catch((error) => { + console.error("GIS 편집 관로 이력 조회 실패:", error); + gisEdit.announceStatus( + "관로 이력 정보를 확인하지 못했습니다. 저장 시 충돌 검증이 제한될 수 있습니다.", + ); + }); + }; + + if (gisEdit.activeTool === "ADD" || gisEdit.activeTool === "DELETE") { + closeGisEditDetail(); + return; + } + + const isGeometryEditTargetSelection = + editSelection.isPreviewSelection !== true && + (gisEdit.activeTool === "MOVE" || gisEdit.activeTool === "VERTEX_EDIT"); + + if (isGeometryEditTargetSelection) { + if ( + typeof editSelection.changeId === "string" && + editSelection.changeId + ) { + loadLatestEditSn(String(editSelection.changeId)); + } + closeGisEditDetail(); + return; + } + + const canOpenPreviewPipeDetail = + clickedLayerTypeName === "tb_ms_swage_ppl" && + editSelection.isPreviewSelection === true && + editSelection.editType !== "DELETE"; + const shouldOpenPipeDetail = + clickedLayerTypeName === "tb_ms_swage_ppl" && + typeof editSelection.changeId === "string" && + editSelection.changeId && + (editSelection.isPreviewSelection !== true || canOpenPreviewPipeDetail); + + if (shouldOpenPipeDetail) { + openGisEditPipeDetail(String(editSelection.changeId)); + loadLatestEditSn(String(editSelection.changeId)); + if ( + editSelection.isPreviewSelection !== true && + !isGeometryEditTargetSelection + ) { + highlightGisEditSelectedFeature(data); + } + setFeatureData(data); + window.setTimeout(() => { + setGisEditDetailChangeId(String(editSelection.changeId)); + setIsOpen(true); + }, 0); + setIsSearchModalOpen(false); + return; + } + + // 편집 대상으로 넘긴 뒤에는 기존 상세 팝업/원본 선택 강조를 정리해 + // 실제 편집 대상인 preview feature가 시각적으로 가려지지 않게 한다. + closeGisEditDetail(); + return; + } + + let map = gisMaps.current; + if (!map) { + console.warn("지도가 초기화되지 않았습니다."); + return; + } + + // 항상 같은 select 객체 사용 + const select = GisApp.Module.select as any; + if (!select) return; + + // 1. 기존 선택 초기화 + select.resetSelectedFeature(); + + // 2. data가 OpenLayers Feature 인스턴스인지 확인 + let foundFeature = null; + if ( + data && + typeof data.getGeometry === "function" && + typeof data.setStyle === "function" + ) { + // 이미 Feature 인스턴스라면 그대로 사용 + foundFeature = data; + } else { + // 아니면 featureId로 맵에서 Feature 인스턴스를 찾아서 사용 + const featureId = data.fid || data.ftrIdn || data.id; + map + .getLayers() + .getArray() + .forEach((layer: any) => { + const source = layer.getSource?.(); + if (source?.getFeatureById) { + const feature = source.getFeatureById(String(featureId).trim()); + if (feature) foundFeature = feature; + } + }); + } + + // 3. 피처 선택 + if (foundFeature) { + let geometry = foundFeature.getGeometry(); + if (geometry) { + let extent = geometry.getExtent(); + + const layerTypeName = + typeof foundFeature.getId === "function" + ? String(foundFeature.getId()).split(".")[0] + : data?.lyrTypeNm; + const shouldUseAgedPipeFocus = + shouldFocusAgedPipeOnNextSelectRef.current && + layerTypeName === AGED_PIPE_LAYER_TYPE; + + map.getView().fit(extent, { + duration: 300, + padding: shouldUseAgedPipeFocus + ? AGED_PIPE_FOCUS_PADDING + : DEFAULT_FEATURE_FIT_PADDING, + maxZoom: shouldUseAgedPipeFocus ? 18 : 16, + constrainResolution: true, + nearest: true, + }); + + if (shouldUseAgedPipeFocus) { + shouldFocusAgedPipeOnNextSelectRef.current = false; + } + } + select.selectFeature(foundFeature, true); + } else { + console.warn("OpenLayers Feature를 찾을 수 없습니다:", data); + } + + setFeatureData(data); + setIsOpen(true); + setIsSearchModalOpen(false); + }; + + const swagePrcsFcltSearchFilter = useMemo(() => { + switch (swagePrcsFcltLayerMode) { + case "gte500": + return { + label: "하수처리시설(500톤 이상)", + cqlFilter: "fclt_cpct >= 500", + }; + case "lt500": + return { + label: "하수처리시설(500톤 미만)", + cqlFilter: "fclt_cpct < 500", + }; + case "none": + return { label: "하수처리시설(레이어 꺼짐)", disabled: true }; + case "all": + default: + return { label: "하수처리시설(전체)" }; + } + }, [swagePrcsFcltLayerMode]); + + const facilityTypes = [ + { text: "하수처리시설", val: SWAGE_PRCS_FCLT_LAYER_TYPE }, + ]; + + const fetchAgedPipeRows = useCallback( + async (page: number, filters: AgedPipeFilters, size: number) => { + const normalized = normalizeAgedPipeFilters(filters); + const layer = findAgedPipeLayer(treeData); + validateAgedPipeLayer(layer); + const queryParams = new URLSearchParams({ + lyrTypeNm: layer.lyrTyNm, + page: String(page - 1), + size: String(size), + sort: "string", + }); + + const cqlFilters = buildAgedPipeCqlFilters( + normalized, + layer.agedPipeFilterTemplate ?? layer.flterSql, + ); + if (cqlFilters.length > 0) { + queryParams.append("cqlFiter", cqlFilters.join(" and ")); + } + + const response = await getData(`v1/fclts?${queryParams.toString()}`); + const rows = Array.isArray(response?.data) ? response.data : []; + + return { + rows: rows.map((item: Record, index: number) => + toDisplayPipeRow(item, index, page, ctpv, sgg, facilityCodes), + ), + total: Number(response?.count ?? 0), + }; + }, + [ctpv, sgg, treeData, facilityCodes], + ); + + const loadAgedPipeList = useCallback( + async (page = 1, filters = appliedAgedPipeFilters) => { + setIsAgedPipeListLoading(true); + try { + const response = await fetchAgedPipeRows( + page, + filters, + AGED_PIPE_PAGE_SIZE, + ); + setAgedPipeRows(response.rows); + setAgedPipeTotalCount(response.total); + setAgedPipeCurrentPage(page); + } catch (error) { + console.error("노후관로 목록 조회 중 오류:", error); + setAgedPipeRows([]); + setAgedPipeTotalCount(0); + } finally { + setIsAgedPipeListLoading(false); + } + }, + [appliedAgedPipeFilters, fetchAgedPipeRows], + ); + + const syncAgedPipePopupFilters = useCallback((filters: AgedPipeFilters) => { + const popup = agedPipePopupWindowRef.current; + if (!popup || popup.closed) { + agedPipePopupWindowRef.current = null; + return; + } + + popup.postMessage( + { + type: AGED_PIPE_FILTER_SYNC_MESSAGE, + filters: normalizeAgedPipeFilters(filters), + }, + window.location.origin, + ); + }, []); + + const handleApplyAgedPipeFilters = useCallback( + (filters = draftAgedPipeFilters) => { + if (!validateElapsedYears(Number(filters.elapsedYears))) { + alert("경과년도는 1년~100년 범위 내에서 입력해주세요."); + return; + } + + const normalized = normalizeAgedPipeFilters(filters); + setDraftAgedPipeFilters(normalized); + setAppliedAgedPipeFilters(normalized); + syncAgedPipePopupFilters(normalized); + + if (isAgedPipeListOpen) { + loadAgedPipeList(1, normalized); + } + }, + [ + draftAgedPipeFilters, + isAgedPipeListOpen, + loadAgedPipeList, + syncAgedPipePopupFilters, + ], + ); + + const openAgedPipePopupWindow = useCallback(() => { + const popup = openWindowPopup("agedPipePopup", { + elapsedYears: String(appliedAgedPipeFilters.elapsedYears), + ctpvCd: appliedAgedPipeFilters.ctpvCd, + sggCd: appliedAgedPipeFilters.sggCd, + }); + + if (!popup) { + alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요."); + return null; + } + + agedPipePopupWindowRef.current = popup; + setIsAgedPipeListOpen(true); + setIsAgedPipeListMinimized(false); + return popup; + }, [appliedAgedPipeFilters]); + + const openAgedPipeList = useCallback(() => { + openAgedPipePopupWindow(); + }, [openAgedPipePopupWindow]); + const { removeLayer: removeExmnResultLayer, messages: exmnLayerMessages } = + useExmnResultMap({ + gisApp: GisApp, + mapRef: gisMaps, + ready: mapReady, + treeData, + entry: exmnMapEntryContext, + pipeChecked: isExmnPplChecked, + manholeChecked: isExmnMnhlChecked, + pipeEnabled: isExmnPplSettingEnabled, + manholeEnabled: isExmnMnhlSettingEnabled, + pipeFilters: exmnPplFilters, + manholeFilters: exmnMnhlFilters, + pipeStyle: createExmnPipeStyle, + manholeStyle: createExmnManholeStyle, + }); + const { + removeSinkholeLayer, + clearSinkholeSelectionLayer, + clearSinkholeFocusedPipeLayer, + ensureSinkholeFocusedPipeLayer, + renderSelectedSinkhole, + focusSinkholeOnMap, + } = useSinkholeLayers({ + gisMaps, + ctpv, + sgg, + appliedSinkholeFilters, + mapReady, + isSinkholeChecked, + sinkholeScope, + sinkholeSelectionVersion, + selectedSinkholeDetail, + sinkholeRadiusMeters, + }); + + // 우측 상세 탭 전환 시 패널의 활성 대상과 지도 선택/강조 상태를 맞춘다. + const syncRightPanelFeatureSelectionOnMap = useCallback( + (data: any) => { + const select = GisApp.Module.select as any; + select?.resetSelectedFeature?.(); + + const layerTypeName = getSelectedFeatureLayerTypeName(data); + if (layerTypeName === SINKHOLE_LAYER_TYPE) { + clearSinkholeFocusedPipeLayer(); + + return; + } + + // 연계 심선/위선 조회는 공용 search-layer를 교체한다. 이때 탭에 보관된 + // 기존 침수흔적 feature는 더 이상 지도 레이어에 속하지 않으므로, 단순히 + // Select 컬렉션에 다시 넣어도 강조가 렌더링되지 않는다. 탭으로 돌아올 + // 때는 FID로 search-layer를 복원한 뒤 로드된 feature를 선택하게 한다. + if ( + layerTypeName === FLDNG_HSTRY_LAYER_TYPE || + layerTypeName === "tb_ms_fldng_dpth_map" + ) { + clearSinkholeFocusedPipeLayer(); + const featureIdentifier = getRightPanelFeatureIdentifier(data).replace( + new RegExp(`^${layerTypeName}\\.`), + "", + ); + if (featureIdentifier) { + const core = GisApp.Module.layer as any; + void core?.showSearchResultsFromGeoJSON?.( + layerTypeName, + `${layerTypeName}.${featureIdentifier}`, + ); + } + + return; + } + + if (!data || typeof data.getGeometry !== "function") { + clearSinkholeFocusedPipeLayer(); + + return; + } + + if (layerTypeName === "tb_ms_swage_ppl") { + const source = ensureSinkholeFocusedPipeLayer(); + if (!source) return; + + source.clear(); + const focusedFeature = + typeof data.clone === "function" ? data.clone() : data; + if ( + typeof focusedFeature.setId === "function" && + typeof data.getId === "function" + ) { + focusedFeature.setId(data.getId()); + } + focusedFeature.setStyle(createSinkholeFocusedPipeStyle()); + source.addFeature(focusedFeature); + select?.selectFeature?.(focusedFeature, true); + return; + } + + clearSinkholeFocusedPipeLayer(); + + select?.selectFeature?.(data, true); + }, + [clearSinkholeFocusedPipeLayer, ensureSinkholeFocusedPipeLayer], + ); + const { + fldngHstryAreaLayerRef, + fldngHstryDepthLayerRef, + clearFldngHstryLayers, + renderSelectedFldngHstry, + focusFldngHstryOnMap, + } = useFloodHistorySelection({ mapReady, gisMaps, selectedFldngHstryDetail }); + + const clearFldngHstrySelection = useCallback(() => { + setSelectedFldngHstryDetail(null); + setFldngHstryDetailPopupPosition(null); + clearFldngHstryLayers(); + }, [clearFldngHstryLayers]); + + const closeFldngHstryDetail = useCallback(() => { + setFldngHstryDetailPopupPosition(null); + }, []); + + const handleFldngHstryRowSelect = useCallback( + (detail: FldngHstryItem) => { + setSelectedFldngHstryDetail(detail); + renderSelectedFldngHstry(detail); + focusFldngHstryOnMap(detail); + setIsSearchModalOpen(false); + }, + [focusFldngHstryOnMap, renderSelectedFldngHstry, setIsSearchModalOpen], + ); + + const openFldngHstryPopupWindow = useCallback(() => { + const popup = openWindowPopup(FLDNG_HSTRY_POPUP_KEY as WindowPopupKey); + if (!popup) { + alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요."); + return null; + } + return popup; + }, []); + + const closeSinkholeDetail = useCallback(() => { + setSelectedSinkholeDetail(null); + setSelectedSinkholeNearbyPipes([]); + clearSinkholeSelectionLayer(); + clearSinkholeFocusedPipeLayer(); + + if (getSelectedFeatureLayerTypeName(featureData) === SINKHOLE_LAYER_TYPE) { + setFeatureData(null); + setIsOpen(false); + } + }, [clearSinkholeFocusedPipeLayer, clearSinkholeSelectionLayer, featureData]); + + const loadSinkholeDetail = useCallback( + async ( + acdntNo: string, + focusMap = true, + fallbackPoint?: { x: number; y: number } | null, + fallbackDetail?: Partial | null, + ) => { + if (!acdntNo) return; + + try { + const hasFallbackCoordinate = + (fallbackDetail?.x != null && fallbackDetail?.y != null) || + (fallbackPoint != null && + Number.isFinite(fallbackPoint.x) && + Number.isFinite(fallbackPoint.y)); + const fallbackCoordStatus = String( + fallbackDetail?.coordStatus ?? fallbackDetail?.geocodeStatus ?? "", + ) + .trim() + .toUpperCase(); + + if ( + fallbackDetail && + !hasFallbackCoordinate && + fallbackCoordStatus && + fallbackCoordStatus !== "SUCCESS" + ) { + const noCoordDetail: SinkholeDetail = { + acdntNo, + ctpvNm: String(fallbackDetail.ctpvNm ?? ""), + sggNm: String(fallbackDetail.sggNm ?? ""), + emdNm: String(fallbackDetail.emdNm ?? ""), + daddr: String(fallbackDetail.daddr ?? ""), + fullAddress: String( + fallbackDetail.fullAddress ?? fallbackDetail.daddr ?? "", + ), + ocrnYmd: String(fallbackDetail.ocrnYmd ?? ""), + restoreStatus: String(fallbackDetail.restoreStatus ?? ""), + restoreCompletedYmd: String( + fallbackDetail.restoreCompletedYmd ?? "", + ), + coordStatus: String( + fallbackDetail.coordStatus ?? fallbackDetail.geocodeStatus ?? "", + ), + geocodeStatus: String( + fallbackDetail.geocodeStatus ?? fallbackDetail.coordStatus ?? "", + ), + geocodeSource: String(fallbackDetail.geocodeSource ?? ""), + geocodeErrorMessage: String( + fallbackDetail.geocodeErrorMessage ?? "", + ), + latitude: fallbackDetail.latitude ?? null, + longitude: fallbackDetail.longitude ?? null, + x: fallbackDetail.x ?? null, + y: fallbackDetail.y ?? null, + sizeWidth: fallbackDetail.sizeWidth ?? null, + sizeLength: fallbackDetail.sizeLength ?? null, + sizeDepth: fallbackDetail.sizeDepth ?? null, + detailCause: fallbackDetail.detailCause ?? null, + deathCount: fallbackDetail.deathCount ?? null, + injuryCount: fallbackDetail.injuryCount ?? null, + vehicleDamageCount: fallbackDetail.vehicleDamageCount ?? null, + }; + + setSelectedSinkholeDetail(noCoordDetail); + setSelectedSinkholeNearbyPipes([]); + renderSelectedSinkhole(null, sinkholeRadiusMeters); + setFeatureData({ + lyrTypeNm: SINKHOLE_LAYER_TYPE, + acdntNo: noCoordDetail.acdntNo, + }); + setIsOpen(true); + setIsSearchModalOpen(false); + return; + } + + let detail: SinkholeDetail; + try { + const detailResponse = await getRawJson( + `/sewerage-service/api/v1/sinkholes/${encodeURIComponent(acdntNo)}`, + ); + detail = (detailResponse?.data ?? detailResponse) as SinkholeDetail; + } catch (detailError) { + if (!fallbackDetail) { + throw detailError; + } + console.warn( + "지반침하 상세 API 실패, 목록 데이터로 대체합니다.", + detailError, + ); + detail = { + acdntNo, + ctpvNm: String(fallbackDetail.ctpvNm ?? ""), + sggNm: String(fallbackDetail.sggNm ?? ""), + emdNm: String(fallbackDetail.emdNm ?? ""), + daddr: String(fallbackDetail.daddr ?? ""), + fullAddress: String( + fallbackDetail.fullAddress ?? fallbackDetail.daddr ?? "", + ), + ocrnYmd: String(fallbackDetail.ocrnYmd ?? ""), + restoreStatus: String(fallbackDetail.restoreStatus ?? ""), + restoreCompletedYmd: String( + fallbackDetail.restoreCompletedYmd ?? "", + ), + coordStatus: String(fallbackDetail.coordStatus ?? ""), + geocodeStatus: String( + fallbackDetail.geocodeStatus ?? fallbackDetail.coordStatus ?? "", + ), + geocodeSource: String(fallbackDetail.geocodeSource ?? ""), + geocodeErrorMessage: String( + fallbackDetail.geocodeErrorMessage ?? "", + ), + latitude: fallbackDetail.latitude ?? null, + longitude: fallbackDetail.longitude ?? null, + x: fallbackDetail.x ?? null, + y: fallbackDetail.y ?? null, + sizeWidth: fallbackDetail.sizeWidth ?? null, + sizeLength: fallbackDetail.sizeLength ?? null, + sizeDepth: fallbackDetail.sizeDepth ?? null, + detailCause: fallbackDetail.detailCause ?? null, + deathCount: fallbackDetail.deathCount ?? null, + injuryCount: fallbackDetail.injuryCount ?? null, + vehicleDamageCount: fallbackDetail.vehicleDamageCount ?? null, + }; + } + const resolvedPoint = + detail.x != null && detail.y != null + ? { x: detail.x, y: detail.y } + : fallbackPoint && + Number.isFinite(fallbackPoint.x) && + Number.isFinite(fallbackPoint.y) + ? fallbackPoint + : null; + + let nearbyPipes: SinkholeNearbyPipe[] = []; + try { + const nearbyRawResponse = resolvedPoint + ? await getRawJson( + `/sewerage-service/api/v1/sinkholes/nearbyPipesByPoint?x=${resolvedPoint.x}&y=${resolvedPoint.y}&radiusMeters=${sinkholeRadiusMeters}`, + ) + : await getRawJson( + `/sewerage-service/api/v1/sinkholes/${encodeURIComponent(acdntNo)}/nearbyPipes?radiusMeters=${sinkholeRadiusMeters}`, + ); + const nearbyResponse = nearbyRawResponse?.data ?? nearbyRawResponse; + nearbyPipes = Array.isArray(nearbyResponse?.items) + ? (nearbyResponse.items as SinkholeNearbyPipe[]) + : []; + } catch (nearbyError) { + console.warn( + "지반침하 반경 내 관로 조회 실패, 상세는 계속 표시합니다.", + nearbyError, + ); + } + const normalizedDetail = resolvedPoint + ? { + ...detail, + x: resolvedPoint.x, + y: resolvedPoint.y, + coordStatus: "SUCCESS", + } + : detail; + + setSelectedSinkholeDetail(normalizedDetail); + setSelectedSinkholeNearbyPipes(nearbyPipes); + renderSelectedSinkhole(normalizedDetail, sinkholeRadiusMeters); + setFeatureData({ + lyrTypeNm: SINKHOLE_LAYER_TYPE, + acdntNo: normalizedDetail.acdntNo, + }); + setIsOpen(true); + setIsSearchModalOpen(false); + + if ( + focusMap && + normalizedDetail.x != null && + normalizedDetail.y != null + ) { + focusSinkholeOnMap(normalizedDetail, sinkholeRadiusMeters); + } + } catch (error) { + console.error("지반침하 상세 조회 중 오류:", error); + alert("지반침하 상세 정보를 불러오지 못했습니다."); + } + }, + [ + focusSinkholeOnMap, + renderSelectedSinkhole, + setIsSearchModalOpen, + sinkholeRadiusMeters, + ], + ); + + const applySinkholeRadius = useCallback( + async (radiusMeters: number) => { + setSinkholeRadiusMeters(radiusMeters); + if (!selectedSinkholeDetail?.acdntNo) { + return; + } + + const selectedPoint = + selectedSinkholeDetail.x != null && selectedSinkholeDetail.y != null + ? { + x: Number(selectedSinkholeDetail.x), + y: Number(selectedSinkholeDetail.y), + } + : null; + + try { + const nearbyRawResponse = + selectedPoint && + Number.isFinite(selectedPoint.x) && + Number.isFinite(selectedPoint.y) + ? await getRawJson( + `/sewerage-service/api/v1/sinkholes/nearbyPipesByPoint?x=${selectedPoint.x}&y=${selectedPoint.y}&radiusMeters=${radiusMeters}`, + ) + : await getRawJson( + `/sewerage-service/api/v1/sinkholes/${encodeURIComponent(selectedSinkholeDetail.acdntNo)}/nearbyPipes?radiusMeters=${radiusMeters}`, + ); + const nearbyResponse = nearbyRawResponse?.data ?? nearbyRawResponse; + setSelectedSinkholeNearbyPipes( + Array.isArray(nearbyResponse?.items) + ? (nearbyResponse.items as SinkholeNearbyPipe[]) + : [], + ); + renderSelectedSinkhole(selectedSinkholeDetail, radiusMeters); + focusSinkholeOnMap(selectedSinkholeDetail, radiusMeters); + } catch (error) { + console.error("지반침하 반경 적용 중 오류:", error); + alert("반경 내 하수관로 정보를 다시 조회하지 못했습니다."); + throw error; + } + }, + [focusSinkholeOnMap, renderSelectedSinkhole, selectedSinkholeDetail], + ); + + const focusSinkholeNearbyPipe = useCallback( + async (pipe: SinkholeNearbyPipe) => { + const fid = String(pipe?.fid ?? "").trim(); + if (!fid) { + alert("표시할 관로 식별값이 없습니다."); + return; + } + + const activeSinkholeTab = rightPanelTabs.find( + (tab) => + tab.id === activeRightPanelTabId && tab.category === "sinkhole", + ); + pipeParentTabIdRef.current = activeSinkholeTab?.id ?? null; + + try { + const layerTypeName = String(pipe?.lyrTypeNm ?? "tb_ms_swage_ppl"); + const layerModule = GisApp.Module.layer as { + storageNm?: string; + } | null; + const storageNm = String(layerModule?.storageNm ?? "").trim(); + if (!storageNm) + throw new Error("GeoServer 저장소 이름을 확인할 수 없습니다."); + const params = new URLSearchParams({ + SERVICE: "WFS", + VERSION: "1.1.0", + REQUEST: "GetFeature", + TYPENAME: `${storageNm}${layerTypeName}`, + SRSNAME: GisApp.TO_PROJ, + OUTPUTFORMAT: "application/json", + FEATUREID: fid, + }); + const response = await getRawJson( + `${GisApp.GEOSERVER_URL}?${params.toString()}`, + ); + const featureCollection = getFeatureCollection(response); + const features = new GeoJSON().readFeatures(featureCollection, { + dataProjection: GisApp.TO_PROJ, + featureProjection: GisApp.TO_PROJ, + }); + + const firstFeature = features[0]; + if (firstFeature) { + syncRightPanelFeatureSelectionOnMap(firstFeature); + setFeatureData(firstFeature); + setIsOpen(true); + setIsSearchModalOpen(false); + } + } catch (error) { + console.error("지반침하 반경 내 관로 지도 표시 실패:", error); + alert("선택한 관로를 지도에서 표시하지 못했습니다."); + } + }, + [ + activeRightPanelTabId, + rightPanelTabs, + setIsSearchModalOpen, + syncRightPanelFeatureSelectionOnMap, + ], + ); + + const buildRightPanelTab = useCallback( + (data: any): RightPanelTab | null => { + if (!data) return null; + + const layerTypeName = getSelectedFeatureLayerTypeName(data); + const now = Date.now(); + + if (layerTypeName === SINKHOLE_LAYER_TYPE) { + const detail = selectedSinkholeDetail; + const acdntNo = String( + detail?.acdntNo ?? getSinkholeAccidentId(data) ?? "", + ).trim(); + if (!acdntNo) return null; + + return { + id: `sinkhole:${acdntNo}`, + kind: "sinkhole", + category: "sinkhole", + displayLabel: `지반침하(${acdntNo})`, + parentTabId: null, + featureData: { + lyrTypeNm: SINKHOLE_LAYER_TYPE, + acdntNo, + }, + sinkholeDetail: detail, + nearbyPipes: selectedSinkholeNearbyPipes, + lastActiveAt: now, + }; + } + + if (gisEditDetailChangeId) { + return { + id: `gisEdit:${gisEditDetailChangeId}`, + kind: "gisEdit", + category: "gisEdit", + displayLabel: "관로편집", + parentTabId: null, + featureData: data, + gisEditChangeId: gisEditDetailChangeId, + lastActiveAt: now, + }; + } + + const category = getRightPanelTabCategory(data); + const surveyJobTypeValue = getRightPanelFeatureProperty(data, [ + "jobType", + ]); + const surveyJobType = + surveyJobTypeValue === "PPL" || surveyJobTypeValue === "MNHL" + ? surveyJobTypeValue + : undefined; + const featureId = + getRightPanelFeatureIdentifier(data) || + `${layerTypeName || "feature"}:${now}`; + // Direct map selections accumulate per pipe; only a pipe with a sinkhole parent replaces the linked slot. + const pipeParentTabId = + category === "pipe" ? pipeParentTabIdRef.current : null; + return { + id: + category === "exmnSurvey" + ? `exmnSurvey:${getRightPanelFeatureProperty(data, ["exmnFacilityId"])}:${getRightPanelFeatureProperty(data, ["manholePosition"])}` + : category === "pipe" && pipeParentTabId + ? RIGHT_PANEL_SINKHOLE_PIPE_TAB_ID + : `feature:${layerTypeName || "unknown"}:${featureId}`, + kind: "feature", + category, + displayLabel: getRightPanelFeatureDisplayLabel(data, category), + surveyJobType, + parentTabId: pipeParentTabId, + featureData: data, + lastActiveAt: now, + }; + }, + [ + gisEditDetailChangeId, + selectedSinkholeDetail, + selectedSinkholeNearbyPipes, + ], + ); + + const upsertRightPanelTab = useCallback((tab: RightPanelTab) => { + setRightPanelTabs((currentTabs) => { + const nextTab = { ...tab, lastActiveAt: Date.now() }; + const existingIndex = currentTabs.findIndex((item) => item.id === tab.id); + const mergedTabs = + existingIndex < 0 + ? [...currentTabs, nextTab] + : currentTabs.map((item, index) => + index === existingIndex ? { ...item, ...nextTab } : item, + ); + + if (mergedTabs.length <= RIGHT_PANEL_TAB_LIMIT) return mergedTabs; + + const overflowCandidate = findRightPanelTabOverflowCandidate( + mergedTabs, + nextTab.id, + ); + if (!overflowCandidate) return mergedTabs.slice(-RIGHT_PANEL_TAB_LIMIT); + + return removeRightPanelTabGroup(mergedTabs, overflowCandidate.id); + }); + setActiveRightPanelTabId(tab.id); + }, []); + + const activateRightPanelTab = useCallback( + (tabId: string) => { + const targetTab = rightPanelTabs.find((tab) => tab.id === tabId); + if (!targetTab) return; + + setRightPanelTabs((currentTabs) => + currentTabs.map((tab) => + tab.id === tabId ? { ...tab, lastActiveAt: Date.now() } : tab, + ), + ); + setActiveRightPanelTabId(tabId); + if (targetTab.category === "pipe") { + pipeParentTabIdRef.current = targetTab.parentTabId ?? null; + } + + if (targetTab.kind === "sinkhole") { + const detail = targetTab.sinkholeDetail ?? null; + setSelectedSinkholeDetail(detail); + setSelectedSinkholeNearbyPipes(targetTab.nearbyPipes ?? []); + setGisEditDetailChangeId(null); + syncRightPanelFeatureSelectionOnMap(targetTab.featureData); + if (detail) { + renderSelectedSinkhole(detail, sinkholeRadiusMeters); + } + } else if (targetTab.kind === "gisEdit") { + setGisEditDetailChangeId(targetTab.gisEditChangeId ?? null); + } else { + setGisEditDetailChangeId(null); + syncRightPanelFeatureSelectionOnMap(targetTab.featureData); + } + + setFeatureData(targetTab.featureData); + setIsOpen(true); + setIsSearchModalOpen(false); + }, + [ + renderSelectedSinkhole, + rightPanelTabs, + setIsSearchModalOpen, + sinkholeRadiusMeters, + syncRightPanelFeatureSelectionOnMap, + ], + ); + + const closeRightPanelTab = useCallback( + (tabId: string | null) => { + if (!tabId) return; + const closingTab = rightPanelTabs.find((tab) => tab.id === tabId); + const remainingTabs = removeRightPanelTabGroup(rightPanelTabs, tabId); + const closingLayerTypeName = getSelectedFeatureLayerTypeName( + closingTab?.featureData, + ); + + // 침수흔적 연계 조회로 만든 공용 search-layer는 선택 해제만으로 + // 제거되지 않는다. 마지막 탭을 닫는 경우와 다른 일반 feature 탭으로 + // 전환되는 경우 모두 잔상으로 남지 않도록 여기서 명시적으로 제거한다. + if ( + closingLayerTypeName === FLDNG_HSTRY_LAYER_TYPE || + closingLayerTypeName === "tb_ms_fldng_dpth_map" + ) { + const layerModule = GisApp.Module.layer as any; + if (layerModule?.searchLayer) { + layerModule.clearSearchLayer?.(); + } + } + + if (closingTab?.kind === "sinkhole") { + setSelectedSinkholeDetail(null); + setSelectedSinkholeNearbyPipes([]); + clearSinkholeSelectionLayer(); + } + if (closingTab?.kind === "feature") { + const select = GisApp.Module.select as any; + select?.resetSelectedFeature?.(); + clearSinkholeFocusedPipeLayer(); + } + if (closingTab?.kind === "gisEdit") { + setGisEditDetailChangeId(null); + } + if ( + closingTab?.category === "pipe" || + pipeParentTabIdRef.current === tabId + ) { + pipeParentTabIdRef.current = null; + } + + setRightPanelTabs(remainingTabs); + + if (remainingTabs.length === 0) { + setActiveRightPanelTabId(null); + pipeParentTabIdRef.current = null; + setFeatureData(null); + setIsOpen(false); + return; + } + + const parentFallbackTab = + closingTab?.category === "pipe" && closingTab.parentTabId + ? remainingTabs.find((tab) => tab.id === closingTab.parentTabId) + : null; + const fallbackTab = + parentFallbackTab ?? + [...remainingTabs].sort((a, b) => b.lastActiveAt - a.lastActiveAt)[0]!; + activateRightPanelTab(fallbackTab.id); + }, + [ + activateRightPanelTab, + clearSinkholeFocusedPipeLayer, + clearSinkholeSelectionLayer, + rightPanelTabs, + ], + ); + + // 선택 피처가 없는 지도 본체 클릭을 기존 지반침하 상세 닫기 흐름과 연결한다. + const handleSinkholeMapBlankClick = useCallback(() => { + if (gisEdit.isEditMode || !selectedSinkholeDetail?.acdntNo) return; + + const sinkholeTabId = `sinkhole:${selectedSinkholeDetail.acdntNo}`; + const hasSinkholeTab = rightPanelTabs.some( + (tab) => tab.id === sinkholeTabId, + ); + + if (hasSinkholeTab && activeRightPanelTabId === sinkholeTabId) { + closeRightPanelTab(sinkholeTabId); + } else { + // 비활성 지반침하 탭은 현재 활성 탭을 바꾸지 않고 종속 관로와 함께 제거한다. + setRightPanelTabs((currentTabs) => + removeRightPanelTabGroup(currentTabs, sinkholeTabId), + ); + setSelectedSinkholeDetail(null); + setSelectedSinkholeNearbyPipes([]); + clearSinkholeSelectionLayer(); + if (pipeParentTabIdRef.current === sinkholeTabId) { + pipeParentTabIdRef.current = null; + } + if ( + !hasSinkholeTab && + getSelectedFeatureLayerTypeName(featureData) === SINKHOLE_LAYER_TYPE + ) { + setFeatureData(null); + setIsOpen(false); + } + } + + const select = GisApp.Module.select as any; + select?.resetSelectedFeature?.(); + clearSinkholeFocusedPipeLayer(); + }, [ + activeRightPanelTabId, + clearSinkholeFocusedPipeLayer, + clearSinkholeSelectionLayer, + closeRightPanelTab, + featureData, + gisEdit.isEditMode, + rightPanelTabs, + selectedSinkholeDetail, + ]); + + useEffect(() => { + if (!isOpen || !featureData) return; + const nextTab = buildRightPanelTab(featureData); + if (nextTab) upsertRightPanelTab(nextTab); + }, [buildRightPanelTab, featureData, isOpen, upsertRightPanelTab]); + + useEffect(() => { + if (isOpen) return; + pipeParentTabIdRef.current = null; + setRightPanelTabs([]); + setActiveRightPanelTabId(null); + }, [isOpen]); + + const openSinkholePopupWindow = useCallback(() => { + const effectiveFilters = normalizeSinkholeFilters( + appliedSinkholeFilters, + sinkholeScope, + ); + const popup = openWindowPopup( + SINKHOLE_POPUP_KEY as WindowPopupKey, + effectiveFilters as Record, + ); + if (!popup) { + alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요."); + return null; + } + + setIsSinkholePopupFilterActive(true); + setAppliedSinkholeFilters(effectiveFilters); + setSinkholeSelectionVersion((version) => version + 1); + + if (sinkholePopupCloseWatcherRef.current) { + window.clearInterval(sinkholePopupCloseWatcherRef.current); + sinkholePopupCloseWatcherRef.current = null; + } + sinkholePopupWindowRef.current = popup; + sinkholePopupCloseWatcherRef.current = window.setInterval(() => { + if (!popup.closed) return; + if (sinkholePopupCloseWatcherRef.current) { + window.clearInterval(sinkholePopupCloseWatcherRef.current); + sinkholePopupCloseWatcherRef.current = null; + } + sinkholePopupWindowRef.current = null; + setIsSinkholePopupFilterActive(false); + setAppliedSinkholeFilters( + normalizeSinkholeFilters( + buildSinkholeFiltersFromYearRange(sinkholeYearRange, sinkholeScope), + sinkholeScope, + ), + ); + setSinkholeSelectionVersion((version) => version + 1); + }, 300); + return popup; + }, [appliedSinkholeFilters, sinkholeScope, sinkholeYearRange]); + + useEffect( + () => () => { + if (sinkholePopupCloseWatcherRef.current) { + window.clearInterval(sinkholePopupCloseWatcherRef.current); + sinkholePopupCloseWatcherRef.current = null; + } + }, + [], + ); + + const handleAgedPipeRowDoubleClick = useCallback( + async (row: AgedPipeRow) => { + try { + shouldFocusAgedPipeOnNextSelectRef.current = true; + const core = GisApp.Module.layer as any; + await core.showSearchResultsFromGeoJSON(AGED_PIPE_LAYER_TYPE, row.fid); + } catch (error) { + shouldFocusAgedPipeOnNextSelectRef.current = false; + console.error("노후관로 위치 이동 중 오류:", error); + } + + setFeatureData(row as any); + setIsOpen(true); + setIsSearchModalOpen(false); + }, + [setIsSearchModalOpen], + ); + + const handlePipeAnalysisRowSelect = useCallback( + async (row: PipeAnalysisDetailRow) => { + // 관로현황 팝업에서 선택한 경우 메시지를 받은 최상위 지도창을 먼저 앞으로 가져온다. + window.focus(); + try { + shouldFocusAgedPipeOnNextSelectRef.current = true; + const core = GisApp.Module.layer as any; + await core.showSearchResultsFromGeoJSON(AGED_PIPE_LAYER_TYPE, row.fid); + } catch (error) { + shouldFocusAgedPipeOnNextSelectRef.current = false; + console.error("관로 위치 이동 중 오류:", error); + } + + setFeatureData(row as any); + setIsOpen(true); + setIsSearchModalOpen(false); + // 지도 이동과 상세 패널 렌더링 이후에도 최상위 창 포커스를 한 번 더 요청한다. + window.focus(); + }, + [setIsSearchModalOpen], + ); + + const handleAgedPipeDownload = useCallback(async () => { + if (agedPipeTotalCount === 0) { + alert("다운로드할 데이터가 없습니다."); + return; + } + + try { + setIsAgedPipeListLoading(true); + setAgedPipeDownloadProgressText("Excel 파일을 준비하고 있습니다."); + await downloadAgedPipeExcel( + appliedAgedPipeFilters, + (job) => { + if (job.status === "QUEUED") { + setAgedPipeDownloadProgressText( + "Excel 다운로드 작업이 대기 중입니다.", + ); + return; + } + + if (job.status === "RUNNING") { + setAgedPipeDownloadProgressText( + `Excel 파일 생성 중입니다. (${job.progressPercent ?? 0}%)`, + ); + } + }, + agedPipeTotalCount, + ); + setAgedPipeDownloadProgressText(""); + } catch (error) { + console.error("노후관로 목록 다운로드 중 오류:", error); + setAgedPipeDownloadProgressText(""); + alert( + error instanceof Error + ? error.message + : "노후관로 Excel 다운로드 중 오류가 발생했습니다.", + ); + } finally { + setIsAgedPipeListLoading(false); + } + }, [agedPipeTotalCount, appliedAgedPipeFilters]); + + useEffect(() => { + const handleAgedPipePopupMessage = (event: MessageEvent) => { + if (event.origin !== window.location.origin) return; + + if (event.data?.type === AGED_PIPE_ROW_SELECT_MESSAGE) { + if (!event.data?.row) return; + handleAgedPipeRowDoubleClick(event.data.row as AgedPipeRow); + return; + } + + if (event.data?.type === AGED_PIPE_POPUP_MINIMIZE_MESSAGE) { + agedPipePopupWindowRef.current = null; + if (event.data?.filters) { + const normalized = normalizeAgedPipeFilters( + event.data.filters as AgedPipeFilters, + ); + setDraftAgedPipeFilters(normalized); + setAppliedAgedPipeFilters(normalized); + } + setAgedPipeTotalCount(Number(event.data?.total ?? agedPipeTotalCount)); + setIsAgedPipeListMinimized(true); + return; + } + + if (event.data?.type === PIPE_ANALYSIS_DETAIL_ROW_SELECT_MESSAGE) { + if (!event.data?.row) return; + handlePipeAnalysisRowSelect(event.data.row as PipeAnalysisDetailRow); + } + }; + + window.addEventListener("message", handleAgedPipePopupMessage); + return () => + window.removeEventListener("message", handleAgedPipePopupMessage); + }, [ + agedPipeTotalCount, + handleAgedPipeRowDoubleClick, + handlePipeAnalysisRowSelect, + ]); + + useEffect(() => { + const handleSinkholePopupMessage = (event: MessageEvent) => { + if (event.origin !== window.location.origin) return; + + if ( + event.data?.type === SINKHOLE_FILTER_SYNC_MESSAGE && + event.data?.filters + ) { + const normalized = normalizeSinkholeFilters( + event.data.filters as SinkholeFilters, + sinkholeScope, + ); + setIsSinkholePopupFilterActive(true); + setAppliedSinkholeFilters(normalized); + setSinkholeSelectionVersion((version) => version + 1); + return; + } + + if (event.data?.type === SINKHOLE_ROW_SELECT_MESSAGE && event.data?.row) { + const row = event.data.row as SinkholeListItem; + const fallbackPoint = + row.x != null && row.y != null + ? { x: Number(row.x), y: Number(row.y) } + : null; + void loadSinkholeDetail( + String(row.acdntNo ?? ""), + true, + fallbackPoint, + row, + ); + return; + } + + if ( + event.data?.type === SINKHOLE_SYNC_COMPLETED_MESSAGE && + event.data?.payload + ) { + setLastSinkholeSyncSummary(event.data.payload as SinkholeSyncSummary); + setSinkholeSelectionVersion((version) => version + 1); + } + }; + + window.addEventListener("message", handleSinkholePopupMessage); + return () => + window.removeEventListener("message", handleSinkholePopupMessage); + }, [loadSinkholeDetail, sinkholeScope]); + + useEffect(() => { + const handleFldngHstryPopupMessage = (event: MessageEvent) => { + if (event.origin !== window.location.origin) return; + if ( + event.data?.type !== FLDNG_HSTRY_ROW_SELECT_MESSAGE || + !event.data?.row + ) + return; + + handleFldngHstryRowSelect(event.data.row as FldngHstryItem); + }; + + window.addEventListener("message", handleFldngHstryPopupMessage); + return () => + window.removeEventListener("message", handleFldngHstryPopupMessage); + }, [handleFldngHstryRowSelect]); + + // WaterDepth 표시 여부를 결정하는 함수 + const handleWaterDepthVisibility = useCallback( + (checkedIds: string[], checkedNodesData: any[]) => { + const waterDepthLayers = checkedNodesData.filter((node) => + WATER_DEPTH_LAYER_TYPES.includes(node.data?.lyrTyNm), + ); + + const hasWaterDepthLayer = waterDepthLayers.length > 0; + setIsWaterDepthLayerActive(hasWaterDepthLayer); + }, + [], + ); + + // 체크된 레이어 변경 처리 함수 + const handleCheckedLayersChange = useCallback( + (checkedIds: string[], checkedNodesData: any[]) => { + checkedNodesDataRef.current = checkedNodesData; + setCheckedLayerLegendVersion((version) => version + 1); + setSwagePrcsFcltLayerMode(getSwagePrcsFcltLayerMode(checkedNodesData)); + + updateFlowDirectionSelection(checkedNodesData); + + handleWaterDepthVisibility(checkedIds, checkedNodesData); + const agedPipeChecked = checkedNodesData.some((node) => + isAgedPipeLayerNode(node), + ); + const waterDepthChecked = checkedNodesData.some((node) => + WATER_DEPTH_LAYER_TYPES.includes(String(node?.data?.lyrTyNm ?? "")), + ); + if (agedPipeChecked && !agedPipeLayerCheckedRef.current) { + setIsAgedPipeSettingVisible(true); + } + if (waterDepthChecked && !waterDepthLayerCheckedRef.current) { + setIsWaterDepthLegendVisible(true); + } + agedPipeLayerCheckedRef.current = agedPipeChecked; + waterDepthLayerCheckedRef.current = waterDepthChecked; + setIsAgedPipeChecked(agedPipeChecked); + const fldngHstryWtlvChecked = checkedNodesData.some((node) => + isFldngHstryWtlvNode(node), + ); + const checkedFldngHstryDepthNodes = checkedNodesData.filter((node) => + isFldngHstryDepthNode(node), + ); + const checkedFldngHstryDepthLayerKeys = Array.from( + new Set( + checkedFldngHstryDepthNodes + .map((node) => String(node?.data?.lyrSn ?? node?.id ?? "").trim()) + .filter(Boolean), + ), + ).sort((left, right) => Number(left) - Number(right)); + const fldngHstryDepthChecked = checkedFldngHstryDepthLayerKeys.length > 0; + const fldngHstryChecked = fldngHstryWtlvChecked || fldngHstryDepthChecked; + setIsFldngHstryWtlvChecked(fldngHstryWtlvChecked); + setIsFldngHstryDepthChecked(fldngHstryDepthChecked); + setCheckedFldngHstryDepthLayerKeys((currentKeys) => + currentKeys.join(",") === checkedFldngHstryDepthLayerKeys.join(",") + ? currentKeys + : checkedFldngHstryDepthLayerKeys, + ); + setIsFldngHstryChecked(fldngHstryChecked); + if (fldngHstryChecked && !fldngHstryLayerCheckedRef.current) { + setIsFldngHstryYearSettingVisible(true); + } + fldngHstryLayerCheckedRef.current = fldngHstryChecked; + if (!fldngHstryChecked) { + clearFldngHstrySelection(); + } + const sinkholeChecked = checkedNodesData.some((node) => + isSinkholeLayerNode(node), + ); + if (sinkholeChecked && !sinkholeLayerCheckedRef.current) { + setIsSinkholeSettingVisible(true); + } + sinkholeLayerCheckedRef.current = sinkholeChecked; + setIsSinkholeChecked(sinkholeChecked); + const exmnPplChecked = checkedNodesData.some((node) => + isExmnResultPplNode(node), + ); + const exmnMnhlChecked = checkedNodesData.some((node) => + isExmnResultMnhlNode(node), + ); + if (exmnPplChecked && !exmnPplLayerCheckedRef.current) { + setIsExmnPplSettingVisible(true); + } + if (exmnMnhlChecked && !exmnMnhlLayerCheckedRef.current) { + setIsExmnMnhlSettingVisible(true); + } + exmnPplLayerCheckedRef.current = exmnPplChecked; + exmnMnhlLayerCheckedRef.current = exmnMnhlChecked; + setIsExmnPplChecked(exmnPplChecked); + setIsExmnMnhlChecked(exmnMnhlChecked); + setActiveLayerSettingOrder((currentOrder) => { + const activeKeys = [ + waterDepthChecked ? "waterDepthLegend" : null, + fldngHstryChecked ? "fldngHstry" : null, + sinkholeChecked ? "sinkhole" : null, + agedPipeChecked ? "agedPipe" : null, + exmnPplChecked ? "exmnPpl" : null, + exmnMnhlChecked ? "exmnMnhl" : null, + ].filter((key): key is string => Boolean(key)); + + return [ + ...currentOrder.filter((key) => activeKeys.includes(key)), + ...activeKeys.filter((key) => !currentOrder.includes(key)), + ]; + }); + if (sinkholeChecked) { + setSinkholeSelectionVersion((version) => version + 1); + } + if (!sinkholeChecked) { + removeSinkholeLayer(); + closeSinkholeDetail(); + } + if (!exmnPplChecked) { + removeExmnResultLayer("PPL"); + } + if (!exmnMnhlChecked) { + removeExmnResultLayer("MNHL"); + } + }, + [ + clearFldngHstrySelection, + closeSinkholeDetail, + handleWaterDepthVisibility, + removeExmnResultLayer, + updateFlowDirectionSelection, + removeSinkholeLayer, + ], + ); + + const waterDepthLegendItems = useMemo( + () => extractWaterDepthLegendItems(checkedNodesDataRef.current ?? []), + [checkedLayerLegendVersion], + ); + + const applySinkholeYearRange = useCallback( + (nextRange: SinkholeYearRange) => { + const normalizedRange = normalizeSinkholeYearRange( + nextRange, + sinkholeYearRange, + ); + setSinkholeYearRange(normalizedRange); + setIsSinkholePopupFilterActive(false); + setSinkholeSelectionVersion((version) => version + 1); + }, + [sinkholeYearRange], + ); + + /** 침수위험지도 루트의 목록보기 버튼에서 전용 조회 팝업을 연다. */ + const openSbmrgDngrMapPopupWindow = useCallback(() => { + const popup = openWindowPopup(SBMRG_DNGR_MAP_POPUP_KEY as WindowPopupKey); + if (!popup) { + alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요."); + } + return popup; + }, []); + + /** 도시침수지도 루트의 목록보기 버튼에서 전용 조회 팝업을 연다. */ + const openFloodDngrMapPopupWindow = useCallback(() => { + const popup = openWindowPopup(FLOOD_DNGR_MAP_POPUP_KEY as WindowPopupKey); + if (!popup) { + alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요."); + } + return popup; + }, []); + + const handleOpenLayerPopup = useCallback( + (_layerNm: string, node?: any) => { + if (isAgedPipeLayerNode(node)) { + openAgedPipeList(); + return false; + } + if (isSinkholeRootNode(node)) { + openSinkholePopupWindow(); + return false; + } + if (isSbmrgDngrMapRootNode(node)) { + openSbmrgDngrMapPopupWindow(); + return false; + } + if (isFloodDngrMapRootNode(node)) { + openFloodDngrMapPopupWindow(); + return false; + } + if (isFldngHstryRootNode(node)) { + openFldngHstryPopupWindow(); + return false; + } + + return true; + }, + [ + openAgedPipeList, + openFldngHstryPopupWindow, + openFloodDngrMapPopupWindow, + openSbmrgDngrMapPopupWindow, + openSinkholePopupWindow, + ], + ); + + // 현재 체크된 특수 레이어만 하단 설정영역에 모아 렌더링하기 위한 패널 목록이다. + const activeLayerSettingItems = useMemo< + { key: string; label: string; content: ReactNode }[] + >(() => { + const nextItems: { + key: string; + label: string; + content: ReactNode; + order: number; + }[] = []; + const resolveSettingOrder = (key: string, fallback: number) => { + const index = activeLayerSettingOrder.indexOf(key); + return index >= 0 ? index : fallback; + }; + + if (isExmnPplChecked && isExmnPplSettingVisible) { + nextItems.push({ + key: "exmnPpl", + label: "관로조사", + order: resolveSettingOrder("exmnPpl", 9995), + content: ( + setIsExmnPplSettingVisible(false)} + /> + ), + }); + } + + if (isExmnMnhlChecked && isExmnMnhlSettingVisible) { + nextItems.push({ + key: "exmnMnhl", + label: "맨홀조사", + order: resolveSettingOrder("exmnMnhl", 9996), + content: ( + setIsExmnMnhlSettingVisible(false)} + /> + ), + }); + } + + if (isFldngHstryChecked && isFldngHstryYearSettingVisible) { + nextItems.push({ + key: "fldngHstry", + label: "침수흔적도", + order: resolveSettingOrder("fldngHstry", 9997), + content: ( + setIsFldngHstryYearSettingVisible(false)} + /> + ), + }); + } + + if (isSinkholeChecked && isSinkholeSettingVisible) { + nextItems.push({ + key: "sinkhole", + label: "지반침하정보", + order: resolveSettingOrder("sinkhole", 9998), + content: ( + setIsSinkholeSettingVisible(false)} + compact + /> + ), + }); + } + + if (isAgedPipeChecked && isAgedPipeSettingVisible) { + nextItems.push({ + key: "agedPipe", + label: "노후관로", + order: resolveSettingOrder("agedPipe", 9999), + content: ( + setIsAgedPipeSettingVisible(false)} + compact + /> + ), + }); + } + + if (isWaterDepthLayerActive && isWaterDepthLegendVisible) { + nextItems.push({ + key: "waterDepthLegend", + label: "침수심 범례", + order: resolveSettingOrder("waterDepthLegend", 9997), + content: ( + setIsWaterDepthLegendVisible(false)} + showToggle={false} + > + {isWaterDepthLegendEnabled ? ( +
+ {waterDepthLegendItems.map((item) => ( +
+ + + {item.label} + +
+ ))} +
+ ) : null} +
+ ), + }); + } + + return nextItems + .sort((a, b) => a.order - b.order) + .map(({ key, label, content }) => ({ key, label, content })); + }, [ + activeLayerSettingOrder, + applySinkholeRadius, + applySinkholeYearRange, + draftAgedPipeFilters, + exmnLayerMessages, + exmnMnhlFilters, + exmnPplFilters, + handleApplyAgedPipeFilters, + fldngHstryYearRange, + isAgedPipeChecked, + isAgedPipeSettingEnabled, + isAgedPipeSettingVisible, + isExmnMnhlChecked, + isExmnMnhlSettingEnabled, + isExmnMnhlSettingVisible, + isExmnPplChecked, + isExmnPplSettingEnabled, + isExmnPplSettingVisible, + isFldngHstryChecked, + isFldngHstryYearFilterEnabled, + isFldngHstryYearSettingVisible, + isSinkholeChecked, + isSinkholeSettingEnabled, + isSinkholeSettingVisible, + isWaterDepthLayerActive, + isWaterDepthLegendEnabled, + isWaterDepthLegendVisible, + lastSinkholeSyncSummary, + sinkholeRadiusMeters, + sinkholeYearRange, + userInfo?.adminRoleYn, + waterDepthLegendItems, + ]); + + // 설정값 변경으로 설정 컴포넌트가 다시 만들어져도 사용자가 보던 내부 스크롤 위치를 유지한다. + const activeLayerSettingScrollTopRef = useRef(0); + const handleActiveLayerSettingScroll = useCallback( + (event: UIEvent) => { + activeLayerSettingScrollTopRef.current = event.currentTarget.scrollTop; + }, + [], + ); + const restoreActiveLayerSettingScroll = useCallback( + (element: HTMLDivElement | null) => { + if (!element) return; + + const maxScrollTop = Math.max( + 0, + element.scrollHeight - element.clientHeight, + ); + element.scrollTop = Math.min( + activeLayerSettingScrollTopRef.current, + maxScrollTop, + ); + }, + [], + ); + + // 선택된 설정이 있을 때만 하단 전용 섹션을 노출하고, 세로 공간이 부족하면 내부 스크롤로 처리한다. + const ActiveLayerSettingView = useCallback(() => { + if (activeLayerSettingItems.length === 0) return null; + + return ( +
+
+ + + + + 선택 레이어 설정 + +
+
+
+ {activeLayerSettingItems.map((item) => ( + {item.content} + ))} +
+
+
+ ); + }, [ + activeLayerSettingItems, + handleActiveLayerSettingScroll, + restoreActiveLayerSettingScroll, + ]); + + useEffect(() => { + if (!mapReady || !gisMaps.current) return; + const row = findAgedPipeLayer(treeData); + if (!row) return; + const core = GisApp.Module.core as any; + const id = String(row.lyrSn); + const layerCode = (GisApp.layerCode as any)?.[id]; + if (!layerCode) return; + try { + validateAgedPipeLayer(row); + const filter = buildAgedPipeCqlFilters( + appliedAgedPipeFilters, + row.agedPipeFilterTemplate ?? row.flterSql, + ).join(" AND "); + layerCode.flterSql = filter; + const visible = isAgedPipeChecked && isAgedPipeSettingEnabled; + let layer = core?.getLayerById?.(id); + if (!visible) { + layer?.setVisible?.(false); + return; + } + // 숨긴 동안 바뀐 조건도 다시 켤 때 반영하도록 실제 레이어의 생성 당시 필터와 비교한다. + if (!layer || layer.get?.("flterSql") !== filter) { + layer = core?.addWfsLayer?.(layerCode); + core?.setSelectInteraction?.(); + } + layer?.setVisible?.(true); + } catch (error) { + core?.getLayerById?.(id)?.setVisible?.(false); + console.error("노후관로 DB 레이어 설정 오류:", error); + } + }, [ + appliedAgedPipeFilters, + isAgedPipeChecked, + isAgedPipeSettingEnabled, + mapReady, + treeData, + checkedLayerLegendVersion, + ]); + useFloodHistoryVisibility({ + mapReady, + gisMaps, + fldngHstryYearRange, + isFldngHstryYearFilterEnabled, + clearFldngHstrySelection, + isFldngHstryWtlvChecked, + isFldngHstryDepthChecked, + checkedFldngHstryDepthLayerKeys, + isFldngHstryChecked, + }); + + const openAnalysisPopup = useCallback( + (popupKey: WindowPopupKey) => { + const popup = openWindowPopup(popupKey); + if (!popup) { + alert( + "브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요.", + ); + return null; + } + + if (popupKey === "pipeAnalysisPopup") { + pipeAnalysisPopupWindowRef.current = popup; + } + + if (popupKey === "flowRiskPopup") { + watchWaterRiskPopup(popup); + } + + return popup; + }, + [watchWaterRiskPopup], + ); + + const openGisEditSessionPopup = useCallback(() => { + const existingPopup = gisEditSessionPopupWindowRef.current; + if (existingPopup && !existingPopup.closed) { + existingPopup.focus(); + window.focus?.(); + return existingPopup; + } + + const popup = openWindowPopup("gisEditSessionPopup"); + if (!popup) return null; + + gisEditSessionPopupWindowRef.current = popup; + if (gisEditSessionPopupCloseWatcherRef.current) { + window.clearInterval(gisEditSessionPopupCloseWatcherRef.current); + gisEditSessionPopupCloseWatcherRef.current = null; + } + gisEditSessionPopupCloseWatcherRef.current = window.setInterval(() => { + if (!popup.closed) return; + if (gisEditSessionPopupCloseWatcherRef.current) { + window.clearInterval(gisEditSessionPopupCloseWatcherRef.current); + gisEditSessionPopupCloseWatcherRef.current = null; + } + gisEditSessionPopupWindowRef.current = null; + setGisEditSessionView("inline"); + }, 300); + window.focus?.(); + return popup; + }, []); + const { + gisEditSelectedVertexHandleKeyRef, + gisEditSelectedVertexMetaListRef, + gisEditVertexLayerRef, + clearGisEditVertexLayer, + applySelectedVertexHandleStyle, + clearSelectedVertexHandle, + removeGisEditInteractions, + } = useGisEditTransform({ + gisMaps, + gisEditPreviewSourceRef, + gisEdit, + gisEditPreviewLayerRef, + setGisEditPreviewCursor, + mapReady, + ensureGisEditPreviewLayer, + shouldUsePipeSnapSource, + ensureGisEditPipeSnapSource, + gisEditMoveGripMetaRef, + }); + const { removeGisEditDrawInteraction } = useGisEditDraw({ + gisMaps, + gisEdit, + mapReady, + removeGisEditInteractions, + ensureGisEditPreviewLayer, + shouldUsePipeSnapSource, + ensureGisEditPipeSnapSource, + setGisEditPreviewCursor, + gisEditPreviewLayerRef, + openGisEditPipeDetail, + setFeatureData, + setGisEditDetailChangeId, + setIsOpen, + setIsSearchModalOpen, + }); + + const refreshGisEditTargetLayers = useCallback( + (layerTypeNames: string[], focusFid?: string | null) => { + const refreshTargetNames = new Set( + (layerTypeNames.length > 0 ? layerTypeNames : ["tb_ms_swage_ppl"]) + .map((layerTypeName) => String(layerTypeName ?? "").trim()) + .filter(Boolean), + ); + const map = gisMaps.current; + map?.getAllLayers?.().forEach((layer: any) => { + const layerTypeName = String(layer?.get?.("lyrTyNm") ?? "").trim(); + if (!refreshTargetNames.has(layerTypeName)) return; + + const source = layer.getSource?.(); + source?.updateParams?.({ _t: Date.now() }); + source?.refresh?.(); + source?.changed?.(); + layer.changed?.(); + }); + + if (focusFid) { + const core = GisApp.Module.layer as any; + core?.showSearchResultsFromGeoJSON?.("tb_ms_swage_ppl", focusFid); + } + }, + [], + ); + + // GIS 편집 진입 시에는 화면 안 인라인 세션을 우선 표시하고, 윈도우 팝업은 사용자가 명시적으로 연다. + const handleToggleGisEdit = useCallback(() => { + if (gisEdit.isEditMode) { + closeGisEditSessionPopup(); + } else { + setGisEditSessionView("inline"); + } + gisEdit.toggleEditMode(); + setIsOpen(false); + }, [closeGisEditSessionPopup, gisEdit]); + + const handleOpenGisEditSessionPopup = useCallback(() => { + const popup = openGisEditSessionPopup(); + if (!popup) { + alert("브라우저 팝업이 차단되었습니다. 팝업 허용 후 다시 시도해주세요."); + return; + } + setGisEditSessionView("popup"); + syncGisEditSessionPopup(); + }, [openGisEditSessionPopup, syncGisEditSessionPopup]); + + // 툴바 버튼은 현재 지도 중심 또는 다음 피처 선택을 기준으로 로컬 변경분을 만든다. + const handleSelectGisEditTool = useCallback( + (tool: "ADD" | "MOVE" | "VERTEX_EDIT" | "DELETE") => { + if (gisEdit.activeTool === tool) { + gisEdit.deactivateTool(); + removeGisEditDrawInteraction(); + removeGisEditInteractions(); + return; + } + + removeGisEditDrawInteraction(); + removeGisEditInteractions(); + gisEdit.activateTool(tool, gisMaps.current); + setIsOpen(false); + }, + [gisEdit, removeGisEditDrawInteraction, removeGisEditInteractions], + ); + + const handleCloseGisEdit = useCallback(() => { + removeGisEditInteractions(); + removeGisEditDrawInteraction(); + clearGisEditPreviewLayer(); + clearGisEditVertexLayer(); + closeGisEditSessionPopup(); + gisEdit.discardSession(); + setGisEditDetailChangeId(null); + setFeatureData(null); + setIsOpen(false); + }, [ + clearGisEditPreviewLayer, + clearGisEditVertexLayer, + closeGisEditSessionPopup, + gisEdit, + removeGisEditDrawInteraction, + removeGisEditInteractions, + ]); + + const handleSaveGisEdit = useCallback(async () => { + if (isGisEditSaving) return; + + const payload = gisEdit.save(gisMaps.current); + if (!payload) return; + + const invalidRequiredChange = findInvalidPipeRequiredChange( + payload.changes, + ); + if (invalidRequiredChange) { + const message = + "관리기관코드, 시설관리번호, 기관관리번호, 용도는 필수 입력 항목입니다."; + alert(message); + gisEdit.announceStatus(message); + setGisEditDetailChangeId(invalidRequiredChange.changeId); + setIsOpen(true); + return; + } + + setIsGisEditSaving(true); + gisEdit.announceStatus("관로 편집 변경분을 저장 중입니다."); + try { + const response = await postRawJson( + "/sewerage-service/api/v1/map/gisEditSessions/save", + payload, + ); + const results = Array.isArray(response?.results) ? response.results : []; + const refreshLayerTypeNames = Array.isArray( + response?.refreshLayerTypeNames, + ) + ? response.refreshLayerTypeNames + : results.flatMap((result: any) => result?.refreshLayerTypeNames ?? []); + const focusFid = + results.find( + (result: any) => result?.fid && result?.editType !== "DELETE", + )?.fid ?? null; + + removeGisEditInteractions(); + removeGisEditDrawInteraction(); + clearGisEditPreviewLayer(); + clearGisEditVertexLayer(); + refreshGisEditTargetLayers(refreshLayerTypeNames, focusFid); + gisEdit.clearChanges(); + setGisEditDetailChangeId(null); + setIsOpen(false); + closeGisEditSessionPopup(); + gisEdit.announceStatus( + response?.message ?? "하수관로 편집 저장이 완료되었습니다.", + ); + } catch (error) { + console.error("GIS 편집 저장 실패:", error); + const apiError = error as Error & { body?: any }; + gisEdit.announceStatus( + `하수관로 편집 저장 실패: ${apiError.body?.message ?? apiError.message}`, + ); + } finally { + setIsGisEditSaving(false); + } + }, [ + clearGisEditPreviewLayer, + clearGisEditVertexLayer, + closeGisEditSessionPopup, + gisEdit, + isGisEditSaving, + refreshGisEditTargetLayers, + removeGisEditDrawInteraction, + removeGisEditInteractions, + ]); + + const handleApplyGisEditPipeBasicInfo = useCallback( + (changeId: string, basicInfo: GisEditPipeBasicInfo) => { + gisEdit.updateChangeBasicInfo(changeId, basicInfo); + }, + [gisEdit.updateChangeBasicInfo], + ); + + const canSelectFeatureDuringGisEdit = useCallback( + (feature: any) => { + if (!gisEdit.isEditMode) return true; + if (!gisEdit.activeTool) return true; + + if ( + gisEdit.selectedLayer?.layerId === "tb_ms_swage_ppl" && + getSelectedFeatureLayerTypeName(feature) !== "tb_ms_swage_ppl" + ) { + gisEdit.announceStatus("하수관로를 선택해주세요."); + } + return false; + }, + [gisEdit], + ); + + const GisEditAwareDetailView = useCallback( + (props: any) => { + const activeRightPanelTab = + rightPanelTabs.find((tab) => tab.id === activeRightPanelTabId) ?? null; + const panelFeatureData = + activeRightPanelTab?.featureData ?? props.featureData; + const currentChange = gisEditDetailChangeRef.current; + const currentFeatureData = gisEditDetailFeatureDataRef.current; + const isEditMode = gisEditIsEditModeRef.current; + const selectedLayerTypeName = + getSelectedFeatureLayerTypeName(panelFeatureData); + const panelSinkholeDetail = + activeRightPanelTab?.kind === "sinkhole" + ? (activeRightPanelTab.sinkholeDetail ?? selectedSinkholeDetail) + : selectedSinkholeDetail; + const panelSinkholeNearbyPipes = + activeRightPanelTab?.kind === "sinkhole" + ? (activeRightPanelTab.nearbyPipes ?? selectedSinkholeNearbyPipes) + : selectedSinkholeNearbyPipes; + const tabsView = ( + + ); + const closeActiveTab = () => { + if (activeRightPanelTabId) { + closeRightPanelTab(activeRightPanelTabId); + return; + } + props.onClose?.(); + }; + + if ( + isEditMode && + currentChange && + currentChange.layerId === "tb_ms_swage_ppl" && + currentChange.editType !== "DELETE" + ) { + return ( + <> + {tabsView} + { + setGisEditDetailChangeId(null); + closeActiveTab(); + }} + onSave={handleApplyGisEditPipeBasicInfo} + /> + + ); + } + + if ( + selectedLayerTypeName === SINKHOLE_LAYER_TYPE && + panelSinkholeDetail + ) { + return ( + <> + {tabsView} + + + ); + } + + return ( + <> + {tabsView} + { + setGisEditDetailChangeId(null); + closeActiveTab(); + }} + /> + + ); + }, + [ + activateRightPanelTab, + activeRightPanelTabId, + closeRightPanelTab, + focusSinkholeNearbyPipe, + handleApplyGisEditPipeBasicInfo, + rightPanelTabs, + selectedSinkholeDetail, + selectedSinkholeNearbyPipes, + sinkholeRadiusMeters, + ], + ); + useGisEditSelection({ + mapReady, + gisMaps, + gisEdit, + gisEditVertexLayerRef, + clearSelectedVertexHandle, + gisEditSelectedVertexHandleKeyRef, + gisEditSelectedVertexMetaListRef, + applySelectedVertexHandleStyle, + setGisEditDetailChangeId, + setFeatureData, + setIsOpen, + setIsSearchModalOpen, + gisEditMoveGripMetaRef, + openGisEditPipeDetail, + }); + + useEffect(() => { + const popup = sinkholePopupWindowRef.current; + if (!popup || popup.closed) { + sinkholePopupWindowRef.current = null; + return; + } + + const effectiveFilters = normalizeSinkholeFilters( + appliedSinkholeFilters, + sinkholeScope, + ); + popup.postMessage( + { + type: SINKHOLE_FILTER_SYNC_MESSAGE, + filters: effectiveFilters, + }, + window.location.origin, + ); + }, [appliedSinkholeFilters, sinkholeScope, sinkholeSelectionVersion]); + useFloodHistoryClick({ + mapReady, + gisMaps, + selectedFldngHstryDetail, + isFldngHstryChecked, + fldngHstryAreaLayerRef, + fldngHstryDepthLayerRef, + setFldngHstryDetailPopupPosition, + setIsSearchModalOpen, + }); + + return ( + <> +
+ {layerLoadError ? ( +
+ 레이어 설정 조회 실패: {layerLoadError} +
+ ) : null} + {agedPipeDownloadProgressText ? ( +
+ {agedPipeDownloadProgressText} +
+ ) : null} + {selectedWaterRiskResultRow && waterRiskInfoCardStyle ? ( + // 위험/정상 배지는 통수위험 상태를 즉시 구분하는 업무 의미색이므로 고정색을 유지한다. +
+
+
+
+ 중점관리지역 +
+
+ {selectedWaterRiskResultRow.areaName} +
+
+ + {buildWaterRiskStatusLabel(selectedWaterRiskDisplayStatus)} + +
+
+
GIS 매핑 관로 중 Capacity 0.8 이상이 1개라도 있으면
+
+ 위험, 없으면{" "} + 정상 +
+
+ + + + + + + {[ + [ + "현재 누적강우량(mm)", + selectedWaterRiskMapView?.currentAccumRainQty, + 1, + ], + [ + "예측강우량(mm)", + selectedWaterRiskMapView?.forecastRainQty, + 1, + ], + [ + "최대 침수유량(m³/s)", + selectedWaterRiskMapView?.maxFloodingFlow, + 2, + ], + ].map(([label, value, digits], index) => ( + + + + + ))} + +
+ {label} + + {typeof value === "number" + ? value.toFixed(Number(digits)) + : "-"} +
+
+ ) : null} + {selectedWaterRiskMapView ? ( + // Capacity와 Flooding 색상은 SWMM 결과의 업무 의미색이므로 테마와 무관하게 유지한다. +
+
Capacity
+
+ {[ + ["#0000ff", "≤ 0.25"], + ["#00d9ff", "≤ 0.50"], + ["#00d95f", "≤ 0.75"], + ["#ffe100", "< 1.00"], + ["#ff1c15", "≥ 1.00"], + ].map(([color, label]) => ( +
+ + {label} +
+ ))} +
+
+
Flooding(m³/s)
+
+ ● 0 + ● > 0 +
+
+
+ ) : null} + {selectedFldngHstryDetail && fldngHstryDetailPopupPosition ? ( + + ) : null} + setIsSearchModalOpen(true)} + onToggleGisEdit={handleToggleGisEdit} + isGisEditActive={gisEdit.isEditMode} + isInfoSearchActive={isSearchModalOpen} + /> + {isGisEditSessionInlineVisible ? ( +
+ +
+ ) : null} + + + } + waterDepth={undefined} + layerSettingView={ActiveLayerSettingView} + treeSectionClassName="px-[4px] py-2 text-foreground" + detachedTreeToggleButton={true} + showLayerPanelTitle={false} + sectionedTreePanel={true} + showBulkLayerToggle={true} + isPlaying={isPlaying} + setIsPlaying={setIsPlaying as any} + targetLayerTypes={WATER_DEPTH_LAYER_TYPES as any} + checkInterval={2500} + checkedLayerChangeDelay={0} + showZoomLevel={true} + toolBarIconSize={20} + showZoomScaleInfo={true} + showFloatingSearchButton={false} + treeTogglePosition="center" + zoomGuide={zoomGuide} + layerIconVisible={(node: any) => + isAgedPipeLayerNode(node) || + isSinkholeRootNode(node) || + isSbmrgDngrMapRootNode(node) || + isFloodDngrMapRootNode(node) || + isFldngHstryRootNode(node) + } + layerLegendIconVisible={(node: any) => !isFldngHstryRootNode(node)} + layerIconRequiresChecked={false} + defaultZoom={7.5} + defaultLonLat={[129, 36.3]} + viewExtent={SOUTH_KOREA_VIEW_EXTENT} + /> + {isAgedPipeListMinimized && ( +
+
+ +
+
+
+ 노후관로 현황 +
+
+ {appliedAgedPipeFilters.elapsedYears}년 이상 ·{" "} + {agedPipeTotalCount.toLocaleString()}건 +
+
+ + +
+ )} + +
+ + ); +}; + +export default MapPageClient; diff --git a/apps/sn/app/(map-layout)/map/ui/map-search-bar.tsx b/apps/sn/app/(map-layout)/map/ui/map-search-bar.tsx new file mode 100644 index 000000000..d91ce0074 --- /dev/null +++ b/apps/sn/app/(map-layout)/map/ui/map-search-bar.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { useEffect, useState, type ComponentProps } from "react"; +import { ProcSearchBar } from "@common/business_components/ui/map/searchBar"; + +/** 지도 검색바의 Radix Select는 브라우저 마운트 이후 생성한다. + * 서버와 최초 클라이언트 렌더는 동일한 자리표시자를 사용하여 aria-controls ID의 + * hydration 불일치를 피한다. 공통 Select 및 다른 앱의 렌더링 방식은 변경하지 않는다. + */ +export default function MapSearchBar(props: ComponentProps) { + const [isMounted, setIsMounted] = useState(false); + + useEffect(() => { + setIsMounted(true); + }, []); + + if (!isMounted) { + return ( +
+ ); + } + + return ; +} diff --git a/apps/sn/app/(map-layout)/map/waterDepth/waterDepth.tsx b/apps/sn/app/(map-layout)/map/water-depth/water-depth.tsx similarity index 100% rename from apps/sn/app/(map-layout)/map/waterDepth/waterDepth.tsx rename to apps/sn/app/(map-layout)/map/water-depth/water-depth.tsx diff --git a/apps/sn/app/(popupLayout)/popup/[popupKey]/page.tsx b/apps/sn/app/(popupLayout)/popup/[popupKey]/page.tsx index b2ad89331..3e5abc918 100644 --- a/apps/sn/app/(popupLayout)/popup/[popupKey]/page.tsx +++ b/apps/sn/app/(popupLayout)/popup/[popupKey]/page.tsx @@ -1,10 +1,10 @@ -import { isWindowPopupKey } from "@/features/windowPopup/model/popupDefinitions"; +import { isWindowPopupKey } from "@/features/window-popup/model/popup-definitions"; import { resolveWindowPopupTheme, WINDOW_POPUP_THEME_PARAM, -} from "@/features/windowPopup/model/popupTheme"; -import PopupThemeSync from "@/features/windowPopup/ui/PopupThemeSync"; -import WindowPopupRenderer from "@/features/windowPopup/ui/WindowPopupRenderer"; +} from "@/features/window-popup/model/popup-theme"; +import PopupThemeSync from "@/features/window-popup/ui/popup-theme-sync"; +import WindowPopupRenderer from "@/features/window-popup/ui/window-popup-renderer"; type PopupPageProps = { params: Promise<{ diff --git a/apps/sn/app/(with-base-layout)/fileValidation.tsx b/apps/sn/app/(with-base-layout)/file-validation.tsx similarity index 100% rename from apps/sn/app/(with-base-layout)/fileValidation.tsx rename to apps/sn/app/(with-base-layout)/file-validation.tsx diff --git a/apps/sn/app/(with-base-layout)/ii/analysisResultView.ts b/apps/sn/app/(with-base-layout)/ii/analysis-result-view.ts similarity index 93% rename from apps/sn/app/(with-base-layout)/ii/analysisResultView.ts rename to apps/sn/app/(with-base-layout)/ii/analysis-result-view.ts index 7375d5d09..5c938cb8a 100644 --- a/apps/sn/app/(with-base-layout)/ii/analysisResultView.ts +++ b/apps/sn/app/(with-base-layout)/ii/analysis-result-view.ts @@ -1,6 +1,6 @@ "use client"; -import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup"; +import { openWindowPopup } from "@/features/window-popup/lib/open-window-popup"; type AnalysisResultPopupKey = "inftwtResultPopup" | "rdiiResultPopup"; diff --git a/apps/sn/app/(with-base-layout)/ii/BaseLayout.tsx b/apps/sn/app/(with-base-layout)/ii/base-layout.tsx similarity index 99% rename from apps/sn/app/(with-base-layout)/ii/BaseLayout.tsx rename to apps/sn/app/(with-base-layout)/ii/base-layout.tsx index 28d3b3efe..4c5ee2cab 100644 --- a/apps/sn/app/(with-base-layout)/ii/BaseLayout.tsx +++ b/apps/sn/app/(with-base-layout)/ii/base-layout.tsx @@ -16,7 +16,7 @@ import { DateMonthPicker, Form } from "@common/business_components/ui"; import { Button, Label } from "@common/components/ui"; import { useConfirm } from "@frontend-opensource/use-react-hooks"; import type { ReactNode } from "react"; -import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox"; +import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpv-sgg-prcs-fclt-select-box"; type TitleName = { mainTitle: string; diff --git a/apps/sn/app/(with-base-layout)/ii/irsetting/page.tsx b/apps/sn/app/(with-base-layout)/ii/irsetting/page.tsx index 30914165e..993f4a382 100644 --- a/apps/sn/app/(with-base-layout)/ii/irsetting/page.tsx +++ b/apps/sn/app/(with-base-layout)/ii/irsetting/page.tsx @@ -16,8 +16,8 @@ * - 로그인된 사용자의 사용자 정보 조회: GET /api/v1/users/info/me (user-service) */ "use client"; -import { postMutiPart } from "@/components/lib/api"; -import FileUploadForm from "@/components/ui/fileUploadForm"; +import { postMutiPart } from "@/shared/api/file-api"; +import FileUploadForm from "@/components/ui/file-upload-form"; import { StandardFormField, StandardFormSection, @@ -34,15 +34,15 @@ import { excelToTimePatternData, excelToRcptrInfoData, } from "@/components/lib/utils"; -import BaseLayout from "../BaseLayout"; -import { useAnalysisCompletionWatcher } from "../useAnalysisCompletionWatcher"; -import { openAnalysisResultMapView } from "../analysisResultView"; +import BaseLayout from "../base-layout"; +import { useAnalysisCompletionWatcher } from "../use-analysis-completion-watcher"; +import { openAnalysisResultMapView } from "../analysis-result-view"; import { validateMultipleFiles, FILE_EXTENSIONS, type FileValidationConfig, -} from "../../fileValidation"; -import { validateBasicSelections } from "../../selectionValidation"; +} from "../../file-validation"; +import { validateBasicSelections } from "../../selection-validation"; import { tokenManager } from "@/entities/auth/lib/token-manager"; import { AUTH_USER_ID } from "@/shared/config"; import { useQuery } from "@tanstack/react-query"; diff --git a/apps/sn/app/(with-base-layout)/ii/rdii/page.tsx b/apps/sn/app/(with-base-layout)/ii/rdii/page.tsx index 4b32cebc0..2fd86149a 100644 --- a/apps/sn/app/(with-base-layout)/ii/rdii/page.tsx +++ b/apps/sn/app/(with-base-layout)/ii/rdii/page.tsx @@ -18,11 +18,11 @@ */ "use client"; -import { postMutiPart } from "@/components/lib/api"; -import FileUploadForm from "@/components/ui/fileUploadForm"; -import BaseLayout from "../BaseLayout"; -import { useAnalysisCompletionWatcher } from "../useAnalysisCompletionWatcher"; -import { openAnalysisResultMapView } from "../analysisResultView"; +import { postMutiPart } from "@/shared/api/file-api"; +import FileUploadForm from "@/components/ui/file-upload-form"; +import BaseLayout from "../base-layout"; +import { useAnalysisCompletionWatcher } from "../use-analysis-completion-watcher"; +import { openAnalysisResultMapView } from "../analysis-result-view"; import { DateMonthPicker, filePicked } from "@common/business_components/ui"; import { StandardFormField, @@ -40,13 +40,13 @@ import { import { EditableFormTable, type EditableTableHeadItem, -} from "../../../components/ui/EditableFormTable"; +} from "../../../components/ui/editable-form-table"; import { validateMultipleFiles, FILE_EXTENSIONS, type FileValidationConfig, -} from "../../fileValidation"; -import { validateBasicSelections } from "../../selectionValidation"; +} from "../../file-validation"; +import { validateBasicSelections } from "../../selection-validation"; import { tokenManager } from "@/entities/auth/lib/token-manager"; import { AUTH_USER_ID } from "@/shared/config"; import { useQuery } from "@tanstack/react-query"; diff --git a/apps/sn/app/(with-base-layout)/ii/rdiiResult/page.tsx b/apps/sn/app/(with-base-layout)/ii/rdiiResult/page.tsx index c426473b4..7bc4b7b60 100644 --- a/apps/sn/app/(with-base-layout)/ii/rdiiResult/page.tsx +++ b/apps/sn/app/(with-base-layout)/ii/rdiiResult/page.tsx @@ -18,19 +18,19 @@ * - RDII분석 삭제: DELETE /api/v1/rdiiAnls/{rainInfwtAnlsSn} (sewerage-service) */ "use client"; -import { deleteData, getData } from "@/components/lib/api"; +import { deleteData, getData } from "@/shared/api/sewerage-api"; import { downloadAnalysisExcel } from "@/shared/lib/download-analysis-excel"; import { zodResolver } from "@hookform/resolvers/zod"; import { useState, useEffect } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; -import BaseLayout from "../BaseLayout"; +import BaseLayout from "../base-layout"; import { handleDelete } from "@/components/lib/utils"; -import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue"; -import useStore from "@/store/useStore"; +import { formatDisplayData } from "@/components/lib/utils/format-display-value"; +import useStore from "@/entities/reference-data/model/reference-data-store"; import { useAlertStore } from "@/shared/lib"; import { useConfirm } from "@frontend-opensource/use-react-hooks"; -import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable"; +import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table"; const formSchema = z.object({ swtrmtFcltCd: z.string().default("0"), // 하수처리시설코드 diff --git a/apps/sn/app/(with-base-layout)/ii/result/page.tsx b/apps/sn/app/(with-base-layout)/ii/result/page.tsx index 084795639..4173cb5c3 100644 --- a/apps/sn/app/(with-base-layout)/ii/result/page.tsx +++ b/apps/sn/app/(with-base-layout)/ii/result/page.tsx @@ -18,19 +18,19 @@ * - 침입수분석 삭제: DELETE /api/v1/inftwtAnls/{inftwtAnlsSn} (sewerage-service) */ "use client"; -import { deleteData, getData } from "@/components/lib/api"; +import { deleteData, getData } from "@/shared/api/sewerage-api"; import { downloadAnalysisExcel } from "@/shared/lib/download-analysis-excel"; import { zodResolver } from "@hookform/resolvers/zod"; import { useState, useEffect } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; -import BaseLayout from "../BaseLayout"; +import BaseLayout from "../base-layout"; import { handleDelete } from "@/components/lib/utils"; -import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue"; -import useStore from "../../../store/useStore"; +import { formatDisplayData } from "@/components/lib/utils/format-display-value"; +import useStore from "@/entities/reference-data/model/reference-data-store"; import { useAlertStore } from "@/shared/lib"; import { useConfirm } from "@frontend-opensource/use-react-hooks"; -import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable"; +import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table"; const formSchema = z.object({ swtrmtFcltCd: z.string().default("0"), // 하수처리시설코드 diff --git a/apps/sn/app/(with-base-layout)/ii/useAnalysisCompletionWatcher.tsx b/apps/sn/app/(with-base-layout)/ii/use-analysis-completion-watcher.tsx similarity index 99% rename from apps/sn/app/(with-base-layout)/ii/useAnalysisCompletionWatcher.tsx rename to apps/sn/app/(with-base-layout)/ii/use-analysis-completion-watcher.tsx index 64e69d7a5..5ac7671fe 100644 --- a/apps/sn/app/(with-base-layout)/ii/useAnalysisCompletionWatcher.tsx +++ b/apps/sn/app/(with-base-layout)/ii/use-analysis-completion-watcher.tsx @@ -3,7 +3,7 @@ import { useEffect, useMemo, useState } from "react"; import { usePathname } from "next/navigation"; -import { getRawJson } from "@/components/lib/api"; +import { getRawJson } from "@/shared/api/sewerage-api"; import { useAlertStore } from "@/shared/lib"; type AnalysisStatusResponse = { diff --git a/apps/sn/app/(with-base-layout)/layout.tsx b/apps/sn/app/(with-base-layout)/layout.tsx index 567cbd86d..0e93ec54a 100644 --- a/apps/sn/app/(with-base-layout)/layout.tsx +++ b/apps/sn/app/(with-base-layout)/layout.tsx @@ -20,9 +20,9 @@ import { BaseLayout } from "@common/components/ui/layout/base"; import type { MenuItem } from "@/entities/menu"; import { useMenuStore } from "@/shared/lib"; import { PageLayout } from "@/shared/ui/page-layout"; -import useStore from "../store/useStore"; +import useStore from "@/entities/reference-data/model/reference-data-store"; import { authService } from "@/entities/auth"; -import { GlobalAnalysisCompletionWatcher } from "./ii/useAnalysisCompletionWatcher"; +import { GlobalAnalysisCompletionWatcher } from "./ii/use-analysis-completion-watcher"; export default function WithBaseLayout({ children, diff --git a/apps/sn/app/(with-base-layout)/oper/components/SearchDatePicker.tsx b/apps/sn/app/(with-base-layout)/oper/components/search-date-picker.tsx similarity index 100% rename from apps/sn/app/(with-base-layout)/oper/components/SearchDatePicker.tsx rename to apps/sn/app/(with-base-layout)/oper/components/search-date-picker.tsx diff --git a/apps/sn/app/(with-base-layout)/oper/ctyFldngPrvtnBiz/page.tsx b/apps/sn/app/(with-base-layout)/oper/ctyFldngPrvtnBiz/page.tsx index d6791599c..92c776e89 100644 --- a/apps/sn/app/(with-base-layout)/oper/ctyFldngPrvtnBiz/page.tsx +++ b/apps/sn/app/(with-base-layout)/oper/ctyFldngPrvtnBiz/page.tsx @@ -1,4 +1,4 @@ -import { CtyFldngPrvtnBizListPage } from "@/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizListPage"; +import { CtyFldngPrvtnBizListPage } from "@/features/cty-fldng-prvtn-biz/ui/cty-fldng-prvtn-biz-list-page"; /** 도시침수예방사업 목록 경로다. */ export default function Page(){return ;} diff --git a/apps/sn/app/(with-base-layout)/oper/ctyFldngPrvtnBiz/register/[id]/page.tsx b/apps/sn/app/(with-base-layout)/oper/ctyFldngPrvtnBiz/register/[id]/page.tsx index a212b25c6..7cae2bdd1 100644 --- a/apps/sn/app/(with-base-layout)/oper/ctyFldngPrvtnBiz/register/[id]/page.tsx +++ b/apps/sn/app/(with-base-layout)/oper/ctyFldngPrvtnBiz/register/[id]/page.tsx @@ -1,4 +1,4 @@ -import { CtyFldngPrvtnBizFormPage } from "@/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizFormPage"; +import { CtyFldngPrvtnBizFormPage } from "@/features/cty-fldng-prvtn-biz/ui/cty-fldng-prvtn-biz-form-page"; /** 도시침수예방사업 수정·삭제 경로다. */ export default async function Page({params}:{params:Promise<{id:string}>}){const {id}=await params;return ;} diff --git a/apps/sn/app/(with-base-layout)/oper/ctyFldngPrvtnBiz/register/page.tsx b/apps/sn/app/(with-base-layout)/oper/ctyFldngPrvtnBiz/register/page.tsx index c31ff139d..ba31ac5b1 100644 --- a/apps/sn/app/(with-base-layout)/oper/ctyFldngPrvtnBiz/register/page.tsx +++ b/apps/sn/app/(with-base-layout)/oper/ctyFldngPrvtnBiz/register/page.tsx @@ -1,4 +1,4 @@ -import { CtyFldngPrvtnBizFormPage } from "@/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizFormPage"; +import { CtyFldngPrvtnBizFormPage } from "@/features/cty-fldng-prvtn-biz/ui/cty-fldng-prvtn-biz-form-page"; /** 도시침수예방사업 신규 등록 경로다. */ export default function Page(){return ;} diff --git a/apps/sn/app/(with-base-layout)/oper/dredging/lib/client.ts b/apps/sn/app/(with-base-layout)/oper/dredging/lib/client.ts deleted file mode 100644 index fea0dcf2d..000000000 --- a/apps/sn/app/(with-base-layout)/oper/dredging/lib/client.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** 기존 준설 화면의 API import를 유지한다. 구현과 첨부 업로드 책임은 feature로 분리했다. */ -export * from "@/features/dredging/api/dredging-api"; -export * from "@/features/dredging/api/photo-upload"; -export { isDredgingBackendEnabled } from "@/features/dredging/api/backend-mode"; diff --git a/apps/sn/app/(with-base-layout)/oper/dredging/lib/date.ts b/apps/sn/app/(with-base-layout)/oper/dredging/lib/date.ts deleted file mode 100644 index 0bec2f65d..000000000 --- a/apps/sn/app/(with-base-layout)/oper/dredging/lib/date.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** 기존 준설 화면 import 경로를 유지하는 호환 진입점. 구현은 features/dredging가 소유한다. */ -export * from "@/features/dredging/lib/date"; diff --git a/apps/sn/app/(with-base-layout)/oper/dredging/lib/exif.ts b/apps/sn/app/(with-base-layout)/oper/dredging/lib/exif.ts deleted file mode 100644 index 35e50ce25..000000000 --- a/apps/sn/app/(with-base-layout)/oper/dredging/lib/exif.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** 기존 준설 화면 import 경로를 유지하는 호환 진입점. 구현은 features/dredging가 소유한다. */ -export * from "@/features/dredging/lib/exif"; diff --git a/apps/sn/app/(with-base-layout)/oper/dredging/lib/registration-request.ts b/apps/sn/app/(with-base-layout)/oper/dredging/lib/registration-request.ts deleted file mode 100644 index 0661e4c84..000000000 --- a/apps/sn/app/(with-base-layout)/oper/dredging/lib/registration-request.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** 기존 준설 화면 import 경로를 유지하는 호환 진입점. 구현은 features/dredging가 소유한다. */ -export * from "@/features/dredging/model/registration-request"; diff --git a/apps/sn/app/(with-base-layout)/oper/dredging/lib/types.ts b/apps/sn/app/(with-base-layout)/oper/dredging/lib/types.ts deleted file mode 100644 index 792cbaf93..000000000 --- a/apps/sn/app/(with-base-layout)/oper/dredging/lib/types.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** 기존 준설 화면 import 경로를 유지하는 호환 진입점. 구현은 features/dredging가 소유한다. */ -export * from "@/features/dredging/model/types"; diff --git a/apps/sn/app/(with-base-layout)/oper/dredging/lib/useDredgingCommonCodes.ts b/apps/sn/app/(with-base-layout)/oper/dredging/lib/useDredgingCommonCodes.ts deleted file mode 100644 index 4f09e5521..000000000 --- a/apps/sn/app/(with-base-layout)/oper/dredging/lib/useDredgingCommonCodes.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** 기존 준설 화면 import 경로를 유지하는 호환 진입점. 구현은 features/dredging가 소유한다. */ -export * from "@/features/dredging/lib/use-dredging-common-codes"; diff --git a/apps/sn/app/(with-base-layout)/oper/dredging/lib/uuid.ts b/apps/sn/app/(with-base-layout)/oper/dredging/lib/uuid.ts deleted file mode 100644 index 0c5bf8e87..000000000 --- a/apps/sn/app/(with-base-layout)/oper/dredging/lib/uuid.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** 기존 준설 화면 import 경로를 유지하는 호환 진입점. 구현은 features/dredging가 소유한다. */ -export * from "@/features/dredging/lib/uuid"; diff --git a/apps/sn/app/(with-base-layout)/oper/dredging/page.tsx b/apps/sn/app/(with-base-layout)/oper/dredging/page.tsx index d16914bb4..03100eee6 100644 --- a/apps/sn/app/(with-base-layout)/oper/dredging/page.tsx +++ b/apps/sn/app/(with-base-layout)/oper/dredging/page.tsx @@ -1,948 +1,6 @@ -/** - * ➡️ 경로 : 유지관리 정보 - 준설관리 - * - * - SFR-010-012 화면설계서 50~55쪽을 기준으로 한 FE 선행 구현 - * - 사진 EXIF 촬영일/GPS, 지도 수동 위치, 최근접 관로 자동 매핑 흐름 제공 - * - 지자체별/관경별/경과년도별 조회와 XLSX 다운로드 제공 - */ -"use client"; +import DredgingListPage from "@/features/dredging/ui/dredging-list-page"; -import LoadingOverlay from "@/components/ui/LoadingBar"; -import { useAlertStore } from "@/shared/lib"; -import { - StandardDataTable, - StandardTableSortHeader, - sortStandardTableRows, - type UiTableSort, -} from "@common/business_components"; -import { - Button, - InputDatePicker, - Label, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@common/components"; -import { MS_SWAGE_PPL_CODES } from "@/app/(map-layout)/map/components/common/table"; -import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup"; -import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types"; -import useStore from "@/store/useStore"; -import { - getCoreRowModel, - useReactTable, - type ColumnDef, - type PaginationState, -} from "@tanstack/react-table"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { useConfirm } from "@frontend-opensource/use-react-hooks"; - -import { - deleteDredgingRecord, - fetchDredgingGridPage, - downloadDredgingGridExcel, - isDredgingBackendEnabled, -} from "./lib/client"; -import { createDredgingClientUuid } from "./lib/uuid"; -import type { - DredgingRecord, - DredgingSaveGroup, - DredgingViewType, -} from "./lib/types"; - -const PAGE_SIZE = 10; -const ALL_REGION_OPTION = { text: "전체", val: "0" }; -const pipeMaterialNameByCode = new Map( - MS_SWAGE_PPL_CODES.map((item) => [item.val.trim(), item.text]), -); - -const resolvePipeMaterialName = (value: string) => { - const normalizedValue = value.trim(); - if (!normalizedValue) return "확인불가"; - const normalizedCode = normalizedValue.toUpperCase(); - if (!/^MOP\d+$/.test(normalizedCode)) return normalizedValue; - return pipeMaterialNameByCode.get(normalizedCode) ?? "확인불가"; -}; - -// 백엔드·프록시 상세 오류 대신 사용자가 다음 행동을 알 수 있는 문구만 표시한다. -const DREDGING_LOAD_ERROR_MESSAGE = - "준설현황을 불러오지 못했습니다. 잠시 후 다시 시도해주세요."; -const currentYear = new Date().getFullYear(); -const formatLocalDate = (date: Date) => { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, "0"); - const day = String(date.getDate()).padStart(2, "0"); - return `${year}-${month}-${day}`; -}; - -/** 준설일자 기본 검색기간을 오늘까지의 최근 1년으로 생성한다. */ -const createDefaultDredgingDateRange = () => { - const to = new Date(); - const from = new Date(to); - from.setFullYear(from.getFullYear() - 1); - return { - fromDate: formatLocalDate(from), - toDate: formatLocalDate(to), - }; -}; - -const initialRecords: DredgingRecord[] = [ - { - id: "mock-dredging-1", - dredgingJobSn: "mock-dredging-job-1", - pipeFid: "MOCK-PIPE-1", - mngInstCd: "4173000", - ctpv: "경기도", - sgg: "양평군", - treatmentZone: "양평처리구역", - fcltMngNo: "PPL-DEMO-001", - pipeDiameterMm: 450, - pipeLengthM: 82.4, - pipeMaterial: "콘크리트", - installationYear: 2004, - dredgingDate: `${currentYear}-05-14`, - photoCount: 3, - registeredDate: `${currentYear}-05-15`, - longitude: 127.4875, - latitude: 37.4916, - snappedLongitude: 127.48751, - snappedLatitude: 37.49159, - registrationMode: "PHOTO", - registrationSource: "EXIF", - matchDistanceMeters: 4.28, - isMock: true, - }, - { - id: "mock-dredging-2", - dredgingJobSn: "mock-dredging-job-2", - pipeFid: "MOCK-PIPE-2", - mngInstCd: "1153000", - ctpv: "서울특별시", - sgg: "구로구", - treatmentZone: "서남처리구역", - fcltMngNo: "PPL-DEMO-002", - pipeDiameterMm: 900, - pipeLengthM: 116.7, - pipeMaterial: "PVC", - installationYear: 1992, - dredgingDate: `${currentYear - 1}-11-08`, - photoCount: 0, - registeredDate: `${currentYear - 1}-11-09`, - longitude: 126.8874, - latitude: 37.4954, - snappedLongitude: 126.88741, - snappedLatitude: 37.49539, - registrationMode: "MANUAL", - registrationSource: "MANUAL", - matchDistanceMeters: 8.15, - isMock: true, - }, -]; - -/** 상세·수정 팝업이 동일한 관로·위치 정보를 받도록 쿼리 파라미터를 한곳에서 구성한다. */ -const buildDredgingPopupParams = (record: DredgingRecord) => ({ - sourcePath: window.location.pathname, - id: record.id, - dredgingJobSn: record.dredgingJobSn, - pipeFid: record.pipeFid, - mngInstCd: record.mngInstCd, - ctpv: record.ctpv, - sgg: record.sgg, - treatmentZone: record.treatmentZone, - fcltMngNo: record.fcltMngNo, - pipeDiameterMm: record.pipeDiameterMm, - pipeLengthM: record.pipeLengthM, - pipeMaterial: record.pipeMaterial, - installationYear: record.installationYear, - dredgingDate: record.dredgingDate, - photoCount: record.photoCount, - registeredDate: record.registeredDate, - longitude: record.longitude, - latitude: record.latitude, - snappedLongitude: record.snappedLongitude, - snappedLatitude: record.snappedLatitude, - registrationMode: - record.registrationMode ?? - (record.photoCount === 0 && record.registrationSource === "MANUAL" - ? "MANUAL" - : "PHOTO"), - registrationSource: record.registrationSource, - matchDistanceMeters: record.matchDistanceMeters, - isMock: record.isMock, -}); - -type TableRow = Record & { - recordIds: string[]; -}; - -type TableHeadDefinition = { - id: string; - title: string; - align?: "left" | "center" | "right"; -}; - -const municipalityHeadlist: TableHeadDefinition[] = [ - { id: "rowNumber", title: "순번" }, - { id: "ctpv", title: "시도" }, - { id: "sgg", title: "시군구" }, - { id: "treatmentZone", title: "처리구역" }, - { id: "fcltMngNo", title: "관로 번호" }, - { id: "pipeDiameterMm", title: "관경(mm)", align: "right" }, - { id: "pipeLengthM", title: "관로연장(m)", align: "right" }, - { id: "pipeMaterial", title: "관재질" }, - { id: "installationYear", title: "매설년도" }, - { id: "elapsedYears", title: "경과년도" }, - { id: "dredgingDate", title: "준설일자" }, - { id: "photoCount", title: "등록사진건수", align: "right" }, - { id: "registeredDate", title: "등록일자" }, -]; - -const diameterHeadlist: TableHeadDefinition[] = [ - { id: "ctpv", title: "시도" }, - { id: "totalLengthKm", title: "관로연장(km)", align: "right" }, - { id: "diameter450", title: "450 이하", align: "right" }, - { id: "diameter600", title: "450~600", align: "right" }, - { id: "diameter700", title: "700", align: "right" }, - { id: "diameter900", title: "800~900", align: "right" }, - { id: "diameter1000", title: "1000", align: "right" }, - { id: "diameter1200", title: "1200", align: "right" }, - { id: "diameter1500", title: "1500 이상", align: "right" }, -]; - -const elapsedYearHeadlist: TableHeadDefinition[] = [ - { id: "ctpv", title: "시도" }, - { id: "totalLengthKm", title: "관로연장(km)", align: "right" }, - { id: "under10", title: "10년 미만", align: "right" }, - { id: "year10To20", title: "10년~20년", align: "right" }, - { id: "year20To30", title: "20년~30년", align: "right" }, - { id: "over30", title: "30년 이상", align: "right" }, - { id: "unknown", title: "확인불가", align: "right" }, -]; - -const roundKm = (meters: number) => Number((meters / 1000).toFixed(3)); - -const parseLocalDate = (value: string) => new Date(`${value}T00:00:00`); - -const formatTableValue = (value: unknown) => { - if (typeof value === "number") return value.toLocaleString("ko-KR"); - if (value === null || value === undefined) return ""; - return String(value); -}; - -const groupByCtpv = (records: DredgingRecord[]) => { - const groups = new Map(); - for (const record of records) { - groups.set(record.ctpv, [...(groups.get(record.ctpv) ?? []), record]); - } - return groups; -}; - -const buildDiameterRows = (records: DredgingRecord[]): TableRow[] => - Array.from(groupByCtpv(records), ([ctpv, items]) => { - const sumLength = (predicate: (record: DredgingRecord) => boolean) => - roundKm( - items - .filter(predicate) - .reduce((sum, record) => sum + record.pipeLengthM, 0), - ); - - return { - ctpv, - totalLengthKm: roundKm( - items.reduce((sum, record) => sum + record.pipeLengthM, 0), - ), - diameter450: sumLength((record) => record.pipeDiameterMm <= 450), - diameter600: sumLength( - (record) => record.pipeDiameterMm > 450 && record.pipeDiameterMm <= 600, - ), - diameter700: sumLength( - (record) => record.pipeDiameterMm > 600 && record.pipeDiameterMm <= 700, - ), - diameter900: sumLength( - (record) => record.pipeDiameterMm > 700 && record.pipeDiameterMm <= 900, - ), - diameter1000: sumLength( - (record) => - record.pipeDiameterMm > 900 && record.pipeDiameterMm <= 1000, - ), - diameter1200: sumLength( - (record) => - record.pipeDiameterMm > 1000 && record.pipeDiameterMm <= 1200, - ), - diameter1500: sumLength((record) => record.pipeDiameterMm >= 1500), - recordIds: items.map((record) => record.id), - }; - }); - -const buildElapsedYearRows = (records: DredgingRecord[]): TableRow[] => - Array.from(groupByCtpv(records), ([ctpv, items]) => { - const sumLength = (predicate: (record: DredgingRecord) => boolean) => - roundKm( - items - .filter(predicate) - .reduce((sum, record) => sum + record.pipeLengthM, 0), - ); - const elapsedYears = (record: DredgingRecord) => - record.installationYear === null - ? null - : currentYear - record.installationYear; - - return { - ctpv, - totalLengthKm: roundKm( - items.reduce((sum, record) => sum + record.pipeLengthM, 0), - ), - under10: sumLength((record) => { - const elapsed = elapsedYears(record); - return elapsed !== null && elapsed < 10; - }), - year10To20: sumLength((record) => { - const elapsed = elapsedYears(record); - return elapsed !== null && elapsed >= 10 && elapsed < 20; - }), - year20To30: sumLength((record) => { - const elapsed = elapsedYears(record); - return elapsed !== null && elapsed >= 20 && elapsed < 30; - }), - over30: sumLength((record) => { - const elapsed = elapsedYears(record); - return elapsed !== null && elapsed >= 30; - }), - unknown: sumLength((record) => elapsedYears(record) === null), - recordIds: items.map((record) => record.id), - }; - }); - -const Page = () => { - const alert = useAlertStore((state) => state.setMessage); - const { confirm } = useConfirm(); - const defaultDredgingDateRange = useMemo(createDefaultDredgingDateRange, []); - const { - ctpv: ctpvRegionOptions, - sgg: sggRegionOptions, - fetchCtpv, - fetchSgg, - } = useStore(); - const [fromDate, setFromDate] = useState(defaultDredgingDateRange.fromDate); - const [toDate, setToDate] = useState(defaultDredgingDateRange.toDate); - // 날짜 입력 중에는 기존 결과를 유지하고 조회·초기화 시점에만 적용 기간을 갱신한다. - const [appliedFromDate, setAppliedFromDate] = useState( - defaultDredgingDateRange.fromDate, - ); - const [appliedToDate, setAppliedToDate] = useState( - defaultDredgingDateRange.toDate, - ); - // 선택 UI는 행정구역 코드를, 준설 조회 API는 행정구역 명칭을 사용한다. - const [ctpvCd, setCtpvCd] = useState("0"); - const [sggCd, setSggCd] = useState("0"); - const [ctpvName, setCtpvName] = useState(""); - const [sggName, setSggName] = useState(""); - const [appliedCtpvName, setAppliedCtpvName] = useState(""); - const [appliedSggName, setAppliedSggName] = useState(""); - const [viewType, setViewType] = useState("MUNICIPALITY"); - const [records, setRecords] = useState( - isDredgingBackendEnabled ? [] : initialRecords, - ); - // 서버 페이지와 전체 건수는 별도 보관해 목록 10건이 전체 결과로 오인되지 않게 한다. - const [serverRows, setServerRows] = useState([]); - const [serverTotal, setServerTotal] = useState(0); - const [isLoading, setIsLoading] = useState(isDredgingBackendEnabled); - const [loadError, setLoadError] = useState(null); - const [selectedRecordId, setSelectedRecordId] = useState(null); - // 삭제 중 중복 요청과 선택 변경을 막는다. - const [isMutating, setIsMutating] = useState(false); - const [currentPage, setCurrentPage] = useState(1); - // 조회구분별 전체 결과를 페이지 절단 전에 정렬하는 단일 상태다. - const [sort, setSort] = useState(); - const [searchVersion, setSearchVersion] = useState(0); - - /** 공통 레이아웃에서 아직 지역 기준정보를 받지 못한 경우 조회를 보완한다. */ - useEffect(() => { - if (ctpvRegionOptions.length === 0) void fetchCtpv(); - if (sggRegionOptions.length === 0) void fetchSgg(); - }, [ctpvRegionOptions.length, fetchCtpv, fetchSgg, sggRegionOptions.length]); - - /** 선택한 시도 코드와 앞 두 자리가 같은 시군구만 후보로 제공한다. */ - const availableSggOptions = useMemo(() => { - if (ctpvCd === "0") return []; - const ctpvPrefix = ctpvCd.slice(0, 2); - return sggRegionOptions.filter((option) => - String(option.val).startsWith(ctpvPrefix), - ); - }, [ctpvCd, sggRegionOptions]); - - // 조회 버튼을 누른 시점의 기간으로 실제 준설관리 API를 호출한다. - useEffect(() => { - if (!isDredgingBackendEnabled) return; - let cancelled = false; - const load = async () => { - setIsLoading(true); - setLoadError(null); - try { - const response = await fetchDredgingGridPage({ - viewType, - fromDate: appliedFromDate, - toDate: appliedToDate, - ctpv: appliedCtpvName, - sgg: appliedSggName, - page: currentPage - 1, - size: PAGE_SIZE, - sort: sort ? `${sort.key},${sort.direction}` : undefined, - }); - if (!cancelled) { - setServerRows(response.content); - setServerTotal(response.totalElements); - setRecords(viewType === "MUNICIPALITY" ? response.content as unknown as DredgingRecord[] : []); - } - } catch { - if (!cancelled) { - setRecords([]); - setServerRows([]); - setServerTotal(0); - setLoadError(DREDGING_LOAD_ERROR_MESSAGE); - } - } finally { - if (!cancelled) setIsLoading(false); - } - }; - void load(); - return () => { - cancelled = true; - }; - // 조회 버튼과 등록 완료 이벤트에서 searchVersion을 증가시켜 명시적으로 재조회한다. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [searchVersion, currentPage, viewType, sort, appliedFromDate, appliedToDate, appliedCtpvName, appliedSggName]); - - const filteredRecords = useMemo( - () => - records.filter( - (record) => - record.dredgingDate >= appliedFromDate && - record.dredgingDate <= appliedToDate && - (!appliedCtpvName || record.ctpv === appliedCtpvName) && - (!appliedSggName || record.sgg === appliedSggName), - ), - [ - records, - appliedFromDate, - appliedToDate, - appliedCtpvName, - appliedSggName, - searchVersion, - ], - ); - - const tableRows = useMemo(() => { - if (isDredgingBackendEnabled) { - return serverRows.map((row) => viewType === "MUNICIPALITY" ? { - ...row, - pipeMaterial: resolvePipeMaterialName(String(row.pipeMaterial ?? "")), - installationYear: row.installationYear ?? "확인불가", - elapsedYears: row.elapsedYears ?? "확인불가", - } : row); - } - if (viewType === "DIAMETER") return buildDiameterRows(filteredRecords); - if (viewType === "ELAPSED_YEAR") - return buildElapsedYearRows(filteredRecords); - - return filteredRecords.map((record, index) => ({ - rowNumber: index + 1, - ...record, - pipeLengthM: Number(record.pipeLengthM.toFixed(2)), - pipeMaterial: resolvePipeMaterialName(record.pipeMaterial), - installationYear: record.installationYear ?? "확인불가", - elapsedYears: - record.installationYear === null - ? "확인불가" - : currentYear - record.installationYear, - recordIds: [record.id], - })); - }, [filteredRecords, viewType, serverRows]); - - // 운영 모드는 서버가 정렬·페이징하며 로컬 시연 모드만 메모리 처리를 유지한다. - const sortedTableRows = useMemo( - () => isDredgingBackendEnabled ? tableRows : sortStandardTableRows(tableRows, sort), - [sort, tableRows], - ); - - const pageRows = useMemo( - () => - isDredgingBackendEnabled ? sortedTableRows : sortedTableRows.slice( - (currentPage - 1) * PAGE_SIZE, - currentPage * PAGE_SIZE, - ), - [currentPage, sortedTableRows], - ); - - const selectedRecord = - records.find((record) => record.id === selectedRecordId) ?? null; - const canManageSelectedRecord = - viewType === "MUNICIPALITY" && selectedRecord !== null && !isMutating; - - const headlist = - viewType === "MUNICIPALITY" - ? municipalityHeadlist - : viewType === "DIAMETER" - ? diameterHeadlist - : elapsedYearHeadlist; - - const title = "준설 목록"; - - const columns = useMemo[]>( - () => - headlist.map((head) => ({ - id: head.id, - accessorFn: (row) => row[head.id], - // 표시명 컬럼까지 서버 전체 정렬을 사용한다. - header: () => ( - { - setSort(nextSort); - setCurrentPage(1); - setSelectedRecordId(null); - }} - /> - ), - cell: ({ getValue }) => formatTableValue(getValue()), - meta: { - align: head.align ?? "center", - }, - })), - [headlist, sort, viewType], - ); - const pagination: PaginationState = { - pageIndex: currentPage - 1, - pageSize: PAGE_SIZE, - }; - const selectedTableRowId = pageRows - .find( - (row) => - selectedRecordId !== null && row.recordIds.includes(selectedRecordId), - ) - ?.recordIds.join("|"); - const table = useReactTable({ - data: pageRows, - columns, - state: { - pagination, - rowSelection: selectedTableRowId ? { [selectedTableRowId]: true } : {}, - }, - rowCount: isDredgingBackendEnabled ? serverTotal : sortedTableRows.length, - manualPagination: true, - getCoreRowModel: getCoreRowModel(), - getRowId: (row) => row.recordIds.join("|"), - onPaginationChange: (updater) => { - const next = - typeof updater === "function" ? updater(pagination) : updater; - setCurrentPage(next.pageIndex + 1); - setSelectedRecordId(null); - }, - }); - - const handleSearch = () => { - if (fromDate > toDate) { - alert("조회 시작일은 종료일보다 늦을 수 없습니다."); - return; - } - setCurrentPage(1); - setSelectedRecordId(null); - setAppliedFromDate(fromDate); - setAppliedToDate(toDate); - setAppliedCtpvName(ctpvName); - setAppliedSggName(sggName); - setSearchVersion((version) => version + 1); - }; - - const handleReset = () => { - setFromDate(defaultDredgingDateRange.fromDate); - setToDate(defaultDredgingDateRange.toDate); - setAppliedFromDate(defaultDredgingDateRange.fromDate); - setAppliedToDate(defaultDredgingDateRange.toDate); - setViewType("MUNICIPALITY"); - setCtpvCd("0"); - setSggCd("0"); - setCtpvName(""); - setSggName(""); - setAppliedCtpvName(""); - setAppliedSggName(""); - setSort(undefined); - setCurrentPage(1); - setSelectedRecordId(null); - setSearchVersion((version) => version + 1); - }; - - const handleDownload = async () => { - try { - if (isDredgingBackendEnabled) { - setIsLoading(true); - await downloadDredgingGridExcel({ - viewType, - fromDate: appliedFromDate, - toDate: appliedToDate, - ctpv: appliedCtpvName, - sgg: appliedSggName, - sort: sort ? `${sort.key},${sort.direction}` : undefined, - }); - return; - } - const visibleHeads = headlist.filter((head) => head.id !== "recordIds"); - const excelRows = [ - visibleHeads.map((head) => head.title), - ...sortedTableRows.map((row) => - visibleHeads.map((head) => row[head.id] ?? ""), - ), - ]; - const { utils, writeFileXLSX } = await import("xlsx"); - const worksheet = utils.aoa_to_sheet(excelRows); - worksheet["!cols"] = visibleHeads.map((head) => ({ - wch: Math.max(head.title.length * 2, 12), - })); - const workbook = utils.book_new(); - utils.book_append_sheet(workbook, worksheet, title.slice(0, 31)); - writeFileXLSX( - workbook, - `준설관리_${viewType}_${fromDate}_${toDate}.xlsx`, - { compression: true }, - ); - } catch (error) { - console.error("준설현황 엑셀 다운로드 실패", error); - alert("엑셀 파일을 생성하지 못했습니다."); - } finally { - setIsLoading(false); - } - }; - - const handleSaved = useCallback( - (savedGroups: DredgingSaveGroup[]) => { - if (isDredgingBackendEnabled) { - setCurrentPage(1); - setSelectedRecordId(null); - setSearchVersion((version) => version + 1); - return; - } - const registeredDate = formatLocalDate(new Date()); - const localJobSn = `local-job-${createDredgingClientUuid()}`; - const addedRecords = savedGroups.map((group, index) => ({ - id: `${localJobSn}-${index + 1}`, - dredgingJobSn: localJobSn, - pipeFid: group.pipe.fid, - mngInstCd: group.pipe.mngInstCd, - ctpv: ctpvName || "경기도", - sgg: sggName || "양평군", - treatmentZone: group.pipe.swtrmtZoneNm || "확인불가", - fcltMngNo: group.pipe.fcltMngNo, - pipeDiameterMm: group.pipe.pipeDiameterMm, - pipeLengthM: group.pipe.pipeLengthM, - pipeMaterial: group.pipe.pipeMaterial, - installationYear: group.pipe.installationYear, - dredgingDate: group.dredgingDate, - photoCount: group.photos.length, - registeredDate, - longitude: group.location.longitude, - latitude: group.location.latitude, - snappedLongitude: group.pipe.snappedLongitude, - snappedLatitude: group.pipe.snappedLatitude, - registrationMode: group.registrationMode, - registrationSource: group.locationSource, - matchDistanceMeters: group.pipe.distanceMeters, - isMock: group.pipe.isMock || !isDredgingBackendEnabled, - })); - setRecords((current) => [...addedRecords, ...current]); - setCurrentPage(1); - }, - [ctpvName, sggName], - ); - - /** 상세 화면과 같은 최대화 팝업에서 준설일자·사진·위치를 함께 수정한다. */ - const openEditRecord = () => { - if (!canManageSelectedRecord || !selectedRecord) return; - - const popup = openWindowPopup( - "dredgingEdit", - buildDredgingPopupParams(selectedRecord), - { maximize: true }, - ); - if (!popup) { - alert( - "준설정보 수정 팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.", - ); - } - }; - - /** 선택 행이 속한 준설작업과 연결 관로·사진 참조를 물리 삭제하고 목록을 재조회한다. */ - const handleDeleteRecord = async () => { - if (!canManageSelectedRecord || !selectedRecord) return; - const confirmed = await confirm( - `선택한 준설작업 전체를 삭제하시겠습니까?\n준설일자: ${selectedRecord.dredgingDate}\n현재 페이지에 보이지 않는 연결 관로와 사진도 모두 삭제됩니다.`, - ); - if (!confirmed) return; - - setIsMutating(true); - try { - await deleteDredgingRecord(selectedRecord.dredgingJobSn); - if (isDredgingBackendEnabled) { - setSearchVersion((version) => version + 1); - } else { - setRecords((current) => - current.filter( - (record) => record.dredgingJobSn !== selectedRecord.dredgingJobSn, - ), - ); - } - setSelectedRecordId(null); - alert("준설정보를 삭제했습니다."); - } catch (error) { - alert( - error instanceof Error - ? error.message - : "준설정보를 삭제하지 못했습니다.", - ); - } finally { - setIsMutating(false); - } - }; - - /** 공통 WindowPopup 체계로 준설결과 등록 화면을 연다. */ - const openDredgingRegister = () => { - const popup = openWindowPopup( - "dredgingRegister", - { sourcePath: window.location.pathname }, - { maximize: true }, - ); - if (!popup) { - alert( - "준설결과 등록 팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.", - ); - } - }; - - /** 등록·수정 팝업의 저장 완료 메시지를 받아 목록을 최신 상태로 갱신한다. */ - useEffect(() => { - const handlePopupMessage = (event: MessageEvent) => { - if (event.origin !== window.location.origin) return; - if (event.data?.type === WINDOW_POPUP_MESSAGE_TYPE.DREDGING_UPDATED) { - setSelectedRecordId(null); - setCurrentPage(1); - setSearchVersion((version) => version + 1); - return; - } - if (event.data?.type !== WINDOW_POPUP_MESSAGE_TYPE.DREDGING_REGISTERED) { - return; - } - - try { - const savedGroups = JSON.parse( - event.data?.payload?.groups ?? "[]", - ) as DredgingSaveGroup[]; - if (Array.isArray(savedGroups) && savedGroups.length > 0) { - handleSaved(savedGroups); - } - } catch (error) { - console.error("준설결과 등록 팝업 응답 처리 실패", error); - } - }; - - window.addEventListener("message", handlePopupMessage); - return () => window.removeEventListener("message", handlePopupMessage); - }, [handleSaved]); - - return ( - <> - -
-
{ - event.preventDefault(); - handleSearch(); - }} - > -
-
- - date && setFromDate(formatLocalDate(date))} - className="w-[15.5rem]" - /> - ~ - date && setToDate(formatLocalDate(date))} - className="w-[15.5rem]" - /> -
- -
- - -
- -
- - -
- -
- - -
- - - -
-
-
- - {loadError ? ( -
- {loadError} - -
- ) : null} - -
- { - setSelectedRecordId(row.recordIds[0] ?? null); - }} - actions={ - <> - - - - - - } - /> -
- - ); -}; - -export default Page; +/** 준설관리 URL은 유지하고 실제 화면·업무 처리는 준설 기능이 소유한다. */ +export default function Page() { + return ; +} diff --git a/apps/sn/app/(with-base-layout)/oper/exmn/register/page.tsx b/apps/sn/app/(with-base-layout)/oper/exmn/register/page.tsx index bf2fa6ac3..c79268a96 100644 --- a/apps/sn/app/(with-base-layout)/oper/exmn/register/page.tsx +++ b/apps/sn/app/(with-base-layout)/oper/exmn/register/page.tsx @@ -1,4 +1,4 @@ -import { ExmnRegistrationPage } from "@/features/exmn/ui/ExmnRegistrationPage"; +import { ExmnRegistrationPage } from "@/features/exmn/ui/exmn-registration-page"; /** SFR-010-004 사업정보·조사결과 등록 화면 라우트다. */ export default function Page() { diff --git a/apps/sn/app/(with-base-layout)/oper/exmn/status/page.tsx b/apps/sn/app/(with-base-layout)/oper/exmn/status/page.tsx index e99f6eb4e..a408290c4 100644 --- a/apps/sn/app/(with-base-layout)/oper/exmn/status/page.tsx +++ b/apps/sn/app/(with-base-layout)/oper/exmn/status/page.tsx @@ -1,4 +1,4 @@ -import { ExmnStatusPage } from "@/features/exmn/ui/ExmnStatusPage"; +import { ExmnStatusPage } from "@/features/exmn/ui/exmn-status-page"; /** SFR-010-005 조사현황과 처리분구별 조사 상세현황 라우트다. */ export default function Page() { 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 98561a9e3..a221c8ffa 100644 --- a/apps/sn/app/(with-base-layout)/oper/floodPre/page.tsx +++ b/apps/sn/app/(with-base-layout)/oper/floodPre/page.tsx @@ -18,10 +18,11 @@ */ "use client"; import { getSnRowNumber } from "@/shared/lib/row-number"; -import { deleteData, useCookieAuth } from "@/components/lib/api"; -import { searchData } from "@/components/lib/common/apiHelpers"; +import { deleteData } from "@/shared/api/sewerage-api"; +import { useCookieAuth } from "@/entities/auth/lib/use-cookie-auth"; +import { searchData } from "@/shared/api/search-page"; import { formatDate, handleDownload } from "@/components/lib/utils"; -import LoadingOverlay from "@/components/ui/LoadingBar"; +import LoadingOverlay from "@/components/ui/loading-bar"; import { StandardDataTable, } from "@common/business_components"; @@ -33,9 +34,9 @@ import { type ColumnDef, type PaginationState, } from "@tanstack/react-table"; -import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup"; -import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types"; -import { SearchDatePicker } from "../components/SearchDatePicker"; +import { openWindowPopup } from "@/features/window-popup/lib/open-window-popup"; +import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types"; +import { SearchDatePicker } from "../components/search-date-picker"; import { useConfirm } from "@frontend-opensource/use-react-hooks"; import { useAlertStore } from "@/shared/lib"; diff --git a/apps/sn/app/(with-base-layout)/oper/floodPre/tableModal.tsx b/apps/sn/app/(with-base-layout)/oper/floodPre/table-modal.tsx similarity index 99% rename from apps/sn/app/(with-base-layout)/oper/floodPre/tableModal.tsx rename to apps/sn/app/(with-base-layout)/oper/floodPre/table-modal.tsx index 208dfad41..f515dbf20 100644 --- a/apps/sn/app/(with-base-layout)/oper/floodPre/tableModal.tsx +++ b/apps/sn/app/(with-base-layout)/oper/floodPre/table-modal.tsx @@ -15,7 +15,7 @@ import { } from "@tanstack/react-table"; import * as XLSX from "xlsx"; -import { getBlobData } from "@/components/lib/api"; +import { getBlobData } from "@/shared/api/file-api"; import { useAlertStore } from "@/shared/lib"; import { StandardDataTable } from "@common/business_components"; import { UITab } from "@common/business_components/ui"; diff --git a/apps/sn/app/(with-base-layout)/oper/obsltPplBiz/page.tsx b/apps/sn/app/(with-base-layout)/oper/obsltPplBiz/page.tsx index d1721ebe7..5ac167ab0 100644 --- a/apps/sn/app/(with-base-layout)/oper/obsltPplBiz/page.tsx +++ b/apps/sn/app/(with-base-layout)/oper/obsltPplBiz/page.tsx @@ -1,4 +1,4 @@ -import { ObsltPplListPage } from "@/features/obsltPpl/ui/ObsltPplListPage"; +import { ObsltPplListPage } from "@/features/obslt-ppl/ui/obslt-ppl-list-page"; /** 노후하수관로 정밀조사·정비사업 목록 경로다. */ export default function Page() { diff --git a/apps/sn/app/(with-base-layout)/oper/obsltPplBiz/register/[id]/page.tsx b/apps/sn/app/(with-base-layout)/oper/obsltPplBiz/register/[id]/page.tsx index 1f8f5e481..1969e3471 100644 --- a/apps/sn/app/(with-base-layout)/oper/obsltPplBiz/register/[id]/page.tsx +++ b/apps/sn/app/(with-base-layout)/oper/obsltPplBiz/register/[id]/page.tsx @@ -1,4 +1,4 @@ -import { ObsltPplFormPage } from "@/features/obsltPpl/ui/ObsltPplFormPage"; +import { ObsltPplFormPage } from "@/features/obslt-ppl/ui/obslt-ppl-form-page"; /** 노후하수관로 사업 수정·삭제 경로다. */ export default async function Page({ params }: { params: Promise<{ id: string }> }) { diff --git a/apps/sn/app/(with-base-layout)/oper/obsltPplBiz/register/page.tsx b/apps/sn/app/(with-base-layout)/oper/obsltPplBiz/register/page.tsx index ab92afe46..708b390b7 100644 --- a/apps/sn/app/(with-base-layout)/oper/obsltPplBiz/register/page.tsx +++ b/apps/sn/app/(with-base-layout)/oper/obsltPplBiz/register/page.tsx @@ -1,4 +1,4 @@ -import { ObsltPplFormPage } from "@/features/obsltPpl/ui/ObsltPplFormPage"; +import { ObsltPplFormPage } from "@/features/obslt-ppl/ui/obslt-ppl-form-page"; /** 노후하수관로 사업 신규 등록 경로다. */ export default function Page() { diff --git a/apps/sn/app/(with-base-layout)/oper/oldPp/headList.ts b/apps/sn/app/(with-base-layout)/oper/oldPp/head-list.ts similarity index 100% rename from apps/sn/app/(with-base-layout)/oper/oldPp/headList.ts rename to apps/sn/app/(with-base-layout)/oper/oldPp/head-list.ts 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 e5c040ba1..08f5c90b4 100644 --- a/apps/sn/app/(with-base-layout)/oper/oldPp/page.tsx +++ b/apps/sn/app/(with-base-layout)/oper/oldPp/page.tsx @@ -15,18 +15,19 @@ import { } from "@tanstack/react-table"; import { useConfirm } from "@frontend-opensource/use-react-hooks"; -import { deleteData, useCookieAuth } from "@/components/lib/api"; -import { searchData } from "@/components/lib/common/apiHelpers"; +import { deleteData } from "@/shared/api/sewerage-api"; +import { useCookieAuth } from "@/entities/auth/lib/use-cookie-auth"; +import { searchData } from "@/shared/api/search-page"; import { formatDate, handleDownload } from "@/components/lib/utils"; -import LoadingOverlay from "@/components/ui/LoadingBar"; -import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup"; -import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types"; +import LoadingOverlay from "@/components/ui/loading-bar"; +import { openWindowPopup } from "@/features/window-popup/lib/open-window-popup"; +import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types"; import { useAlertStore } from "@/shared/lib"; import { StandardDataTable, } from "@common/business_components"; import { Button, Input, Label } from "@common/components"; -import { SearchDatePicker } from "../components/SearchDatePicker"; +import { SearchDatePicker } from "../components/search-date-picker"; const PAGE_SIZE = 10; diff --git a/apps/sn/app/(with-base-layout)/oper/oldPp/tableModal.tsx b/apps/sn/app/(with-base-layout)/oper/oldPp/table-modal.tsx similarity index 99% rename from apps/sn/app/(with-base-layout)/oper/oldPp/tableModal.tsx rename to apps/sn/app/(with-base-layout)/oper/oldPp/table-modal.tsx index 0471872fb..b3d58035b 100644 --- a/apps/sn/app/(with-base-layout)/oper/oldPp/tableModal.tsx +++ b/apps/sn/app/(with-base-layout)/oper/oldPp/table-modal.tsx @@ -10,11 +10,11 @@ import { } from "@tanstack/react-table"; import * as XLSX from "xlsx"; -import { getBlobData } from "@/components/lib/api"; +import { getBlobData } from "@/shared/api/file-api"; import { useAlertStore } from "@/shared/lib"; import { StandardDataTable } from "@common/business_components"; import { UITab } from "@common/business_components/ui"; -import { oldPpTable2HeadList, oldPpTable6HeadList } from "./headList"; +import { oldPpTable2HeadList, oldPpTable6HeadList } from "./head-list"; const TABS = [ { val: "1", text: "raw data" }, diff --git a/apps/sn/app/(with-base-layout)/oper/operStore/page.tsx b/apps/sn/app/(with-base-layout)/oper/operStore/page.tsx index ab4561a16..131ca1a6b 100644 --- a/apps/sn/app/(with-base-layout)/oper/operStore/page.tsx +++ b/apps/sn/app/(with-base-layout)/oper/operStore/page.tsx @@ -5,7 +5,7 @@ import { useEffect, useMemo, useState } from "react"; import { SelectBox } from "@common/business_components/ui"; import { Label } from "@common/components/ui"; -import useOnsemiroAddrStore from "@/store/useOnsemiroAddrStore"; +import useOnsemiroAddrStore from "@/entities/reference-data/model/onsemiro-address-store"; const DEFAULT_OPTION = { text: "전체", val: "0" }; diff --git a/apps/sn/app/(with-base-layout)/oper/trtdst/page.tsx b/apps/sn/app/(with-base-layout)/oper/trtdst/page.tsx index 57b0fc935..9d64722d4 100644 --- a/apps/sn/app/(with-base-layout)/oper/trtdst/page.tsx +++ b/apps/sn/app/(with-base-layout)/oper/trtdst/page.tsx @@ -1,4 +1,4 @@ -import { TrtdstManagementPage } from "@/features/exmn/ui/TrtdstManagementPage"; +import { TrtdstManagementPage } from "@/features/exmn/ui/trtdst-management-page"; /** 처리분구관리 업무 화면 라우트다. */ export default function Page() { diff --git a/apps/sn/app/(with-base-layout)/selectionValidation.tsx b/apps/sn/app/(with-base-layout)/selection-validation.tsx similarity index 100% rename from apps/sn/app/(with-base-layout)/selectionValidation.tsx rename to apps/sn/app/(with-base-layout)/selection-validation.tsx diff --git a/apps/sn/app/components/lib/api/index.ts b/apps/sn/app/components/lib/api/index.ts deleted file mode 100644 index 0d431c803..000000000 --- a/apps/sn/app/components/lib/api/index.ts +++ /dev/null @@ -1,254 +0,0 @@ -/** - * 📄 API Utility - * API 호출 유틸리티 파일 - * - * - 다양한 HTTP 메소드의 API 호출 함수 (GET, POST, PUT, DELETE) - * - 쿠키 기반 인증 토큰 관리 및 사용자 조회 - * - 특정 서비스 (DC, CMS, GeoServer) 호출 지원 - * - Axios 및 Fetch 기반 HTTP 요청 처리 - * - 타입 안전성 보장을 위한 인터페이스 활용 - * - 에러 핸들링 및 응답 처리 표준화 - */ -import { useEffect, useState } from "react"; -import axios from 'axios'; -import { AxiosError } from 'axios'; -import { CLAIM_NAME, SITE_ID } from "@/shared/config"; -import { tokenManager } from "@/entities/auth/lib/token-manager"; -import { getRemainingMs } from "@common/auth"; -// 기존 shared/api barrel의 인증 client 초기화 부수효과를 불러오지 않는 순수 transport다. -import { createHttpTransport } from "@/shared/api/http-transport"; - -const BASE_URL = '/sewerage-service/api'; -const DEFAULT_HEADERS = { - "X-Site-Id": SITE_ID, -}; -const LOGIN_PAGE_URL = - process.env.NEXT_PUBLIC_CM_LOGIN_PAGE_URL || "/auth/login"; - -const withSiteIdHeaders = (headers: HeadersInit = {}) => ({ - ...headers, - ...DEFAULT_HEADERS, -}); - -const resolveApiUrl = (url: string) => - url.startsWith('/') ? `${url}` : `${BASE_URL}/${url}`; - -const handleUnauthorizedResponse = () => { - if (typeof window === "undefined") return; - - const accessToken = tokenManager.getToken(CLAIM_NAME); - const remainingMs = accessToken ? getRemainingMs(accessToken) : null; - const isExpired = remainingMs === null || remainingMs <= 0; - - if (!isExpired) return; - - tokenManager.clearAuthToken(); - - const isLoginPage = - window.location.pathname === "/auth/login" || - window.location.pathname.startsWith("/auth/login/"); - - if (!isLoginPage) { - window.location.assign(LOGIN_PAGE_URL); - } -}; - -// 기존 import 경로를 유지하는 호환 진입점이다. HTTP 처리는 shared/api가 소유한다. -const httpTransport = createHttpTransport({ - baseUrl: BASE_URL, - headers: DEFAULT_HEADERS, - onUnauthorized: handleUnauthorizedResponse, -}); -export const { getData, postData, putData, getRawJson, postRawJson, deleteData } = - httpTransport; - -const parseDownloadFileName = (contentDisposition: string | null) => { - if (!contentDisposition) return undefined; - - const utf8Match = contentDisposition.match(/filename\*\s*=\s*UTF-8''([^;]+)/i); - if (utf8Match?.[1]) { - return decodeURIComponent(utf8Match[1].trim()); - } - - const plainMatch = contentDisposition.match(/filename\s*=\s*"?([^\";]+)"?/i); - return plainMatch?.[1]?.trim(); -}; - -export const postBlobData = async ( - url: string, - data: object, -): Promise<{ blob: Blob; fileName?: string }> => { - const res = await fetch(resolveApiUrl(url), { - method: "POST", - headers: withSiteIdHeaders({ - "Content-Type": "application/json", - }), - body: JSON.stringify(data), - }); - if (res.status === 401) { - handleUnauthorizedResponse(); - } - - if (!res.ok) { - const errorText = await res.text().catch(() => ""); - throw new Error( - `Failed to fetch blob: ${res.status} ${res.statusText}${errorText ? ` - ${errorText.slice(0, 200)}` : ""} (${url})`, - ); - } - - return { - blob: await res.blob(), - fileName: parseDownloadFileName(res.headers.get("content-disposition")), - }; -}; - -export const postMutiPart = async (url: string, data: FormData): Promise => { - try { - const res = await axios.post(resolveApiUrl(url), data, { - headers: DEFAULT_HEADERS, - timeout: 600000, // 10분 - }); - console.log('postMultiPart response:', res); - return res.data.data; - } catch (error) { - if (error instanceof AxiosError) { - const responseBody = error.response?.data; - const serverMessage = - typeof responseBody?.message === 'string' && responseBody.message.trim() - ? responseBody.message.trim() - : typeof responseBody?.error?.message === 'string' && responseBody.error.message.trim() - ? responseBody.error.message.trim() - : typeof responseBody === 'string' && responseBody.trim() - ? responseBody.trim() - : null; - - const nextError = new Error( - serverMessage ?? `Multipart request failed: ${error.response?.status ?? 'UNKNOWN'} ${error.response?.statusText ?? ''}`.trim(), - ) as Error & { status?: number; body?: unknown }; - nextError.status = error.response?.status; - nextError.body = responseBody; - throw nextError; - } - throw error; - } -}; - -export const getBlobData = async (url: string): Promise => { - const res = await fetch(resolveApiUrl(url), { - method: "GET", - headers: withSiteIdHeaders(), - }); - if (res.status === 401) { - handleUnauthorizedResponse(); - } - - if (!res.ok) { - 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(); -}; - -export const getBlobDataWithHeaders = async ( - url: string, -): Promise<{ blob: Blob; fileName?: string; headers: Headers }> => { - const res = await fetch(resolveApiUrl(url), { - method: "GET", - headers: withSiteIdHeaders(), - }); - if (res.status === 401) { - handleUnauthorizedResponse(); - } - - if (!res.ok) { - const errorText = await res.text().catch(() => ""); - throw new Error( - `Failed to fetch blob: ${res.status} ${res.statusText}${errorText ? ` - ${errorText.slice(0, 200)}` : ""} (${url})`, - ); - } - - return { - blob: await res.blob(), - fileName: parseDownloadFileName(res.headers.get("content-disposition")), - headers: res.headers, - }; -}; - -// DC API 호출 -export const getDcData = async (url: string, resType: string = "json"): Promise => { - const res = await fetch('/dc-service' + url, { - method: "GET", - headers: withSiteIdHeaders(), - }); - if (res.status === 401) { - handleUnauthorizedResponse(); - } - if (res.ok) { - let data; - if (resType === "json") data = await res.json(); - else if (resType === "blob") data = await res.blob(); - - return data; - } - throw new Error('Failed to fetch data'); -}; - -// CMS API 호출 -export const getCmsData = async (url: string, data?: object): Promise => { - const res = await fetch('/cms-service' + url, { - method: "GET", - headers: withSiteIdHeaders(), - }); - if (res.status === 401) { - handleUnauthorizedResponse(); - } - if (res.ok) { - const data = await res.json(); - return data; - } - throw new Error('Failed to fetch data'); -}; - -// GeoServer API 호출 -export const getGeoData = async (url: string, data?: object): Promise => { - const res = await fetch('/geoserver' + url, { - method: "GET", - headers: withSiteIdHeaders(), - }); - if (res.status === 401) { - handleUnauthorizedResponse(); - } - if (res.ok) { - const data = await res.json(); - return data; - } - throw new Error('Failed to fetch data'); -}; - -export function getCookie(name: string) { - const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)')); - if (match && match[2]) { - return decodeURIComponent(match[2]); - } - return null; -} - -export const useCookieAuth = () => { - const [data, setData] = useState(""); - - useEffect(() => { - const fetch = async () => { - const result = getCookie("Authorization"); - if (result) { - setData(result); - } - }; - fetch(); - console.log("useCookieAuth 호출"); - }, []); - - return data; -}; diff --git a/apps/sn/app/components/lib/utils/excelToJson.ts b/apps/sn/app/components/lib/utils/excel-to-json.ts similarity index 100% rename from apps/sn/app/components/lib/utils/excelToJson.ts rename to apps/sn/app/components/lib/utils/excel-to-json.ts diff --git a/apps/sn/app/components/lib/utils/fileDeleteUtil.tsx b/apps/sn/app/components/lib/utils/file-delete-util.tsx similarity index 98% rename from apps/sn/app/components/lib/utils/fileDeleteUtil.tsx rename to apps/sn/app/components/lib/utils/file-delete-util.tsx index d9525f028..d9574df08 100644 --- a/apps/sn/app/components/lib/utils/fileDeleteUtil.tsx +++ b/apps/sn/app/components/lib/utils/file-delete-util.tsx @@ -14,7 +14,7 @@ */ import { getCookie } from "@/shared/lib"; import { SITE_ID } from "@/shared/config"; -import { ENDPOINT } from "../api/attachment/attachment-service"; +import { ENDPOINT } from "@/entities/attachment/api/attachment-service"; const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id"; const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization"; diff --git a/apps/sn/app/components/lib/utils/fileDownload.ts b/apps/sn/app/components/lib/utils/file-download.ts similarity index 100% rename from apps/sn/app/components/lib/utils/fileDownload.ts rename to apps/sn/app/components/lib/utils/file-download.ts diff --git a/apps/sn/app/components/lib/utils/fileUploadUtil.tsx b/apps/sn/app/components/lib/utils/file-upload-util.tsx similarity index 96% rename from apps/sn/app/components/lib/utils/fileUploadUtil.tsx rename to apps/sn/app/components/lib/utils/file-upload-util.tsx index 39e38c57c..955ad20b9 100644 --- a/apps/sn/app/components/lib/utils/fileUploadUtil.tsx +++ b/apps/sn/app/components/lib/utils/file-upload-util.tsx @@ -11,7 +11,7 @@ */ import { Upload, type HttpResponse } from "tus-js-client"; import { getCookie, setCookie } from "@/shared/lib"; -import { ENDPOINT } from "../api/attachment/attachment-service"; +import { ENDPOINT } from "@/entities/attachment/api/attachment-service"; const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id"; const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization"; diff --git a/apps/sn/app/components/lib/utils/formatDisplayValue.tsx b/apps/sn/app/components/lib/utils/format-display-value.tsx similarity index 98% rename from apps/sn/app/components/lib/utils/formatDisplayValue.tsx rename to apps/sn/app/components/lib/utils/format-display-value.tsx index d6a564467..238497adf 100644 --- a/apps/sn/app/components/lib/utils/formatDisplayValue.tsx +++ b/apps/sn/app/components/lib/utils/format-display-value.tsx @@ -10,7 +10,7 @@ * - null/undefined 값 처리 및 기본값 설정 * - 다양한 필드 타입별 조건부 포맷팅 로직 */ -import { dateFields, numberFields, yearMonthFields, dateTimeFields } from "../../../(map-layout)/map/components/common/table"; +import { dateFields, numberFields, yearMonthFields, dateTimeFields } from "@/entities/facility/model/field-formats"; import { formatDateString } from "./date"; // 🎯 데이터 객체 전체를 포맷팅하는 함수 diff --git a/apps/sn/app/components/lib/utils/index.tsx b/apps/sn/app/components/lib/utils/index.tsx index 8bc8cf9be..4e762e419 100644 --- a/apps/sn/app/components/lib/utils/index.tsx +++ b/apps/sn/app/components/lib/utils/index.tsx @@ -1,7 +1,7 @@ -export * from "./fileUploadUtil"; -export * from "./fileDownload"; +export * from "./file-upload-util"; +export * from "./file-download"; export * from './date' -export * from './excelToJson' -export * from './fileDeleteUtil' -export * from './fileDownload' -export * from './formatDisplayValue' +export * from './excel-to-json' +export * from './file-delete-util' +export * from './file-download' +export * from './format-display-value' diff --git a/apps/sn/app/components/ui/ctpvSggPrcsFcltSelectBox.tsx b/apps/sn/app/components/ui/ctpv-sgg-prcs-fclt-select-box.tsx similarity index 98% rename from apps/sn/app/components/ui/ctpvSggPrcsFcltSelectBox.tsx rename to apps/sn/app/components/ui/ctpv-sgg-prcs-fclt-select-box.tsx index c92eb6184..7a1f410d1 100644 --- a/apps/sn/app/components/ui/ctpvSggPrcsFcltSelectBox.tsx +++ b/apps/sn/app/components/ui/ctpv-sgg-prcs-fclt-select-box.tsx @@ -15,8 +15,8 @@ import React, { useState, useEffect } from "react"; import { SelectBox } from "@common/business_components/ui"; import { Label } from "@common/components"; -import useStore from "../../store/useStore"; -import { getData } from "@/components/lib/api/index"; +import useStore from "@/entities/reference-data/model/reference-data-store"; +import { getData } from "@/shared/api/sewerage-api"; interface ApiSelectOption { text: string; diff --git a/apps/sn/app/components/ui/EditableFormTable.tsx b/apps/sn/app/components/ui/editable-form-table.tsx similarity index 100% rename from apps/sn/app/components/ui/EditableFormTable.tsx rename to apps/sn/app/components/ui/editable-form-table.tsx diff --git a/apps/sn/app/components/ui/fileUploadForm.tsx b/apps/sn/app/components/ui/file-upload-form.tsx similarity index 100% rename from apps/sn/app/components/ui/fileUploadForm.tsx rename to apps/sn/app/components/ui/file-upload-form.tsx diff --git a/apps/sn/app/components/ui/LoadingBar/index.tsx b/apps/sn/app/components/ui/loading-bar/index.tsx similarity index 100% rename from apps/sn/app/components/ui/LoadingBar/index.tsx rename to apps/sn/app/components/ui/loading-bar/index.tsx diff --git a/apps/sn/app/components/ui/operFileUploadForm.tsx b/apps/sn/app/components/ui/oper-file-upload-form.tsx similarity index 98% rename from apps/sn/app/components/ui/operFileUploadForm.tsx rename to apps/sn/app/components/ui/oper-file-upload-form.tsx index 022fe7170..25e369d11 100644 --- a/apps/sn/app/components/ui/operFileUploadForm.tsx +++ b/apps/sn/app/components/ui/oper-file-upload-form.tsx @@ -12,7 +12,7 @@ "use client"; import { SelectBox } from "@common/business_components/ui"; import React, { useState, useEffect } from "react"; -import FileUploadForm from "./fileUploadForm"; +import FileUploadForm from "./file-upload-form"; import { yearOptions, getQuarterOptions } from "@/components/lib/utils"; const operFileUploadForm = ({ diff --git a/apps/sn/app/components/ui/verticalTable.tsx b/apps/sn/app/components/ui/vertical-table.tsx similarity index 100% rename from apps/sn/app/components/ui/verticalTable.tsx rename to apps/sn/app/components/ui/vertical-table.tsx diff --git a/apps/sn/app/components/ui/YearRangeSelectBox.tsx b/apps/sn/app/components/ui/year-range-select-box.tsx similarity index 100% rename from apps/sn/app/components/ui/YearRangeSelectBox.tsx rename to apps/sn/app/components/ui/year-range-select-box.tsx diff --git a/apps/sn/docs/기준문서/009-네이밍규칙.md b/apps/sn/docs/기준문서/009-네이밍규칙.md index 39626ba75..e1b5330d5 100644 --- a/apps/sn/docs/기준문서/009-네이밍규칙.md +++ b/apps/sn/docs/기준문서/009-네이밍규칙.md @@ -5,7 +5,11 @@ ## 2. 파일/폴더 - 화면 파일과 폴더는 기능 기준으로 묶고, 한 화면에서 여러 역할이 섞이면 하위 폴더로 분리한다. -- 문서 파일은 현재처럼 한글 제목을 사용할 수 있지만, 코드 파일은 기존 프로젝트 관례를 우선 따른다. +- 일반 코드 파일과 비라우트 내부 폴더는 kebab-case로 통일한다(2026-09-28 사용자 지시). 예: `map-page-client.tsx`, `use-map-runtime-bridge.ts`, `gis-edit-types.ts`, `features/window-popup`. +- React 컴포넌트·클래스의 코드 내 이름은 PascalCase, 훅·함수는 camelCase를 유지한다. 파일명 통일을 이유로 API 필드·export symbol·업무 코드값을 바꾸지 않는다. +- URL을 구성하는 기존 app 라우트 폴더, `(group)`, `[param]`, `[...param]`과 Next 지정 파일 `page.tsx`, `layout.tsx`, `route.ts` 등은 이번 통일 대상에서 제외한다. API lowerCamelCase 경로도 유지한다. +- `index.ts`, `.d.ts`·`.module.css` 같은 확장자 관례, 설정 파일명·정적 자원·문서 파일명은 보존한다. 문서 파일은 기존 한글 제목과 경로를 유지한다. +- SN의 일반 내부 폴더는 `ui`, `lib`, `model`을 사용하며 underscore 접두사를 일괄 도입하지 않는다. business_components 및 타 앱 경로 변경은 소비자 영향 분석과 별도 승인 범위로 구분한다. - 임시 파일명, `new`, `temp`, `test2` 같은 이름은 커밋 전에 정리한다. ## 3. 컴포넌트 diff --git a/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/002-qa-SFR-010-004~007-조사사업-통합검증결과.md b/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/002-qa-SFR-010-004~007-조사사업-통합검증결과.md index 88cc194e3..e0d291237 100644 --- a/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/002-qa-SFR-010-004~007-조사사업-통합검증결과.md +++ b/apps/sn/docs/작업검증/SFR-010-004~SFR-010-007-조사사업/002-qa-SFR-010-004~007-조사사업-통합검증결과.md @@ -639,3 +639,33 @@ - `git diff --check`는 줄바꿈 변환 경고 외 whitespace 오류 없이 통과했다. - 사용자 요청에 따라 전체 TypeScript 검사와 Next 빌드는 수행하지 않았다. - 조사 종합집계 필터, 조사 상세표·Excel, 본단위등급표와 시설 상세의 코드명 수동 검증이 남아 있다. + +## 2026-10-01 조사 레이어 검색 독립 표출 검증 +- BE compileJava --offline 통과. 임시 ExmnMapLayerScopeTest 10건 통과(실패 0/오류 0): 다지역 동일 번호 분리, 지역 미확정, 중복 도형 배제, 번호/도형 누락 분류, 빈 결과, 선택 사업/분구 전달·연결 검증, 모순 지역 조건, 상류맨홀만 반환, 501건 배치 분할. 처음 2건의 Mockito 중첩 stubbing 오류는 테스트 fixture를 수정한 후 모두 통과했다. +- FE tsc --noEmit --incremental false --pretty false 통과. Git 공백 검사 통과. +- 실제 8883 API: 지역 없는 PPL 40/40/누락0, MNHL 40/18/누락22(도형 없음22), 제보 테스트 사업·덕풍 PPL 3/3/누락0, 동일 MNHL 3/0/도형없음3. 순서는 matched/returned/missing이다. +- 등급6과 사업 없는 처리분구 요청은 HTTP400 확인. 정상 조회는 HTTP200 확인. +- 실제 3002 Chrome: 검색 없는 /map에서 조사결과 체크 후 관로40·맨홀18 안내 확인, 해제 후 설정 카드 제거 확인. 상세현황 URL로 덕풍 관로 강조와 줌18 이동 육안 확인, 관로 체크 해제 시 강조선 제거 및 재체크 후 40개 조회 복구 확인. +- 현재 전체 PPL 단일 측정: UTF-8 약36,262 bytes, 약856ms. 전국 대량 자료 부하검증을 의미하지 않는다. bbox는 계획대로 후속 성능 개선 단계다. +- 맨홀 미연결은 상류번호 누락이 아니라 해당 지역 GIS에서 도형을 찾지 못한 결과다. 덕풍3건 및 전체22건 원천 정합성 보정은 별도 확인이 필요하며 자동으로 다른 위치에 표시하지 않는다. +- 필터 변경/취소의 모든 경쟁조건, 보고서 PDF, 전국 대량 렌더링 전체 회귀는 미실행이다. 기존 테스트 자산은 수정하지 않았고 이번 임시 검증파일은 결과 요약 후 정리한다. + +### 2026-10-01 후속 사용자 확인 +- 사용자 확인으로 조사 레이어 ON/OFF 정상 표출을 확인했다. +- 사용자 요청에 따라 별도 파란 선택 강조를 제거하고 기본 조사 색상 및 선택 영역 위치 이동을 유지했다. 앞선 강조선 검증은 제거 전 기록이다. + +## 2026-10-01 조사결과 업로드 양식 미준비 안내 +- 사용자 요청은 양식 생성 절차를 생략하는 수정이 아니라 미생성 상태에 ‘양식 받기’ 선행 안내를 제공하는 것이다. +- 실제 다운로드 이력 대신 활성 처리분구의 pplExmnSheetNm/mnhlExmnSheetNm 존재 여부를 기존 사업 상세 API로 확인한다. +- 팝업 진입, 데이터 검토 전, 저장 확인·첨부 업로드 전에 확인한다. 검토 후 사업 수정으로 시트 연결이 초기화됐으면 이전 검토 상태를 폐기하고 안내한다. +- 문구: “조사 입력양식이 준비되지 않았습니다. 사업정보에서 ‘양식 받기’를 먼저 실행한 후 조사결과를 등록해 주세요. 사업정보를 수정한 경우에도 양식을 다시 받아야 합니다.” +- FE ExmnResultRegisterPopupPage.tsx와 exmn-business-validation.ts에 안내/사전 확인을 추가하고 BE ExmnSurveyResultParser의 SHEET_NAME_NOT_ASSIGNED 메시지도 같은 행동 안내로 보완했다. 자동 양식 생성·DB 변경·기존 저장 필수조건 변경은 하지 않았다. +- 검증: 미생성/부분생성/공백/정상/처리분구 없음 5건 통과, FE 타입 검사와 BE compileJava --offline 및 diff --check 통과. 임시 파일 생성 없음. 실제 파일 업로드/저장 및 브라우저 회귀는 미실행이다. +- 롤백은 이번 안내·사전 확인과 parser 오류문구만 복원한다. 실제 다운로드 성공 여부를 추적하는 기능은 아니다. + +### 2026-10-01 후속 지시: 저장 시에만 조사양식 안내 +- 앞선 초기 진입/데이터 검토 단계의 양식 준비 안내와 프론트 사전 차단을 제거했다. 저장을 누른 경우에만 공통 alert로 “조사관리에서 해당 사업 행 선택 → 수정 → 조사양식 받기 후 결과등록 재진행”을 안내한다. 검토 결과는 보존한다. +- 검토 API는 양식 생성과 같은 명명 규칙으로 미등록 시트명만 메모리에서 보완한다. 저장된 시트명은 우선 사용하고 검토 중 시트명/DB를 저장하지 않는다. 실제 파일의 시트·필수값·중복·PDF 검증은 유지한다. +- 저장 API는 실제 시트명 등록을 계속 요구하며 미준비면 기존 결과를 변경하기 전에 차단한다. 프론트는 첨부 업로드 전에 최신 상태를 확인하며 서버에서 같은 오류가 반환돼도 alert로 안내한다. +- 검증: ExmnTemplateGateTest 7건 통과(Excel/PPL PDF/MNHL 검토 독립, 미준비 commit 무쓰기 차단, 기존 시트명 보존, 준비 완료 commit, 해시 불일치 차단), FE 타입 검사 통과. 테스트는 mock 기반이며 실제 파일 저장·DB 변경 및 브라우저 끝단 저장은 수행하지 않았다. +- 임시 테스트 소스·산출물은 요약 후 정리한다. 이전 ‘검토 전 양식 확인’ 기록은 이 지시로 대체한다. diff --git a/apps/sn/docs/작업검증/SFR-011-002-관로분석/001-qa-SFR-011-002-관로분석-통합검증결과.md b/apps/sn/docs/작업검증/SFR-011-002-관로분석/001-qa-SFR-011-002-관로분석-통합검증결과.md index d70d513a3..11575355d 100644 --- a/apps/sn/docs/작업검증/SFR-011-002-관로분석/001-qa-SFR-011-002-관로분석-통합검증결과.md +++ b/apps/sn/docs/작업검증/SFR-011-002-관로분석/001-qa-SFR-011-002-관로분석-통합검증결과.md @@ -83,3 +83,13 @@ - SN 전체 TypeScript는 기존 `map/page.tsx:3659`의 `postMessage` 타입 오류 1건만 남았고 이번 공통 보강 코드의 추가 오류는 없다. - ESLint는 설치 상태에서 `eslint-plugin-react-hooks`를 찾지 못해 실행 환경 오류로 완료하지 못했다. - 판정: PASS. + +## 2026-09-30 관로분석 시도 기본값 부산 적용 + +- quick_vibe: 사용자 지시로 시도 전체 옵션을 제거하고 기본 시도를 부산광역시(26)로 설정했다. +- `PipeAnalysisPopupPage.tsx`: 시도 전체 option 및 공통 목록의 값 0을 제외한다. 시군구·처리구역 전체 선택은 유지한다. +- `pipeAnalysisUtils.ts`: 집계 기본 필터 ctpvCd=26. 최초 자동 조회와 초기화에서 같은 기본값을 사용한다. 계정 지역 제한은 기존 applyScopeToSummaryFilters를 우선한다. +- API/백엔드/DB 및 상세 필터 기본값은 변경하지 않는다. +- 원본: 요구사항정의서 기능 추적표 88행 SFR-011-002, 화면설계서 10번 SN_MN010103. 부산 기본값은 이번 사용자 지시를 적용한다. +- 검증: git diff --check 통과. ESLint는 eslint-plugin-react-hooks 설치 누락으로 실행 실패. 실제 브라우저 확인과 전체 빌드는 미실행이며 사용자 개발 서버를 유지했다. +- 롤백: 집계 기본 시도를 0으로 되돌리고 시도 전체 option을 복원한다. diff --git a/apps/sn/docs/작업검증/SFR-011-007-통수위험분석/002-qa-SFR-011-007-통수위험분석-통합검증결과.md b/apps/sn/docs/작업검증/SFR-011-007-통수위험분석/002-qa-SFR-011-007-통수위험분석-통합검증결과.md index 1d99320f7..d220683ff 100644 --- a/apps/sn/docs/작업검증/SFR-011-007-통수위험분석/002-qa-SFR-011-007-통수위험분석-통합검증결과.md +++ b/apps/sn/docs/작업검증/SFR-011-007-통수위험분석/002-qa-SFR-011-007-통수위험분석-통합검증결과.md @@ -221,3 +221,19 @@ - 데이터 조회, 행 선택, 위험 판정, API 요청·응답 코드는 변경하지 않았다. - 대상 `WaterRiskPopupPage.tsx` ESLint와 프론트·공통 문서 저장소의 `git diff --check`가 통과했다. - 실행 중인 `sn` 개발 서버가 없어 로그인 화면의 실제 줄바꿈·배치는 확인하지 않았다. + +## 2026-09-30 과거분석이력 기본 강우발생기간 + +- 사용자 지시로 과거분석이력 최초 진입 기간만 2026-08-15 ~ 2026-08-17로 지정했다. 후속 사용자 지시에 따라 초기화는 실행 시점의 최근 1년·전체 검색조건을 새로 생성해 첫 페이지를 조회한다. +- 수정 파일: `frontend/apps/sn/app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage.tsx`. +- 현재 탭의 조회기간은 기존 최근 1년 기준으로 별도 생성해 과거 고정기간에 의해 현재 데이터가 제외되지 않도록 했다. +- 과거 날짜 직접 변경·조회와 최신순 정렬을 유지한다. 백엔드·API 형식·DB 변경 없음. +- 검증: git diff --check 통과 및 최초 initialFilters와 초기화 buildDefaultResultFilters 분리 및 초기화 후 재조회 흐름 확인. 브라우저 확인 미실행, ESLint는 앞선 작업에서 확인한 eslint-plugin-react-hooks 누락으로 재실행하지 않았다. 개발 서버와 빌드는 조작하지 않았다. +- 롤백: 이 파일의 기본기간 및 조회기간 분리 변경만 복원한다. + +## 2026-10-01 강우 발생기간 조회 제한 +- 메모리 내 날짜 유틸 검증 12건 통과: 제보 587일 차단, 366일 허용, 367일 차단, 윤년 경계, 잘못된 날짜, 빈값, 역전, 미래 날짜, 과거 종료일 보정, 기간 증가 보정, 정상 시작일 유지, 오늘 기준 기본기간. +- git diff --check 통과. 테스트 파일·원시 결과 파일은 생성하지 않았다. +- 3002 포트에서 실행 중인 개발 서버가 확인되지 않아 브라우저 달력·배치 및 실API 회귀는 미실행. 서버를 새로 실행하거나 전체 빌드하지 않았다. +- 후속 브라우저 확인: 최초/초기화 최근 1년, 종료일 이동 시 시작일 보정, 기간 초과 직접 입력 조회 차단, 검색조건 아래 오류 표시, 정상 조회 및 페이지 이동. +- TypeScript 전체 검사 통과: tsc --noEmit --incremental false --pretty false (exit 0). diff --git a/apps/sn/docs/작업검증/SFR-011-노후관로관리/001-qa-SFR-011-노후관로관리-통합검증결과.md b/apps/sn/docs/작업검증/SFR-011-노후관로관리/001-qa-SFR-011-노후관로관리-통합검증결과.md index 07a93843e..0e7b939d9 100644 --- a/apps/sn/docs/작업검증/SFR-011-노후관로관리/001-qa-SFR-011-노후관로관리-통합검증결과.md +++ b/apps/sn/docs/작업검증/SFR-011-노후관로관리/001-qa-SFR-011-노후관로관리-통합검증결과.md @@ -75,3 +75,11 @@ | 서버 노드 호환 | 정적 통과 | 기존 노드와 프론트 가상 노드를 동일 기준으로 정규화 | | 변경 공백 검사 | 통과 | 프론트 저장소 `git diff --check` | | 브라우저 육안 확인 | 대기 | 지도 새로고침 후 체크박스 옆 빨간 선형 범례 확인 필요 | + +## 2026-10-01 DB 레이어 설정 이관 검증 +- TypeScript 검사 통과: node node_modules/typescript/bin/tsc --noEmit --incremental false --pretty false. 의존성 읽기 권한으로 상승 실행했다. +- 메모리 내 자동 검증 9건 통과: DB ID/스타일/원본 템플릿 보존, 가상 행 미생성, 경과연수 변경, 시군구 우선, 시도 조건, 빈/미지원 템플릿 거부, 기존 제어 행 오류 격리, 중첩 행, SQL 템플릿 일치. +- git diff --check 통과. 테스트 파일이나 원시 결과 파일은 생성하지 않았다. +- 실행 중 3002 브라우저에서 DB 미전환 안내와 기존 시설물 트리/노후관로 항목 표시를 확인했다. DB 응답이 실제 WFS 설정 요건을 충족하지 않아 정상 관로 표출·목록·Excel 실데이터 회귀는 미완료이다. +- 사용자 서버 3002/8883은 재시작하거나 종료하지 않았다. 전체 빌드는 실행하지 않았다. +- 후속 검증: 기존 139번에 UPDATE 적용 후 새로고침, 체크/해제, 30년→40년, 설정 숨김 중 변경 후 재표시, 지도·목록·Excel 기준 일치 확인. diff --git a/apps/sn/docs/작업계획/SN-패키지구조진단/005-fe-SN-폴더별역할및적절성-분석결과.md b/apps/sn/docs/작업계획/SN-패키지구조진단/005-fe-SN-폴더별역할및적절성-분석결과.md new file mode 100644 index 000000000..a3086a2ec --- /dev/null +++ b/apps/sn/docs/작업계획/SN-패키지구조진단/005-fe-SN-폴더별역할및적절성-분석결과.md @@ -0,0 +1,140 @@ +# 프론트엔드 폴더별 역할·적절성 분석 + +기준일: 2026-09-28. 상태: 읽기 전용 소스 분석 완료. 구조 개선 구현은 별도 계획이며 이 문서 작성으로 코드를 변경하지 않았다. + +## 1. 목적과 결론 + +SN 앱과 공통 business_components의 실제 폴더·대표 소스·import·패키지 선언을 기준으로 역할과 개선 방향을 정리한다. 다른 앱·공용 패키지는 관계 확인만 수행했다. + +현재 구조는 기능별 `src/features` 분리 방향은 적절하지만 기존 `app` 중심 구현과 새 계층이 섞인 과도기다. 전면 재작성보다 **지도 대형 파일 분리 → API·상태 소유권 정리 → 기능별 UI 통합 → 공통 컴포넌트 경계 정리**가 적절하다. + +- 확인됨: 폴더 존재, 대표 파일의 역할, 실제 import, 파일 규모, 패키지 의존 선언. +- 설계 제안: 목표 폴더와 단계별 이전 방향. 완료된 구조로 해석하지 않는다. +- 확인 필요: 전체 import 그래프, 동적 등록, 앱별 실행 경로, 타 앱 컴포넌트별 사용 여부. 패키지 의존 선언만으로 개별 파일 사용을 단정하지 않는다. +- 브라우저·빌드 미수행: 이번 요청은 분석·설계 문서 작성이며 기능 회귀 통과를 주장하지 않는다. + +## 2. 프론트엔드 전체 구조 + +기준 경로: `D:/workspace/IIPBS-FRONTEND/frontend`. + +| 폴더 | 역할 | 적절성 | 문제점·개선 방향 | +| --- | --- | --- | --- | +| apps | 서비스별 독립 앱 | 적절 | 업무 기능은 해당 앱에 유지 | +| apps/sn | 하수관로 시스템 | 적절 | SN 전용 로직의 불필요한 공용화 금지 | +| packages/common/components | 기본 UI | 역할상 적절 | business_components와 기본 UI/업무 조합 UI 경계를 유지. 내부 전체 품질 평가는 미수행 | +| packages/common/business_components | 그리드·폼·지도·업로드 등 조합 UI | 조건부 적절 | 범용 UI와 특정 데이터/API 의존 코드 분리 필요 | +| packages/common/auth, network, config | 인증·통신·설정 공유 | 역할상 적절 | SN의 유사 구현과 중복 확인 필요. 이번 수정 권한 밖 | +| packages/common/assets, types | 공용 정적 자원·타입 | 적절 | SN 전용 자료와 구분 | +| packages/mindone, oneandall, rsone | 업체별 패키지 영역 | 유지 가능 | 공용화 기준 필요. 업체 경계 자체를 임의 제거하지 않음 | +| docs | 프론트 전체 공유 문서 | 적절 | SN 문서는 apps/sn/docs에 작성. 공용 docs 임의 수정 금지 | +| .husky, .gitea | Git 훅·개발 운영 설정 | 유지 | 기능 폴더 정리 대상으로 삭제하지 않음 | +| node_modules, .turbo | 의존성·캐시 | 정상 | 소스 구조 평가와 구별. 실행 중 임의 삭제 금지 | + +## 3. SN 폴더별 평가 + +기준 경로: `D:/workspace/IIPBS-FRONTEND/frontend/apps/sn`. + +| 폴더 | 현재 역할 | 판단 | 문제점·개선 방향 | +| --- | --- | --- | --- | +| app | Next 라우트·레이아웃과 기존 구현 | 정리 필요 | API·스토어·업무 로직이 함께 존재. 라우팅/조립 위주로 축소 | +| app/(auth) | 인증 화면 진입 | 적절 | 로그인 구현은 기능 모듈에서 호출 | +| app/(with-base-layout) | 기본 레이아웃 업무 화면 | 부분 적절 | 기능 호출만 하는 페이지와 상세 로직 페이지가 혼재. 얇은 진입점으로 통일 | +| app/(map-layout)/map | 하수관망도 | 최우선 | 지도 초기화·이벤트·레이어·편집·팝업·분석을 단일 페이지가 조정 | +| app/(map-layout)/map/components | 지도 팝업·GIS·분석 | 경계 정리 | UI 외 API·상태도 포함. 기능별 api/model/lib/ui로 점진 이전 | +| app/(popupLayout) | 별도 창 팝업 라우트·레이아웃 | 적절 | 팝업 기반과 개별 업무 내용 분리 유지 | +| app/api | 서버 측 인증·프록시·업로드·다운로드 중계 | 적절 | 클라이언트 HTTP 함수와 다르므로 shared/api와 합치지 않음 | +| app/components/ui | SN 재사용 UI | 분류 필요 | shared/ui와 분산. 업무 폼은 features, 범용 UI는 shared/ui | +| app/components/lib/api | 기존 HTTP·CMS·GeoServer 호출 | 정리 필요 | 인증·여러 서비스 호출이 섞임. transport와 업무 endpoint adapter 분리 | +| app/components/lib/utils | 날짜·파일·Excel·표시 유틸 | 분류 필요 | 순수 함수는 shared/lib, 업무 변환은 해당 feature | +| app/store | 지도·검색·보고서·지역 상태 | 정리 필요 | shared/store·src/store와 분산. 상태 소유 기능으로 배치 | +| src/app/providers | 앱 Provider 조립 | 적절 | Next 라우트 app과 역할이 다름. 인증·메뉴·캐시·전역 UI의 조립 계층 | +| src/features | 업무 기능별 구현 | 권장 | 현재 방향 유지. app에 남은 같은 기능을 점진 이관 | +| src/entities | 인증·메뉴·권한 등 공유 도메인 | 대체로 적절 | 여러 기능이 실제 공유하는 모델 중심. 모든 DTO를 모으지 않음 | +| src/shared/api | HTTP 기반·공통 다운로드 | 부분 적절 | app/components/lib/api에 대한 역방향 의존 정리 | +| src/shared/config | 앱 설정·기술 상수 | 적절 | 대형 업무 코드 사전/표 컬럼은 기능 계층으로 분리 | +| src/shared/model | 공통 응답 타입 | 적절 | 업무 요청·응답 타입은 기능 내 유지 | +| src/shared/ui | 알림·확인·프롬프트·공통 UI | 적절 | SN 공통 UI의 기준 위치로 사용 | +| src/shared/providers | 공통 Context 구현 | 유지 가능 | src/app/providers는 조립, 이곳은 개별 공통 Provider 구현 | +| src/shared/store | 사용자·메뉴·첨부·지역 등 상태 | 부분 적절 | 도메인 상태와 진짜 공통 UI 상태 구분. 전역화 최소화 | +| src/shared/lib, src/shared/util | 함수·훅 | 통합 기준 필요 | lib/util 구분 불명확. shared/lib를 기준으로 책임별 파일 배치 | +| src/store | 탭 상태·메뉴 권한 조회 | 위치 개선 | 앱 탐색 상태는 src/app/model 등으로 이동 검토 | +| src/auth/login | 로그인 UI | 위치 개선 | 로그인 행위는 features/auth, 인증 도메인은 entities/auth로 구분 검토 | +| src/types | 라이브러리 타입 선언 보완 | 적절 | 전역 선언만 배치. 기능 타입 저장소로 쓰지 않음 | +| public/images, public/samples | 정적 자원·샘플 | 적절 | 실제 양식 참조를 확인하기 전 삭제 금지 | +| docs | SN 공식 작업 문서 | 적절 | 구현 이력과 미구현 설계를 구분 | +| .next, .turbo, node_modules | 실행·빌드·의존성 산출물 | 정상 | 리팩토링을 이유로 일괄 삭제하지 않음 | +| app/grid-overflow-verification | 과거 검증 경로 | 빈 폴더 정리 후보 | 현재 하위 항목 0개. 실행 라우트 파일 없음 | +| app/layer-bulk-verification | 과거 검증 경로 | 빈 폴더 정리 후보 | 현재 하위 항목 0개. 이번에는 삭제하지 않음 | +| app/row-number-verification | 과거 검증 경로 | 빈 폴더 정리 후보 | 현재 하위 항목 0개. 삭제 직전 재확인 필요 | + +### 실제 의존 관계에서 확인한 문제 + +- `oper/obsltPplBiz/page.tsx`는 `features/obslt-ppl/ui/ObsltPplListPage`를 호출하는 얇은 진입점이다. 지도·준설은 같은 방식으로 완전히 분리되지 않았다. +- `tsconfig.json`의 `@/components/*`는 `app/components/*`, `@/store/*`는 `app/store/*`를 가리킨다. 기본 `@/*`의 src 기준과 섞여 실제 소유 위치가 직관적이지 않다. +- `shared/store/data-store.ts`가 `@/components/lib/api`를 참조한다. 공통 기반에서 기존 app 구현으로 의존하는 경로다. +- `src/shared/api/index.ts`는 인증 client와 순수 transport를 함께 export한다. 기존 API는 초기화 부수효과를 피하려고 transport 파일을 직접 import한다. barrel에 담는 모듈의 초기화 책임 구분이 필요하다. +- app/store와 shared/store에 지역·사업코드 조회 책임이 겹치지만 완전히 동일한 구현이라는 뜻은 아니다. 데이터 출처·캐시·사용 범위를 대조해야 한다. + +## 4. features 내부 평가 + +| 기능 폴더 | 역할 | 개선 방향 | +| --- | --- | --- | +| cfmtnBgt | 최종확정예산 조회·선택 | 기능 경계 유지 | +| ctyFldngPrvtnBiz | 도시침수예방사업·Excel | 대형 화면/양식 변환 추가 분리 | +| obsltPpl | 노후관로 사업 관리 | 라우트 obsltPplBiz와 이름 차이 문서화. 일괄 rename 우선하지 않음 | +| dredging | 준설 API·등록·사진·EXIF | app에 남은 목록·상세 UI와 소유권 통일 | +| exmn | 조사사업·조사 상세·처리분구 | 대형 상세 팝업의 조건/표/합계/다운로드 분리 | +| fldngPredcPrst | 침수예측 현황 | 연관 UI·API·상태의 기능 경계 유지 | +| gisEdit | 도형 수정·스냅샷·편집 모델 | app의 UI/상호작용과 책임 통일. 기존 모듈 재사용 | +| gisManagement | 업로드 모델·다운로드·표시 | 관리 팝업과 조회/전송 책임 묶기 | +| mapLayers | 레이어 정보·유하방향 | 페이지의 레이어 수명주기 분리 시 재사용 | +| windowPopup | 팝업 정의·등록·실행 | 메시지·창 수명주기 기반만 공통화. 개별 업무 로직은 각 기능 | + +모든 기능에 빈 api/model/lib/ui 폴더를 만들 필요는 없다. 책임이 있을 때만 생성한다. + +## 5. business_components 평가 + +| 폴더 | 역할 | 판단·개선 방향 | +| --- | --- | --- | +| src/ui/table, standard_data_table, table_div | 여러 형태 표 | 서버 페이지 목록/단순표 등 사용 기준 정의 후 공통화 | +| src/ui/form, form_ui, standard_form | 폼·배치 | 입력 계약·검증·배치 차이를 비교해 기준 컴포넌트 지정 | +| src/ui/search_* | 검색조건·배치·주소 | left/right/base의 차이를 문서화하고 조합 방식 정리 | +| src/ui/map | 지도·트리·도구·선택 | 지도 엔진/표시와 업무 데이터 접근 분리 | +| src/ui/map/lib | 편집·레이어·측정·스타일 | 기능 분리는 적절. JS 경계의 타입을 점진 도입 | +| src/ui/map/searchBar | 지역·처리시설 조회 | hasudo WFS와 CMS 직접 호출 확인. 데이터 로더 주입 검토 | +| src/ui/file-upload | 업로드 UI·처리 | 업무 API·용량·확장자는 설정/어댑터로 전달 | +| src/ui/excel | 브라우저 Excel·서식 | 소규모 양식용으로 한정. 대량 원시 데이터는 서버 다운로드 | +| src/ui/chart | 차트 표현 | 업무 지표 계산과 구분하여 유지 | +| src/ui/date_*, select, tab, dialog_ui 등 | 입력·표시 UI | 혼재된 명명 규칙은 신규 기준부터 적용. 일괄 경로 변경 금지 | +| src/ui/sdmInsertForm | 특정 입력 업무 폼 | 이름만으로 공용성/미사용 판정 불가. 앱별 사용처 확인 필요 | +| src/lib | 공통 유틸·메시지 연결 | 앱 직접 의존 없이 유지 | +| types | 타입 선언 | src 선언·빌드 결과와 책임 중복 확인 필요 | +| dist | 배포용 빌드 결과 | package.json exports가 참조하므로 임의 삭제 금지 | +| node_modules, .turbo | 의존성·캐시 | 소스 정리 대상 아님 | + +패키지 의존 선언은 SN·WR·FO·DM에서 확인했다. 이것만으로 개별 컴포넌트 사용 여부를 확정하지 않는다. SN tsconfig의 소스 alias와 패키지의 dist exports가 공존하므로 개발 소스 검증과 패키지 소비 경로 검증을 구별해야 한다. + +## 6. 우선순위와 규모 근거 + +2026-09-28 `Get-Content | Measure-Object -Line` 기준, 빈 줄을 제외한 수치. 줄 수는 분리 검토 지표이지 단독 결함 판정 기준이 아니다. + +| 순서 | 대상 | 관측 | 권장 조치 | +| --- | --- | --- | --- | +| 1 | map/page.tsx | 약 7,170줄 | 지도 초기화·레이어·편집·팝업·분석 책임 분리 | +| 2 | API·스토어 | app/shared/src/store에 분산 | 의존 방향·상태 소유권 정리 | +| 3 | exmn-survey-detail-popup-page.tsx | 약 1,060줄 | 조회 조건·표·합계·다운로드 분리 | +| 4 | feature-click-event-popup.tsx | 약 1,002줄 | 시설별 상세 패널과 공통 이벤트 분리 | +| 5 | map/components/common/table.ts | 약 913줄 | 컬럼 정의·코드명 사전 분리 | +| 6 | 준설 목록·상세 | 약 894줄·877줄 | features/dredging으로 기능 집중 | +| 7 | 공통 표·폼 | 유사 폴더 다수 | 전 앱 사용계약 조사 후 점진 통합 | +| 8 | 빈 검증 폴더 | 3개 | 삭제 시점 재확인 후 정리 | + +보조 검토: oldPp/head-list.ts 약 1,716줄은 선언 데이터 비중이 높으므로 지도 실행 로직보다 우선하지 않는다. GIS 관리 팝업 약 796줄, useGisEdit 약 727줄, 도시침수 사업 Excel 약 802줄도 해당 단계에서 분리 범위를 검토한다. + +## 7. 근거·영향·다음 문서 + +- 대표 근거: SN tsconfig.json, app/providers 연결, src/app/providers/app-provider.tsx, src/shared/providers/store-provider.tsx, app/components/lib/api/index.ts, src/shared/api/index.ts, src/shared/store/data-store.ts, src/store/tab-store.ts, features/window-popup/registry.tsx. +- 공통 근거: business_components/package.json 및 src/ui/index.ts, map/searchBar/searchBar.tsx, SN/WR/FO/DM package.json. +- 기존 문서: 같은 폴더의 001·002 파일역할목록, 002 SN 구조정리 구현결과, 003 회귀검증, 004 공통 구조정리 결과. 과거 완료 범위를 이번 미구현 계획으로 덮어쓰지 않는다. +- 상세 실행 설계: [006-fe-SN-단계별구조개선-설계계획.md](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/docs/작업계획/SN-패키지구조진단/006-fe-SN-단계별구조개선-설계계획.md). +- 이번 변경은 문서 추가뿐이다. 코드/API/DB/환경/배포 변경과 파일 삭제 없음. 롤백은 이번 추가 문서만 제거하면 되며 기존 코드 변경을 되돌릴 필요가 없다. diff --git a/apps/sn/docs/작업계획/SN-패키지구조진단/006-fe-SN-단계별구조개선-설계계획.md b/apps/sn/docs/작업계획/SN-패키지구조진단/006-fe-SN-단계별구조개선-설계계획.md new file mode 100644 index 000000000..0d4da1b98 --- /dev/null +++ b/apps/sn/docs/작업계획/SN-패키지구조진단/006-fe-SN-단계별구조개선-설계계획.md @@ -0,0 +1,376 @@ +# SN 단계별 구조 개선 설계 계획 + +최종 실행 결과(2026-09-29): 잔여 구조 개선 반영과 대표 회귀검증을 마쳤다. 단계별 반영·공유 UI 유지 판단·펌프 코드 의미 예외·검증 제한은 [012 최종 결과](./012-fe-SN-잔여구조개선-최종결과.md)가 최신 기준이다. 아래 최초/중간 상태 문구는 당시 이력이다. + +2026-09-29 실행 현황: 2번은 010, 잔여 단계의 이번 구현·검증·미완료 범위는 [011 잔여 구조 개선 결과](./011-fe-SN-잔여구조개선-구현및검증결과.md)를 따른다. 1번 지도 전체 분리와 5번 코드 전 소비처 연결은 아직 진행 중이다. 아래 최초 계획 당시의 상태 문구를 현재 전체 완료 여부로 해석하지 않는다. + +작성일·최종 보완일: 2026-09-28. 상태: **1번 첫 분리 배치 구현·부분 검증, 나머지 미착수**. 최종 설계 작성 후 사용자의 “1번부터 진행해” 지시에 따라 시작했다. 상세 실적·미검증·남은 1-C/1-D/1-E 범위는 [007 첫 구현 결과](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/docs/작업계획/SN-패키지구조진단/007-fe-SN-지도페이지분리-1차구현결과.md)를 따른다. 이 설계 문서의 계획표 자체는 모든 항목 구현 완료 보고가 아니다. 최종 결정 근거와 실행환경 경계는 15~18절을 함께 따른다. + +## 1. 목적·기준·범위 + +- 입력: [폴더별 역할·적절성 분석](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/docs/작업계획/SN-패키지구조진단/005-fe-SN-폴더별역할및적절성-분석결과.md)의 우선순위 1~8. +- 목적: 라우팅과 업무 로직 분리, 상태 소유권 단일화, 역방향 의존 제거, 기존 사용자 동작 보존. +- 허용 예정 범위: SN 내부 코드·문서. business_components는 전 앱 영향 조사 후 호환성을 확인한 범위만 변경한다. +- 제외: 다른 앱 구현, 권한 밖 공용 패키지, DB·백엔드 계약·Java·Docker·환경·배포·의존성 버전 변경. 필요성이 생기면 별도 변경안으로 분리한다. +- 원본 추적: 9월 28일 직접 열람한 요구사항정의서 `3. (기능)요구사항정의 및 추적표` 84행 SFR-010-012, 88행 SFR-011-002와 화면설계서 9쪽 SN_MN010102, 36쪽 SN_MN010303, 53쪽 SN_MN040201. 구조 분리는 원본이 정한 구현 방식이 아니라 현재 사용자 요청에 따른 설계 제안이다. +- 조사·GIS 편집의 상세 근거는 기존 002 SN 구조정리 구현결과에 기록된 XLSX 76~78/96~99행, PPT 34·35·39·41·43·45쪽을 참조한다. 해당 단계 착수 시 원본을 다시 직접 열어 확정한다. 이번 계획에서 전 범위 신규 원본 검증 완료로 표현하지 않는다. +- 공통 AGENTS, SN AGENTS, SN `기준문서/008-설계정책.md`, 과거 리팩토링 결과를 따른다. 과거 테스트 보존 문구보다 현재 테스트 파일 정리 지시가 우선한다. + +## 2. 목표 구조와 의존 규칙 + +아래 경로 중 신규 경로는 설계안이다. 빈 폴더를 선행 생성하거나 기존 전체 경로를 일괄 변경하지 않는다. 사용자 승인으로 지도 내부 폴더명은 SN 관례에 맞춘 `ui`, `lib`, `model`을 사용한다. Next의 underscore 비공개 폴더 기능은 사용하지 않으므로 이 하위에 `page.tsx`·`route.ts`를 추가하지 않는 것을 코드 검토 기준으로 삼는다. + +```text +apps/sn/ +├─ app/ # Next 라우트·레이아웃·서버 route handler +│ ├─ (map-layout)/map/ +│ │ ├─ page.tsx # 지도 화면 진입점 +│ │ ├─ ui/map-page-client.tsx # 지도 전용 기능 조립 +│ │ ├─ lib/use-map-runtime.ts # 지도 생성·해제 +│ │ └─ model/map-runtime.ts # 라우트 전용 연결 타입 +│ ├─ (with-base-layout)/... # 업무별 진입점 +│ ├─ (popupLayout)/... # 팝업 진입점 +│ └─ api/ # 서버 전용 프록시·파일 스트리밍 유지 +├─ src/ +│ ├─ app/ +│ │ ├─ providers/ # 앱 초기화·전역 Provider 조합 +│ │ ├─ styles/ # 실제 기존 전역 스타일 +│ │ └─ model/ # 앱 전체 탐색·탭 상태 후보 +│ ├─ features/ +│ │ ├─ mapLayers/ # 레이어 표시·스타일·수명주기 +│ │ ├─ gisEdit/ # 편집 상태·상호작용·미리보기 +│ │ ├─ gisManagement/ # 업로드·다운로드·현황 +│ │ ├─ windowPopup/ # 창 실행·메시지 기반 +│ │ ├─ exmn/ # 조사 화면·조회·표·다운로드 +│ │ ├─ dredging/ # 준설 목록·등록·상세 +│ │ └─ ... # 실제 업무별 기능 경계 +│ ├─ entities/ # 여러 기능에서 공유하는 도메인 모델 +│ ├─ shared/ # 순수 HTTP·UI·유틸·공통 타입 +│ └─ types/ # 전역 타입 보완 +├─ public/ # 실제 참조하는 정적 자원·양식 +└─ docs/ # 설계·구현·검증 기록 +``` + +| 원칙 | 적용 방법 | +| --- | --- | +| 라우트와 전역 조립을 구분 | 루트 app은 Next 라우트. 지도 전용 조립은 map/ui 등 일반 내부 폴더. src/app은 전역 초기화·스타일·앱 전체 탐색에 한정 | +| features는 업무 소유 | api/model/lib/ui 중 실제 필요한 폴더만 생성 | +| shared는 상위 계층을 import하지 않음 | shared → features/entities/app 의존을 새로 만들지 않음. 인증 정책이 필요하면 callback/adapter로 전달 | +| 도메인 간 연결은 조립 계층에서 수행 | 다른 기능의 내부 파일을 직접 import하지 않고 공개 계약 사용 | +| 상태 원본은 하나 | 동일 선택·필터를 복제한 store를 만들지 않음 | +| 서버/클라이언트 경계 유지 | app/api는 서버 전용. 환경값·토큰 처리 코드를 클라이언트 barrel에 넣지 않음 | +| 기존 API 의미 보존 | lowerCamelCase 경로 단일화, 기존 payload·인증·오류 의미 유지 | +| 지도·그리드·Excel 분리 | 지도 전체 도형, 목록 page/size, 전체 적용조건 다운로드를 각각 보존 | + +## 3. 실행 순서와 단계별 산출물 + +| 순서 | 작업 | 선행조건 | 종료 조건 | +| --- | --- | --- | --- | +| 준비 | 기준선·파일점유·사용자 서버 확인 | 구현 착수 승인 | 변경 전 동작과 실제 수정 파일 확정 | +| 1 | 지도 페이지 책임 분리 | 준비 | 아래 1-A~1-E 회귀 통과, 새 대형 훅으로 단순 이동하지 않음 | +| 2 | API·상태 소유권 정리 | 1의 지도 계약 안정화 | app에 대한 역방향 의존 제거, 상태 중복 제거 | +| 3 | 조사 상세 팝업 분리 | 2의 API 기준 | 그룹·병합·전체 합계·다운로드 보존 | +| 4 | 시설 클릭 상세 팝업 분리 | 1의 선택 이벤트 기준 | 시설별 상세·강조·편집 충돌 없음 | +| 5 | 지도 컬럼·코드명 분리 | 4의 시설 계약 | 컬럼·표시명·정렬·Excel 정합성 확인 | +| 6 | 준설 기능 집중 | 2·5 | 목록·상세·등록을 기능 내부로 통합, 저장/삭제 범위 보존 | +| 7 | 공통 표·폼 경계 정리 | 3·6의 SN 사용계약 확보 | SN·WR·FO·DM 영향 검증 또는 변경 보류 | +| 8 | 빈 폴더·잔여 참조 정리 | 각 단계 참조 이관 완료 | 정확한 삭제 목록과 0참조 확인 | + +한 단계 안에서도 동작 단위로 나누며, 이전 단계 미검증 상태에서 다음 대규모 이동을 누적하지 않는다. 일수는 환경·참조 조사가 끝나기 전 확정하지 않는다. + +## 4. 1번 상세 설계: 지도 페이지 + +대상: `app/(map-layout)/map/page.tsx` 약 7,170줄. `Gis`, `GisApp`, OpenLayers interaction·layer·overlay, 편집 훅, I/I 결과 훅, 팝업 registry, 페이지 내 ref/effect가 연결돼 있다. 줄 수 감소 자체가 완료 기준이 아니다. + +### 4.1 책임·상태 소유권 + +| 책임 | 이동/유지 위치 제안 | 소유 상태·입력 | 외부 제공 계약 | 종료·정리 책임 | +| --- | --- | --- | --- | --- | +| 지도 생성·해제 | app/(map-layout)/map/lib/use-map-runtime | DOM container, 지도 인스턴스 ref | 좁은 MapRuntime adapter, 준비 상태 | 자신이 등록한 listener/overlay/interaction만 해제. 지도 해제 순서는 현 구현 확인 후 확정 | +| 기본 레이어·유하방향 | features/map-layers | 지도 adapter, 표시 설정 | 표시 변경·설정 조회 | 등록한 레이어·유하방향 listener 해제 | +| 선택·강조·상세 연결 | map/ui 조립 + 해당 업무 feature | 선택 FID, 선택 도형, 편집 여부 | onSelect/onClear/focusFeature | 오래된 선택·강조 제거, 편집 중 일반 선택 충돌 방지 | +| GIS 편집 | features/gis-edit | 편집 세션, mode, selected vertex, snapshot | 도구 전환·취소·저장 요청 | Draw/Snap/Translate 등 도구별 활성/비활성, 미리보기와 listener 정리 | +| 분석 결과 레이어 | 각 분석 기능의 lib/model | 적용조건, 요청 순번, 결과 도형 | reload/clear/focus | 취소·오래된 응답 무시·레이어 제거 | +| 팝업 창 통신 | windowPopup 기반 + 지도 조립 어댑터 | 창 ref, 메시지, 마지막 선택 FID | 창 열기·결과 통지·선택 전달 | 메시지 listener/닫힘 polling timer 해제 | +| 우측 탭·패널 | map/ui와 model | 열린 탭·활성 탭·패널 상태 | 선택/닫기 callbacks | 탭 닫힘에 따른 관련 표시 해제, 다른 기능 상태는 보존 | + +설계 제안: MapRuntime에는 필요한 작업만 노출한다. 기존 GisApp 전체를 모든 훅에 전달하거나 다수의 ref/setter를 통째로 담은 객체를 만들지 않는다. 정확한 메서드와 타입은 현 사용처 목록을 만든 뒤 정한다. OpenLayers Map을 Zustand·localStorage에 넣지 않는다. + +### 4.2 작은 실행 단계 + +| 단계 | 실행 내용 | 즉시 확인할 회귀 | +| --- | --- | --- | +| 1-A 기준선 | 지도 page의 순수 계산·상수·JSX·effect·listener·timer·ref 목록을 작성. 기존 mapLayers/gisEdit 및 I/I 훅과 중복 추출 금지 | 진입/재진입, 초기 중심·줌, 레이어 초기값, 초기 API 실패 상태 | +| 1-B 저위험 분리 | 입력/출력이 명확한 순수 변환·스타일을 기존 lib에 통합. 우측 패널·레이어 설정 표시 JSX 분리 | 동일 설정·도형·표시값, 버튼 동작 유지 | +| 1-C 지도 수명주기 | 인스턴스 생성·준비·해제와 기본 listener 등록을 runtime 훅으로 분리 | 개발 모드 재실행/재진입 후 지도·listener 중복 없음, 사용자 지도 이동 상태 보존 | +| 1-D 업무 훅 이전 | 분석 레이어·선택·팝업 연결을 한 기능씩 이동. 편집 interaction은 일반 선택 분리 후 마지막에 이동 | 목록 페이지 이동 후 전체 지도 유지, 연속 조회의 오래된 응답 무시, 팝업 지연 도형 선택 | +| 1-E 진입점 축소 | map/ui/MapPageClient가 기능 훅을 조립하고 route page는 이를 호출. 미사용 import/ref 정리 | 전체 지도 시나리오 및 미저장 편집 보호 | + +단계 1에서는 외부 API·코드명·스타일 기본값·조회 타이밍을 함께 바꾸지 않는다. 성능 최적화나 비즈니스 결함 수정이 필요하면 별도 변경으로 분리한다. + +### 4.3 수명주기·메시지 안전 규칙 + +- effect를 옮길 때 dependency·실행 순서·최초 실행 조건을 기록한다. hook 분리로 재등록 주기가 달라지지 않도록 확인한다. +- effect cleanup은 생성한 이벤트 키·타이머·interaction을 정확히 해제한다. 공용 지도 전체 리소스를 일괄 제거하지 않는다. +- 선택/이동/꼭지점 편집 mode는 명시적으로 구분한다. 편집 mode에서 상세 열기 클릭이 동시에 처리되지 않도록 기존 우선순위를 보존한다. +- 비동기 조회는 취소 또는 요청 식별자로 최신 요청만 적용한다. popup과 지도 응답의 도착 순서가 뒤바뀌어도 마지막 사용자 선택을 보존한다. +- postMessage의 기존 origin/source/메시지 타입 검증을 확인하고 유지한다. 미비점 발견 시 별도 보완 범위를 기록한다. URL·메시지 이름을 임의로 변경하지 않는다. +- geometry의 좌표계·FID·레이어 ID 및 transform 횟수를 보존한다. 그리드 10건 배열로 전체 지도 source를 대체하지 않는다. + +### 4.4 1번 완료 판단 + +- route page가 업무 조회·interaction 생성·복수 레이어 수명주기를 직접 구현하지 않는다. +- 새로 만든 단일 훅에 수천 줄을 옮기는 것만으로 완료 처리하지 않는다. 분리 단위마다 소유 상태·입출력·cleanup이 설명 가능해야 한다. +- 아래 검증표의 지도 필수 시나리오를 통과해야 한다. 데이터/환경 문제로 미검증이면 조건부 완료로 명시하고 다음 위험 변경을 보류한다. + +## 5. 2번: API·상태 소유권 + +2026-09-29 적용: 레거시 공통 API 진입점 제거, 책임별 HTTP adapter, 앱 인증 bridge, 도메인/기능별 store 이관 및 미사용 dataStore 정리 완료. 검증·예외·위치 결정은 [010 API·상태 소유권 구현결과](D:/workspace/IIPBS-FRONTEND/frontend/apps/sn/docs/작업계획/SN-패키지구조진단/010-fe-SN-API상태소유권정리-구현결과.md)를 따른다. 아래 표는 설계 기준 이력이며 tab store는 auth가 app을 역참조하지 않도록 entities/menu/model에 배치했다. 개별 화면에 남은 업무 endpoint adapter 분리는 3~6번과 함께 수행한다. + +| 대상 | 설계 | 보존·검증 | +| --- | --- | --- | +| app/components/lib/api | 순수 transport는 shared/api, 업무 endpoint는 features/*/api, CMS·GeoServer 공통 통신은 업무에 독립적인 adapter로 구분 | base URL·헤더·쿠키·401·JSON/Blob·AbortSignal·오류 상태/본문 | +| shared/api barrel | client 초기화와 순수 transport export를 분리 | 순수 모듈 import로 인증 초기화가 발생하지 않음 | +| app/store와 shared/store | 실제 독자를 찾아 지역/메뉴/선택/보고서별 원본 store를 하나로 정함 | 두 store를 동시에 유지하며 동기화하는 방식 지양 | +| src/store/tab-store | 앱 탐색 계층으로 이동 검토 | persist key/version·열린 탭·권한 조회·로그아웃 초기화 보존 | +| 검색 상태 | draft 조건과 applied 조건을 기능 model에서 구분 | 검색 전 입력 변경이 목록/Excel 조건을 바꾸지 않음 | +| 서버 목록 데이터 | 현재 캐시 정책을 먼저 유지하고 단일 소유 hook으로 이동 | React Query 전환을 단순 폴더 이동에 함께 강제하지 않음 | + +하위 shared가 인증 entities를 직접 import해야 하는 구조라면 인증 정책을 앱 조립에서 주입한다. 기존 alias는 호출자를 이관한 후 0참조를 확인하고 제거를 검토한다. tsconfig 변경은 환경/설정 변경으로 구분하여 별도 승인 범위를 확인한다. 임시 재export가 필요하면 단일 구현만 남기고 제거 조건·시점을 명시하며 영구 이중 구현을 두지 않는다. + +## 6. 3번: 조사 상세 팝업 + +- 대상: features/exmn/ui/exmn-survey-detail-popup-page.tsx 및 StandardDataTable, 관련 api/model. +- 제안 분리: 조건 UI, 시설 결과 표, 본단위구간 표, 전체 summary 표시, 조회 hook, 다운로드 요청 hook. +- UI 모듈이 raw JSON 판정이나 서버 응답 구조 추측을 하지 않도록 response adapter와 표시 모델을 구분한다. +- 동적 결함 열과 CMS 명칭, 그룹 key(pipeNo/unitSeq 등 실제 계약), row span은 feature 내부에서 관리한다. +- 페이지 이동은 서버 요청이며 합계는 전체 summary를 사용한다. 본단위구간을 물리 행 수로 나누지 않는다. +- 확인: 관로/맨홀, 구조/운영 조건, 구간 모드, 검색/초기화/정렬/페이지, 전체 합계, Excel 열·병합·전체 건수. + +## 7. 4번: 시설 클릭 상세 팝업 + +- 대상: map/components/popup-component/feature-click-event-popup.tsx. +- 공통 container는 선택 시설 유형의 패널을 조립하고, 시설별 필드 표시·연관 조회는 해당 기능의 ui/api로 분리한다. +- 지원 유형 목록은 현재 분기를 직접 열거해 확정한다. 새 시설 유형이나 공통 fallback 의미를 추정 추가하지 않는다. +- 시설 선택 이벤트/지도 강조와 패널 조회 상태를 구분한다. 이전 시설 응답이 늦게 도착해 최신 상세를 덮어쓰지 않도록 한다. +- 확인: 시설별 선택·닫기·다른 시설 재선택, 관련 목록 페이지, 좌표/코드 표시, 편집 mode 충돌, 부모 지도 강조 해제. + +## 8. 5번: 컬럼 정의·코드명 분리 + +- 대상: map/components/common/table.ts 및 실제 소비 화면. +- 컬럼 정의는 해당 시설/기능의 model에 둔다. truly 공통인 rowNo·정렬·셀 표시 보조만 shared/lib에 둔다. +- CMS 코드명 조회와 컬럼 선언을 분리한다. 조회 그룹은 기존 코드·CMS 실제 응답으로 확인하며 이름으로 추정하지 않는다. +- 백엔드 Excel의 최신 CMS 명칭과 FE 표시가 일치하는지 비교한다. 프론트 전환 시 기존 값과 운영 코드·미등록 코드·빈값도 확인한다. +- 코드명 캐시 TTL/장애 정책은 서버와 조율한다. 현재 정적 선택지와 달라지는 범위는 사용자에게 명시한다. +- 컬럼 key·순서·정렬 필드·단위·날짜·말줄임·순번을 유지한다. 정렬 기준을 코드에서 명칭으로 바꾸는 것은 별도 계약 변경으로 검토한다. + +## 9. 6번: 준설 기능 집중 + +- app/oper/dredging의 목록·상세를 features/dredging/ui로 이전하고 기존 등록 body·api·model을 재사용한다. +- 목록 조회/정렬/페이지, 통계 mode, 상세/사진, 등록·EXIF·업로드 큐를 각각 소유 hook과 UI로 구분한다. +- 실제 API 모드와 로컬 demo 예외를 명시적으로 유지한다. 서버 페이지에서 브라우저 전체 수집으로 회귀하지 않는다. +- 사진 5장 제한, 촬영일/GPS 판정, 수동 위치 보완, 재시도, 삭제 범위, 미저장 취소를 보존한다. +- Excel은 마지막 적용 조건 전체와 정렬을 사용한다. 관재질 명칭 정렬의 백엔드 계약은 변경하지 않는다. +- 검증은 조회·상세·다운로드를 우선하며, 등록/수정/삭제는 승인된 테스트 데이터·환경에서만 수행한다. + +## 10. 7번: 공통 표·폼 컴포넌트 + +| 단계 | 작업 | 진행 조건 | +| --- | --- | --- | +| 7-A 사용계약 조사 | SN·WR·FO·DM의 import/재export/동적 참조, 실제 props·CSS·페이지 처리·편집·업로드 callback 비교 | 패키지 의존 선언만으로 미사용 판정 금지 | +| 7-B API 설계 | 기준 표/폼의 표현 책임을 정하고 서버 페이지·클라이언트 예외를 명시적 모드로 설계 | SN에 맞춘 기본값 변경으로 타 앱 의미를 바꾸지 않음 | +| 7-C 호환 확장 | 선택적 props나 명시적 adapter부터 적용. 기존 export·기본 동작 유지 | 권한 범위 내 호환 변경만 허용 | +| 7-D 소비 검증 | 소스 alias를 쓰는 개발 경로와 dist exports 소비 경로를 모두 검사 | 다른 앱 빌드/서버를 임의 중단하지 않음 | +| 7-E 제거 판단 | 전체 소비자가 이관됐고 0참조임을 확인한 중복 구현만 제거 | 타 앱 수정이 필요하면 별도 승인 전 보류 | + +지도 searchBar의 하드코딩된 hasudo/CMS 조회는 로더 주입 계약을 설계하되, 호환 기본 로더를 없애려면 모든 소비자 전환이 선행돼야 한다. 새 버전의 외부 배포나 의존성 업데이트는 이번 계획에 포함하지 않는다. + +## 11. 8번: 폴더·참조 정리 + +- 현재 빈 검증 폴더 3개는 삭제 직전에 실제 내용·프로세스·참조를 다시 확인한다. +- 옮긴 파일은 직접 import뿐 아니라 index 재export, popup registry, dynamic import, tsconfig alias, CSS import, 정적 URL도 확인한다. +- public/samples·shared 패키지·빌드 산출물은 이름만 보고 삭제하지 않는다. +- tests/scripts 등 이번 작업에서 생성한 테스트 자산은 결과 요약 후 삭제한다. 기존 타 작업자 자료는 생성 주체·삭제 권한을 확인한다. +- 테스트 전용 빈 폴더와 끊어진 실행 명령을 정리한다. 실행 중 .next·공유 build·node_modules를 일괄 삭제하지 않는다. +- Windows는 검증된 절대 경로와 LiteralPath를 사용하고 junction/symlink를 따라가지 않는다. 삭제가 정책/파일 핸들에 막히면 우회하지 않고 정확한 잔여 목록을 보고한다. + +## 12. 검증 계획과 통과 기준 + +| 메뉴 > 화면 | 필수 시나리오 | 통과 근거 | +| --- | --- | --- | +| 하수관망도 > 첫 진입/재진입 | 지도 준비, 레이어 초기값, 빠른 페이지 전환 | 중복 지도·listener·오류 없음 | +| 하수관망도 > 레이어 관리 | 개별·전체 켜기/끄기, 유하방향, 설정 변경 | UI와 실제 지도 일치, 기존 상태 정책 유지 | +| 하수관망도 > 시설 상세 | 유형별 클릭, 재선택, 닫기 | 최신 시설의 상세/강조 일치 | +| 하수관망도 > GIS 편집 | 선택·이동·꼭지점·추가·취소·저장 경계 | interaction 충돌·도형 손실 없음. DB 저장은 승인된 환경에서만 | +| 하수관망도 > 분석/보수이력 팝업 | 팝업 열기/닫기, 목록 페이지, 도형보다 빠른 선택 | 부모/자식 실제 연동, 전체 지도 유지 | +| 하수관망도 > 정보검색 | 검색·정렬·페이지·전체 Excel | 적용조건/전체 건수/표시명 일치 | +| 유지관리 > 조사 상세 | 시설·구간 모드, 조건, 동적 컬럼, 합계, Excel | 서버 페이지·그룹·전체 합계·열/병합 유지 | +| 유지관리 > 준설 | 3개 구분, 정렬/페이지, 상세/사진, 다운로드 | 현 업무 결과 보존 | +| 인증·메뉴·탭 | 로그인/로그아웃, 권한, 새로고침 | 기존 토큰/탭/권한 캐시 정책 유지 | +| 공유 표·폼 소비 앱 | 실제 사용 화면별 페이지/편집/검증 | SN 외 영향 확인. 미수행은 통과로 표시하지 않음 | + +- 정적 검증: 타입 검사, 범위 내 lint, import 방향/0참조 검사, diff 및 한글 확인. 단순 줄 수 감소를 성과로 대체하지 않는다. +- 브라우저: 실제 조작·콘솔·필요한 Network 응답을 확인한다. canvas 자동화가 어려우면 수동 관측으로 전환하고 이유를 남긴다. +- 빌드: 사용자 Next 서버 실행 중 next build 금지. 승인된 별도 검증 조건 또는 서버 종료 승인 이후에만 수행한다. +- 신규 검증 서버는 PID·포트·주체 기록 후 종료한다. 사용자 서버를 임의 재시작하지 않는다. +- 임시 테스트 결과는 최종 문서에 핵심 수치/명령/한계만 요약하고 삭제한다. 기존 테스트 소스 보존 여부는 현재 사용자 지시를 따른다. + +## 13. 롤백·중단 기준 + +- 단계별 변경 파일과 기존 수정분을 구분하고, 실제 착수 전 작업 트리 상태를 기록한다. 사용자 변경을 덮어쓰지 않는다. +- UI 이동·호출자 import·공개 export·관련 CSS를 한 묶음으로 되돌린다. 전역 git reset/checkout으로 다른 작업을 제거하지 않는다. +- 저장 데이터/DB 마이그레이션을 포함하지 않는 구조 개선으로 제한한다. 새로운 DB 변경이 필요하면 해당 단계를 중단한다. +- 원본 계약 불명확, 기존 편집 도형 손실, 인증/권한 회귀, 전 앱 소비자 영향 미확인, 사용자 서버 종료가 필수인 경우는 중단/보류 사유로 기록한다. +- 병렬화가 필요하면 명시적인 위임 요청·파일 소유권을 먼저 확정한다. 이 계획은 에이전트 자동 병렬 실행 지시가 아니다. + +## 14. 착수 체크리스트와 보고 형식 + +- [ ] 해당 단계 구현 승인 및 실제 쓰기 범위 확인 +- [ ] 공통 작업현황·파일점유·결정로그와 현재 사용자 수정 확인 +- [ ] 해당 기능 원본 요구사항/화면 ID 직접 재확인 +- [ ] 변경 전 사용자 시나리오·API·상태 수명주기 기준선 확보 +- [ ] 파일별 이전 경로·소유 상태·입출력·cleanup 확정 +- [ ] 범위 내 구현 및 실제 브라우저/타입/계약 검증 +- [ ] 메뉴 > 화면별 통과/실패/미검증, 코드 변경 이유, 롤백 기록 +- [ ] 생성 테스트 파일·로그·프로세스 정리 및 잔여 목록 기록 + +최종 보고에는 목적, 수정 파일, 변경 이유/방법, 영향 범위, 검증·미검증, 롤백, 남은 리스크를 포함한다. 이 체크리스트는 전체 단계의 게이트이며 첫 배치의 실제 수행 범위는 007 구현결과에 기록한다. + +## 15. 최종 구조 결정: CM의 장점과 Next.js 공식 규칙 + +### 15.1 조사 범위와 버전 + +- 조사일: 2026-09-28. SN package.json의 Next.js는 **15.5.18**이다. 공식 사이트의 `/docs/15/` 문서를 기준으로 조회했다. 버전 문서는 15 계열 문서이며 설치된 특정 패치 버전의 불변 사본은 아니다. +- 최신 문서의 새 API·실험 기능을 현재 환경에 자동 적용하지 않는다. Next/React 업그레이드, Cache Components·PPR·`use cache` 도입, middleware 이름 변경은 이번 구조 개선에 포함하지 않는다. +- 공식 문서 사실과 SN의 설계 선택을 구분한다. Next.js는 features/entities/shared 같은 업무 폴더명을 강제하지 않는다. 따라서 CM의 pages/widgets를 복사하거나 정해진 아키텍처 명칭을 충족시키기 위한 폴더 증설은 하지 않는다. [공식 프로젝트 구조](https://nextjs.org/docs/15/app/getting-started/project-structure) + +### 15.2 최종 결정표 + +| 주제 | 공식 기준·CM 관측 | SN 최종 선택 | +| --- | --- | --- | +| 루트 app과 src/app | 공식적으로 root app이 있으면 src/app은 라우팅 대상으로 사용되지 않음 | root app을 유일한 라우트 루트로 유지. src/app의 직접 import된 Provider·스타일은 일반 소스 모듈로 사용 | +| app 내부 구현 | Next는 라우트 옆 코드 배치도 허용. `_folder`는 선택적인 라우팅 제외 기능 | SN에서는 기존 관례를 우선해 일반 `ui/lib/model` 사용. 업무 기능은 features에 두고 내부 폴더에 route 파일을 만들지 않음 | +| page.tsx | CM 대시보드 route는 화면 export만 연결 | SN은 가벼운 진입점을 기본으로 하되, 서버 초기 데이터·메타데이터 등 필요한 route 책임까지 무조건 외부로 밀어내지 않음 | +| src/app | CM/SN 모두 Provider와 styles 존재 | 초기화·전역 스타일·앱 탐색 상태에 한정. 기존 src/app/map 제안은 철회하고 map 라우트 내부 ui/lib/model로 대체 | +| features | CM에는 별도 features가 없음 | SN의 기존 업무 기능 경계를 유지. 일반 화면·지도·팝업이 같은 구현을 사용 | +| pages/widgets | CM은 화면과 조합 UI 계층을 별도 운영 | 일괄 신설하지 않음. 지도 조립에만 필요한 계층을 route-local로 추가 | +| 공통코드 API | CM은 entities/code에 도메인 API를 배치 | 여러 기능에서 공유하는 코드 그룹 조회/타입은 entities/code 후보. 특정 업무 fallback·표시 규칙은 feature에 둠 | +| QueryClient | CM은 Provider 함수 내부에서 생성, SN은 useState로 유지 | SN의 인스턴스 안정성을 유지. CM 패턴을 기계적으로 복사하지 않음 | + +root app/src/app 우선순위는 [공식 src 규칙](https://nextjs.org/docs/15/app/api-reference/file-conventions/src-folder), colocation·비공개 폴더는 [공식 프로젝트 구성](https://nextjs.org/docs/15/app/getting-started/project-structure#colocation)에 근거한다. root app을 이동하거나 삭제하면 src/app의 프레임워크 의미가 달라질 수 있으므로 라우트 루트 이전은 별도 설계 대상이다. + +### 15.3 파일 배치 판단 순서 + +1. URL·layout·metadata·HTTP endpoint의 책임인가? → root app의 Next 파일 규칙. +2. 특정 라우트에서만 여러 기능을 연결하는가? → 해당 route의 `ui/lib/model`. 다른 feature가 이 라우트 전용 내부 폴더를 역참조하면 안 된다. +3. 준설·조사·GIS 편집 등 업무 기능 자체인가? → features. 한 곳에서만 사용하더라도 업무 의미가 분명하면 features가 가능하다. +4. 사용자·메뉴·코드처럼 여러 기능이 공유하는 도메인인가? → entities. +5. 업무와 무관한 통신·표현·순수 보조 기능인가? → shared. +6. 여러 앱에 걸친 실제 사용·동일 계약이 확인됐는가? → business_components 검토. 단지 재사용할 수 있어 보인다는 이유로 이동하지 않는다. + +api/model/lib/ui는 실행환경을 뜻하지 않는다. 예를 들어 feature/api에도 서버 전용 코드가 생길 수 있으므로 서버용/브라우저용 진입점을 분리한다. 인터페이스 정의는 소비 기능 또는 공통 도메인에 두고, route-local MapRuntime adapter가 이를 구현·주입한다. 재사용 기능이 route-local 타입을 import하는 역방향 의존을 만들지 않는다. 일반 내부 폴더는 Next가 강제로 숨기는 폴더가 아니며, 페이지/핸들러 파일이 없기 때문에 URL endpoint가 생기지 않는다는 점을 구별한다. + +## 16. Next.js 실행환경·데이터 설계 보완 + +### 16.1 서버·클라이언트 경계 + +공식적으로 page/layout은 기본 Server Component이며 `'use client'`는 모듈 그래프의 클라이언트 경계를 만든다. Client Component도 기본적으로 초기 HTML 사전 렌더링에 참여하므로 `'use client'`만으로 모듈 최상단 `window` 접근이 안전해지는 것은 아니다. Server→Client props는 React가 직렬화할 수 있는 값이어야 한다. Provider는 실제 사용 범위에 가깝게 배치하는 것이 권장된다. [공식 Server/Client Components](https://nextjs.org/docs/15/app/getting-started/server-and-client-components) + +SN 적용 규칙: + +- 지도·편집·선택·브라우저 popup은 Client 영역을 유지한다. 서버 컴포넌트로 전부 바꾸는 것을 목표로 하지 않는다. +- map/page는 가능하면 서버 진입점, `ui/MapPageClient`는 상호작용 경계로 둔다. auth Provider를 감쌌다는 이유만으로 루트 layout 전체에 `'use client'`를 추가하지 않는다. +- DOM·Window·OpenLayers Map·Feature 인스턴스와 일반 callback을 서버 props로 전달하지 않는다. 초기 조건은 직렬화 가능한 데이터로 전달하고 인스턴스는 Client 내부에서 만든다. +- 인증·메뉴·공통 알림은 기존 범위를 우선 유지한다. 지도 전용 Provider는 실제 소비 범위를 조사한 뒤 지도 레이아웃으로 제한할 수 있는지 검토한다. Provider 이동에 따른 상태 초기화·미저장 값 손실을 검증한다. +- 서버 request별 데이터와 브라우저 전역 상태를 구분한다. 사용자별 서버 상태를 module singleton store에 저장하지 않는다. + +### 16.2 지연 로딩과 지도 진입 예시 + +브라우저 전용 라이브러리의 `ssr: false`는 Client Component 안에서 사용해야 한다. dynamic import를 만들었다는 이유만으로 사용하지 않는 UI가 자동으로 지연되는 것은 아니며 실제 조건부 렌더·번들 경로를 확인한다. [공식 Lazy Loading](https://nextjs.org/docs/15/app/guides/lazy-loading) + +설계 예시(실제 코드 미변경): + +```tsx +// app/(map-layout)/map/page.tsx — route 진입점 +import MapEntry from './ui/MapEntry'; +export default function Page() { + return ; +} + +// app/(map-layout)/map/ui/MapEntry.tsx +'use client'; +import dynamic from 'next/dynamic'; +const MapPageClient = dynamic(() => import('./MapPageClient'), { + ssr: false, + loading: () =>
지도를 불러오는 중입니다.
, +}); +export default function MapEntry() { + return ; +} +``` + +위 wrapper는 기존 GisApp/지도 의존성이 서버 import에 안전하지 않을 때 사용한다. 모든 화면에 추가하지 않는다. 기존 팝업 dynamic registry는 유지하며 feature의 거대한 index가 지도·Excel 라이브러리를 일반 목록의 초기 번들로 끌어들이지 않는지 확인한다. 파일만 나누고 전체를 eager import하면 성능 개선으로 보고하지 않는다. + +### 16.3 조회·Route Handler·업무 서버의 책임 + +| 작업 | 실행 위치·원칙 | +| --- | --- | +| 조회조건 변경·페이지 이동·실시간 지도 조작 | 기존 Client 조회 hook/React Query와 feature API 유지. 무조건 서버 렌더링으로 전환하지 않음 | +| 서버 초기 조회가 유효한 화면 | 기존 인증·게이트웨이 정책을 유지하는 server 전용 API adapter 사용. 자신의 Next `/api`를 HTTP로 재호출하지 않음 | +| 파일 스트리밍·쿠키가 필요한 중계 | 기존 app/api Route Handler 유지. 단순 업무 API마다 중복 proxy를 신설하지 않음 | +| 대량 Excel 생성·장시간 작업 | 기존 Spring 작업 API가 소유. Next Route Handler는 접수/상태/파일 중계이며 자체 메모리 큐로 대체하지 않음 | +| 등록·수정·삭제 | 기존 백엔드 API와 권한 정책 유지. 구조 개선 때문에 Server Actions로 재작성하지 않음 | + +Server Component에서 자체 Route Handler를 호출하면 불필요한 HTTP 왕복이 생기며 빌드 시 정적 렌더 환경에서 문제가 될 수 있다. 이는 인증을 우회해 내부 DB로 직접 접속하라는 뜻이 아니다. [공식 Backend for Frontend](https://nextjs.org/docs/15/app/guides/backend-for-frontend#server-components) + +### 16.4 캐시·검색조건·비동기 요청 API + +Next.js 15에서는 서버 fetch와 GET Route Handler가 기본 캐시되지 않는 방향으로 변경됐고 cookies/headers 및 route의 params·page searchParams가 비동기 API로 바뀌었다. 기존 Next 14 기본값을 전제로 설계하지 않는다. 브라우저의 `useSearchParams()` hook과 page의 비동기 searchParams prop을 혼동하지 않는다. [공식 v15 변경 사항](https://nextjs.org/docs/15/app/guides/upgrading/version-15) + +SN 정책: + +- React Query 캐시, Next 서버 fetch 캐시, HTTP 캐시는 각각 별개다. 하나를 무효화하면 다른 것도 자동 갱신된다고 가정하지 않는다. +- 사용자별 조회·작업 상태·다운로드 응답은 공유 캐시로 노출하지 않는다. 코드·기준정보 캐시는 TTL·소유 범위·장애 정책을 별도로 기록한다. +- 목록 query key에는 적용조건·page·size·sort를 포함한다. 지도 key에는 영역/레이어/적용조건을 사용하고 목록 page/size를 섞지 않는다. Excel은 applied 조건 전체를 사용한다. +- 작성 중 draft 조건과 applied 조건을 구분한다. URL 반영이 필요한 조건은 기존 링크/새로고침 계약을 확인해 정하고 모든 transient state를 URL에 넣지 않는다. +- stale 응답 차단과 취소는 React Query 또는 요청 식별자로 소유 hook에서 수행한다. 요청 취소 자체를 업무 실패 알림으로 노출하지 않는다. +- `await cookies()`, `await params` 등 현재 15 버전 계약을 유지한다. 새 캐시 옵션·실험 기능·환경값 변경은 포함하지 않는다. + +### 16.5 보안·오류·로딩 + +서버 전용 데이터 접근은 클라이언트 import와 분리하고, 클라이언트에는 필요한 필드만 전달한다. 서버 전용 모듈에는 `server-only` 등 경계 표식을 검토하되 의존성 설치가 필요하면 별도 승인 범위를 확인한다. `.server.ts` 파일명만으로 프레임워크가 실행환경을 강제하는 것은 아니다. [공식 Data Security](https://nextjs.org/docs/15/app/guides/data-security) + +- UI 버튼 숨김이나 AuthWrapper만으로 권한 검증을 대체하지 않는다. 실제 Route Handler·백엔드에서 기존 인증/소유권 검증을 유지한다. +- 다운로드 중계는 허용된 백엔드·jobId·콘텐츠 타입을 검사하고 버퍼 전체 적재 대신 스트리밍한다. 민감한 헤더·쿠키를 응답이나 클라이언트 props에 복사하지 않는다. +- 실패 유형을 401/403, 입력 검증, 연결 실패, 취소, 서버 작업 실패로 구분한다. 사용자에게 재로그인·수정·재시도 등 해당 행동을 안내한다. + +`error.tsx`는 렌더 오류 경계이며 일반 이벤트 핸들러·비동기 콜백의 모든 오류를 대신 처리하지 않는다. 버튼 다운로드·지도 클릭 후 API 실패는 기능 hook에서 처리해야 한다. [공식 Error Handling](https://nextjs.org/docs/15/app/getting-started/error-handling) + +- route 진입 로딩과 목록 재조회 로딩을 구분한다. `loading.tsx`만 추가해서 Client effect 조회의 로딩이 해결됐다고 판단하지 않는다. +- 데이터 없음·로딩 중·오류를 별도 상태로 표시한다. 지도 한 레이어 실패가 전체 지도를 무조건 초기화하지 않도록 오류 경계를 정한다. +- retry는 재조회 가능한 동작에만 적용한다. 등록·삭제 자동 재시도로 중복 수행을 만들지 않는다. + +## 17. 기존 1~8번 계획에 반영할 추가 완료 조건 + +| 단계 | 추가 확인 항목 | +| --- | --- | +| 준비 | 설치 Next 15.5.18·활성 라우트 루트·Client 경계·Provider·현재 캐시 정책을 기준선으로 기록 | +| 1 지도 | route-local 조립 위치, client-only 지도 import, SSR/hydration 오류, 진입/재진입의 listener·timer·instance 정리 | +| 2 API·상태 | shared 역참조 제거, 도메인 API 소유권, 서버/브라우저 진입점 분리, 사용자별 데이터 공유 캐시 금지 | +| 3 조사 | 동적 표/구간·전체 합계 보존에 더해 query key·취소·오류/빈 결과 분리 | +| 4 상세 | 연속 선택 stale 응답, 렌더 오류와 API 오류, 부모 창 메시지 검증 | +| 5 컬럼 | CMS 조회 소유권과 캐시 정책, FE/BE Excel 코드명 정합성. 서버 캐시 갱신과 FE 캐시 갱신을 구분 | +| 6 준설 | 목록·등록·다운로드 UI를 나누되 서버 요청·적용조건 원본은 하나. lazy 분리로 저장 동작이 달라지지 않음 | +| 7 공유 UI | 전 앱 props 호환성, 소스 alias/dist 소비 경로, 클라이언트 라이브러리의 불필요한 번들 유입 검사 | +| 8 정리 | route-local ui/lib/model 하위에 page/route 파일 없음, 불필요 barrel/alias/legacy 참조 제거, 테스트 자산 정리 | + +최종 완료는 파일 개수나 목표 줄 수가 아니라 (1) 기능의 구현 위치가 하나로 모임, (2) 상태 원본이 하나임, (3) 실행환경 경계가 명확함, (4) 기존 사용자 흐름과 권한이 보존됨으로 판단한다. production build·청크 결과 검증은 사용자 dev 서버를 방해하지 않는 승인된 환경에서 수행하고 이번 문서 작업의 검증으로 기재하지 않는다. + +## 18. 설계 문서 보완 당시의 내역·검증 범위 + +- 유지: 기능 중심 features, 1~8번 순서, 지도/목록/Excel 분리, 타 앱 영향 조사, 작은 단위 실행과 롤백. +- 변경: src/app/map 제안 대신 지도 route의 `ui/lib/model` 사용. src/app은 전역 초기화·styles·탐색 역할로 명확화. +- 추가: CM 장점의 선택 적용, 파일 배치 판단 순서, Next.js 15 실행환경·캐시·BFF·오류·로딩·지연 로딩 기준과 단계별 완료 조건. +- 근거: 공식 Next.js 문서 8종(각 절 직접 링크), CM/SN 소스 관측, SN package.json의 버전. 인터넷 일반 블로그나 커뮤니티 의견을 설계 근거로 사용하지 않았다. +- 코드·API·DB·패키지·환경·서버 프로세스 변경 없음. 브라우저·빌드·실행 회귀 미수행. 문서 경로·파일명은 유지했으며 UTF-8 한글과 수정된 구조의 내부 참조를 확인한다. +- 롤백: 이번 문서 보완 부분만 복원하면 된다. 이전 구현·사용자 변경·설계 이력을 일괄 되돌리지 않는다. diff --git a/apps/sn/docs/작업계획/SN-패키지구조진단/007-fe-SN-지도페이지분리-1차구현결과.md b/apps/sn/docs/작업계획/SN-패키지구조진단/007-fe-SN-지도페이지분리-1차구현결과.md new file mode 100644 index 000000000..3a23d6265 --- /dev/null +++ b/apps/sn/docs/작업계획/SN-패키지구조진단/007-fe-SN-지도페이지분리-1차구현결과.md @@ -0,0 +1,84 @@ +# 1번 지도 페이지 분리: 첫 구현 배치 + +작업일: 2026-09-28. 상태: **1-A 기준선·1-B 보조 로직 분리 완료, 1-C 준비/메시지 연결 부분 분리**. 1번 전체 완료가 아니다. 편집 interaction과 개별 분석 레이어 hook 분리는 남아 있다. + +## 1. 목적·근거·범위 + +- 사용자 지시: 최종 설계의 1번부터 구현. +- 기준: 같은 폴더의 006 단계별 구조개선 설계계획. 이번 수정은 SN 지도 route와 라우트 전용 내부 모듈로 한정한다. +- 원본 재확인: 요구사항정의서 `3. (기능)요구사항정의 및 추적표` 96/98/100행 SFR-012-001/003/005, 화면설계서 34쪽 SN_MN010301·36쪽 SN_MN010303·37쪽 SN_MN010304. 시설 표출/속성, 원본과 분리된 편집, 관저고 기반 유하방향 의미를 바꾸지 않는다. +- 작업 시작 시 프론트 추적 코드 변경은 없고 005/006 문서가 미추적 상태였다. 해당 문서는 보존했다. +- BE·DB·business_components·타 앱·환경·패키지 버전·배포 설정 미변경. 사용자 서버 종료/재시작과 next build 미수행. + +## 2. 수정 구조 + +기준: `apps/sn/app/(map-layout)/map/`. 후속 사용자 승인으로 underscore 접두사를 제거해 `ui`, `lib`, `model`로 변경하고 import를 수정했다. 파일의 업무 본문은 유지했다. 이는 Next 비공개 폴더 기능을 사용하는 구조가 아니라 SN의 일반 내부 폴더 명명 관례를 따른 것이다. + +후속 폴더명 변경 검증: 이전 underscore 경로의 코드 참조 0건, 변경 파일 whitespace 검사 및 `pnpm exec tsc --noEmit --incremental false --pretty false` 통과(exit 0). 브라우저 도구 연결은 두 차례 시간 초과로 명칭 변경 후 화면 재검증을 완료하지 못했다. 아래 최초 분리 때의 브라우저 통과 기록과 구분한다. 사용자 서버 재시작·빌드·테스트 파일 생성은 하지 않았다. 이전 빈 폴더를 남기지 않고 3개 디렉터리를 이동했으며 제품 파일 삭제는 없다. + +| 파일 | 책임 | 변경 이유 | +| --- | --- | --- | +| page.tsx | 서버 route 진입점, MapPageClient 연결 | URL 진입과 지도 구현을 구분 | +| ui/map-page-client.tsx | 기존 Client 지도 화면·상태 조립 | 기존 화면 상태/동작을 유지하면서 기능별 분리의 기준점 확보 | +| ui/map-control-styles.tsx | 기존 지도 컨트롤 global CSS | 스타일 소유 위치 구분. 선택자·값 유지 | +| model/map-constants.ts | 레이어 ID·메시지·표시 상수 | 상수 정의를 이벤트 처리와 분리 | +| lib/map-values.ts | 응답·날짜·geometry key·입력 판별 | 소규모 변환 책임 분리 | +| lib/analysis-layer-styles.ts | 조사·침수이력 스타일 | 기존 등급·라벨·표현 유지 | +| lib/sinkhole-features.ts | 지반침하 도형/상세 fallback·트리 보조·스타일 | 지반침하 관련 변환을 묶고 원본 로직 유지 | +| lib/gis-edit-features.ts | 편집 FID·선택·속성·필수항목 보조 | 편집 interaction 자체와 보조 계산 분리 | +| lib/right-panel-features.ts | 시설 상세 탭 식별·분류·표시 | 탭 표시 정책을 화면 상태에서 분리 | +| lib/water-risk-styles.ts | 통수위험 도형 스타일 | 위험·Capacity·Flooding 표현 유지 | +| lib/layer-tree.ts | 관종 스타일 선택·트리 표시 보조 | 기존 유하방향/트리 계산 분리 | +| lib/use-map-runtime-bridge.ts | 지도 준비 감지와 정보검색/GIS업로드 메시지 effect | 300ms polling, 메시지 등록/해제 책임을 명시 | + +원래 `Gis`가 지도 객체를 생성한다. 이번 배치에서 지도 생성 소유권을 새 훅으로 빼앗거나 새 지도 인스턴스를 추가하지 않았다. bridge 훅은 기존 gisMaps ref를 참조하고 준비 타이머·메시지 listener만 관리한다. 기존 same-origin 검사·payload 분기·callback 변경 시 effect 갱신을 유지했다. 새로운 창 source 검증 정책은 이번 이동에 섞지 않았다. + +## 3. 주요 코드 변화 + +```tsx +// route는 Client 화면을 연결한다. +import MapPageClient from "./ui/MapPageClient"; +export default function Page() { + return ; +} + +// 기존 effect가 있던 위치에서 연결 훅을 호출한다. +useMapRuntimeBridge({ gisMaps, setMapReady, loadFloodPredictionGridLayer }); +``` + +별도의 SSR 비활성 wrapper를 추가하지 않았다. 기존 Client 화면과 공통 Gis의 로딩 방식을 그대로 유지했고 현재 개발 서버에서 진입·재진입을 확인했다. production prerender 검증은 별도다. + +규모는 빈 줄 제외 기준: 기존 route 약 7,170줄 → route 5줄 + Client 조립 약 6,218줄. bridge는 약 190줄이다. **Client 조립 파일은 여전히 크다. 단순 파일 이동을 1번 전체 완료로 간주하지 않는다.** 상단 78개 선언과 스타일·bridge effect가 실제 별도 책임으로 분리됐다. + +## 4. 검증 결과 + +| 검증 | 결과·한계 | +| --- | --- | +| 원본 선언 비교 | git HEAD와 분리 모듈 78개 선언을 TypeScript AST printer 기준으로 대조, 서식 정리 전 본문 차이 0개. 이후 Prettier 서식 정리 | +| 타입 검사 | `pnpm exec tsc --noEmit --incremental false --pretty false` 최종 exit 0 | +| 최초 검사 | 파일 이동 중 실행된 최초 검사는 page.tsx 일시 부재로 실패. route 복원 후 최종 검사는 통과. 실패 이력을 최종 통과로 숨기지 않음 | +| 포맷 | 신규 모듈 11개 및 route를 명시 경로로 Prettier 적용. 최초 glob 패턴은 괄호 경로 미매칭으로 실패하여 실제 파일 경로로 실행 | +| whitespace | git diff --check 통과. CRLF 변환 안내는 오류와 구분 | +| 하수관망도 > 최초 진입/새로고침 | 승인된 localhost 계정 로그인 후 지도·시설 마커·레이어 트리·분석 메뉴 표출 확인 | +| 하수관망도 > 지도 확대/축소 | 줌 7.5 → 8.5 → 7.5 및 축척 표시 확인 | +| 하수관망도 > 지반침하 설정 | 적용 → 미적용 → 적용 토글 및 기존 반경/연도 표시 확인 | +| 하수관망도 > GIS 편집 | 편집 세션 0건, 도구 표시, 이동 mode 안내, 취소 후 도구 닫힘 확인. 데이터 저장·삭제 미수행 | +| 하수관망도 > 정보검색 | 버튼 클릭 후 자식 창이 도구의 탭 목록에 나타나지 않음. 실제 부모/자식 메시지 왕복은 미검증. 클릭만으로 통과 처리하지 않음 | +| production build·전체 편집 회귀 | 미수행. 사용자 dev 서버 실행 중 next build 금지. 꼭지점 drag/실제 도형 변경/저장·모든 분석 레이어 회귀 미수행 | + +검증은 실데이터 조회와 UI 상태 변경만 수행했다. 실제 DB 업무 데이터 등록·수정·삭제는 하지 않았다. + +## 5. 다음 분리 배치와 완료 게이트 + +1. 1-C 후속: 공통 Gis 생성·해제 계약을 읽고 실제 cleanup 소유권을 명시한다. 준비 bridge 분리만으로 생성/해제 전체 검증 완료라 하지 않는다. +2. 1-D: 유하방향·지반침하·침수이력·조사 등 분석 레이어를 한 기능씩 상태/요청/정리 책임으로 묶는다. 기존 features/map-layers·gisEdit 코드를 중복 생성하지 않는다. +3. GIS 편집 interaction은 일반 선택/상세 연결 회귀 후 마지막에 분리한다. ref/setter 수십 개를 그대로 받는 거대 훅으로 옮기지 않는다. +4. 정보검색·보수이력 등의 부모/자식 실제 연동 검증 수단을 확보해야 관련 메시지 계약 변경을 확대할 수 있다. 현재 메시지 본문은 유지했다. +5. 1-E: 화면 JSX·패널 조립을 더 나누고 전체 필수 회귀가 끝났을 때 1번 완료 처리한다. 2~8번 구현은 아직 미착수다. + +## 6. 정리·롤백·남은 위험 + +- 테스트 스크립트·fixture·캡처·로그 파일을 새로 저장하지 않았다(정리 대상 0개). AST 비교는 메모리에서 실행했다. 신규 제품 모듈과 최종 문서는 유지한다. +- 별도 앱 서버를 기동하지 않았다. 기존 사용자 프론트 PID 25020/3002와 백엔드 PID 27268/8883을 유지한다. 검증용 탭은 종료한다. +- 롤백: 이번 route 변경과 신규 ui/lib/model 모듈을 한 묶음으로 원복한다. 기존 사용자 문서·다른 변경을 포함한 전체 reset 금지. 미추적 새 모듈은 원본 page의 기존 Git 내용으로 복원 가능한 추출분이며 임의 백업 파일은 만들지 않았다. +- 위험: 대형 조립 컴포넌트·기존 any 타입·편집 및 팝업 의존은 여전히 남아 있다. 일부 원본 callback의 비동기 오류/창 source 검증 개선은 별도 검토이며 이번 책임 이동에서 동작을 변경하지 않았다. diff --git a/apps/sn/docs/작업계획/SN-패키지구조진단/008-fe-SN-케밥케이스통일-구현결과.md b/apps/sn/docs/작업계획/SN-패키지구조진단/008-fe-SN-케밥케이스통일-구현결과.md new file mode 100644 index 000000000..e427f4fed --- /dev/null +++ b/apps/sn/docs/작업계획/SN-패키지구조진단/008-fe-SN-케밥케이스통일-구현결과.md @@ -0,0 +1,260 @@ +# SN 파일·내부 폴더 kebab-case 통일 + +작업일: 2026-09-28. 사용자 지시: 라우트는 유지하고 일반 파일·내부 폴더를 kebab-case로 통일한다. + +## 1. 범위와 처리 원칙 + +- 대상은 apps/sn/app 및 src의 소스 356개. 다른 앱과 business_components는 변경하지 않는다. +- 파일 경로 210개 변경, import/export/동적 import 등 모듈 참조 545개 수정, 참조 수정 또는 이동한 소스 243개. +- 경로에 page/layout/route 등 Next 지정 파일이 있는 모든 상위 디렉터리와 동적 세그먼트·route group을 보호했다. 라우트·레이아웃·핸들러 파일 경로 32개가 변경 전후 동일하다. +- 컴포넌트/함수/export 이름, UI 문자열, API URL, popup key, query parameter, 데이터 필드, CSS selector는 변경하지 않았다. +- 일반 파일과 폴더는 kebab-case, Next 지정 파일·라우트 폴더·설정 파일·정적 자원·문서 파일명은 예외로 네이밍 지침 009에 명시했다. +- 앞선 지도 분리의 미커밋 변경과 005/006/007 문서는 보존하면서 현재 파일 경로 표기를 갱신했다. 예전 다른 작업 보고서의 경로는 당시 기록으로 유지하며 아래 전체 매핑을 최신 경로 확인에 사용한다. +- package.json의 lint:refactor에서 mapLayers/gisManagement 경로만 새 이름으로 변경했다. 패키지 버전·환경·next 설정은 변경하지 않았다. + +## 2. URL 유지 예시 + +| 종류 | 유지 경로 | +| --- | --- | +| 지도 | /map | +| 노후관로 팝업 | /map/agedPipePopup | +| 일반 팝업 | /popup/[popupKey] (mapSearch 등 key 유지) | +| I/I 결과 | /ii/rdiiResult | +| 사업 목록 | /oper/obsltPplBiz, /oper/ctyFldngPrvtnBiz | +| 기존 운영정보 | /oper/oldPp, /oper/floodPre, /oper/operStore | +| SN 서버 API | /api/gisUpload, /api/gridDownload/[jobId], /api/proxy/[...url], /api/auth/logout | + +## 3. 검증 + +- TypeScript 모듈 resolver로 이동 전 실제 경로를 해석해 참조를 변환했다. 경로 충돌/대소문자만 다른 이동 0건. +- 변환 후 재스캔: 파일 356개 유지, 남은 이름 변경 후보 0건, 추가 참조 수정 후보 0건, 보호 대상 32개 경로 일치. +- pnpm exec tsc --noEmit --incremental false --pretty false 통과(exit 0). +- git diff --check 통과. 원본 개행의 CRLF 안내는 오류와 구별한다. +- 실제 브라우저: localhost 로그인, `/map` 지도·레이어 트리·분석 메뉴 로딩, `/popup/mapSearch` 동적 팝업과 2페이지 순번 11~20, `/oper/obsltPplBiz` 기존 URL의 실제 사업 목록을 확인했다. 최초 지도 navigation 도구 timeout 이후 로그인 화면을 확인해 정상 검증을 이어갔다. 개발 서버가 실행 중이므로 next build는 하지 않았다. +- 이번 변경은 위치·모듈 경로의 기계적 변경이며 신규 업무 규칙/DB 매핑 설계가 없다. 기능 검증 기준은 직전 설계·지도 분리 결과의 원본 요구사항 추적을 유지한다. + +## 4. 정리·롤백·위험 + +- 이동 후 비어 있는 이전 내부 폴더 52개만 제거했다. 보호된 라우트 디렉터리와 기존 빈 검증 폴더는 일괄 삭제하지 않았다. 업무 소스 파일은 삭제하지 않고 이동했다. +- 임시 변환/검증 스크립트 `sn-kebab-plan-20260928.cjs` 1개는 검증 후 삭제했다. 별도 복구 사본은 없으며 원시 데이터·이미지·로그는 생성하지 않았다. 최종 결과 문서와 이동 매핑은 보존하고 검증용 브라우저 탭을 닫았다. +- 사용자 프론트/백엔드 서버를 종료·재시작하지 않았고 별도 서버를 기동하지 않았다. 배포·push 없음. +- 롤백은 아래 매핑을 역적용하고 import와 lint 경로를 함께 되돌린다. 선행 지도 분리와 사용자 변경이 있으므로 저장소 전체 reset 금지. 제거된 빈 폴더는 원래 이름으로 다시 만들 수 있다. +- 일부 외부 도구에 수동으로 저장한 파일 경로는 갱신이 필요할 수 있다. 모든 화면의 저장·삭제·지도 상호작용을 전수 실행한 것으로 보고하지 않는다. +- 읽기 전용 외부 참조 검사에서 business_components/tsconfig.json의 `../../../apps/sn/app/map/SearchModal.tsx`를 발견했다. 이 경로는 이번 변경 전에도 존재하지 않았던 경로로, 현재 지도 경로는 app/(map-layout)/map이다. 이번 이름 변경이 새로 만든 누락이 아니므로 공유 설정은 변경하지 않았다. + +## 5. 전체 파일 경로 매핑 + +기준 경로: D:/workspace/IIPBS-FRONTEND/frontend/apps/sn. 파일명뿐 아니라 부모 폴더 변경 때문에 경로가 바뀐 파일도 포함한다. + +| 이전 | 현재 | +| --- | --- | +| `app/(map-layout)/map/components/agedPipe/agedPipeExport.ts` | `app/(map-layout)/map/components/aged-pipe/aged-pipe-export.ts` | +| `app/(map-layout)/map/components/agedPipe/AgedPipeLayerSettingPanel.tsx` | `app/(map-layout)/map/components/aged-pipe/aged-pipe-layer-setting-panel.tsx` | +| `app/(map-layout)/map/components/agedPipe/AgedPipePopupPage.tsx` | `app/(map-layout)/map/components/aged-pipe/aged-pipe-popup-page.tsx` | +| `app/(map-layout)/map/components/agedPipe/agedPipeUtils.ts` | `app/(map-layout)/map/components/aged-pipe/aged-pipe-utils.ts` | +| `app/(map-layout)/map/components/agedPipeStatusByCtpv/agedPipeStatusByCtpvExport.ts` | `app/(map-layout)/map/components/aged-pipe-status-by-ctpv/aged-pipe-status-by-ctpv-export.ts` | +| `app/(map-layout)/map/components/agedPipeStatusByCtpv/AgedPipeStatusByCtpvPopupPage.tsx` | `app/(map-layout)/map/components/aged-pipe-status-by-ctpv/aged-pipe-status-by-ctpv-popup-page.tsx` | +| `app/(map-layout)/map/components/agedPipeStatusByCtpv/agedPipeStatusByCtpvUtils.ts` | `app/(map-layout)/map/components/aged-pipe-status-by-ctpv/aged-pipe-status-by-ctpv-utils.ts` | +| `app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx` | `app/(map-layout)/map/components/analysis-menu/analysis-popup-menu.tsx` | +| `app/(map-layout)/map/components/common/codeMapper.ts` | `app/(map-layout)/map/components/common/code-mapper.ts` | +| `app/(map-layout)/map/components/exmn/ExmnLayerSettingPanel.tsx` | `app/(map-layout)/map/components/exmn/exmn-layer-setting-panel.tsx` | +| `app/(map-layout)/map/components/exmn/exmnMapApi.ts` | `app/(map-layout)/map/components/exmn/exmn-map-api.ts` | +| `app/(map-layout)/map/components/exmn/exmnMapTypes.ts` | `app/(map-layout)/map/components/exmn/exmn-map-types.ts` | +| `app/(map-layout)/map/components/exmn/ExmnSurveyResultSection.tsx` | `app/(map-layout)/map/components/exmn/exmn-survey-result-section.tsx` | +| `app/(map-layout)/map/components/fldngHstry/fldngHstryClient.ts` | `app/(map-layout)/map/components/fldng-hstry/fldng-hstry-client.ts` | +| `app/(map-layout)/map/components/fldngHstry/FldngHstryDetailPanel.tsx` | `app/(map-layout)/map/components/fldng-hstry/fldng-hstry-detail-panel.tsx` | +| `app/(map-layout)/map/components/fldngHstry/FldngHstryFeatureClick.tsx` | `app/(map-layout)/map/components/fldng-hstry/fldng-hstry-feature-click.tsx` | +| `app/(map-layout)/map/components/fldngHstry/FldngHstryLayerSettingPanel.tsx` | `app/(map-layout)/map/components/fldng-hstry/fldng-hstry-layer-setting-panel.tsx` | +| `app/(map-layout)/map/components/fldngHstry/FldngHstryPopupPage.tsx` | `app/(map-layout)/map/components/fldng-hstry/fldng-hstry-popup-page.tsx` | +| `app/(map-layout)/map/components/fldngHstry/fldngHstryTypes.ts` | `app/(map-layout)/map/components/fldng-hstry/fldng-hstry-types.ts` | +| `app/(map-layout)/map/components/fldngHstry/fldngHstryUtils.ts` | `app/(map-layout)/map/components/fldng-hstry/fldng-hstry-utils.ts` | +| `app/(map-layout)/map/components/floodDngrMap/FloodDngrMapPopupPage.tsx` | `app/(map-layout)/map/components/flood-dngr-map/flood-dngr-map-popup-page.tsx` | +| `app/(map-layout)/map/components/floodDngrMap/floodDngrMapTypes.ts` | `app/(map-layout)/map/components/flood-dngr-map/flood-dngr-map-types.ts` | +| `app/(map-layout)/map/components/gisEdit/GisEditPipeSessionPopup.tsx` | `app/(map-layout)/map/components/gis-edit/gis-edit-pipe-session-popup.tsx` | +| `app/(map-layout)/map/components/gisEdit/GisEditSessionPanel.tsx` | `app/(map-layout)/map/components/gis-edit/gis-edit-session-panel.tsx` | +| `app/(map-layout)/map/components/gisEdit/GisEditToolbar.tsx` | `app/(map-layout)/map/components/gis-edit/gis-edit-toolbar.tsx` | +| `app/(map-layout)/map/components/gisEdit/gisEditTypes.ts` | `app/(map-layout)/map/components/gis-edit/gis-edit-types.ts` | +| `app/(map-layout)/map/components/gisEdit/useGisEdit.ts` | `app/(map-layout)/map/components/gis-edit/use-gis-edit.ts` | +| `app/(map-layout)/map/components/gisEdit/useGisEditCommonCodes.ts` | `app/(map-layout)/map/components/gis-edit/use-gis-edit-common-codes.ts` | +| `app/(map-layout)/map/components/gisManagement/GisManagementPopupPage.tsx` | `app/(map-layout)/map/components/gis-management/gis-management-popup-page.tsx` | +| `app/(map-layout)/map/components/gisManagement/GisUploadPreviewMap.tsx` | `app/(map-layout)/map/components/gis-management/gis-upload-preview-map.tsx` | +| `app/(map-layout)/map/components/gisManagement/useGisManagementCommonCodes.ts` | `app/(map-layout)/map/components/gis-management/use-gis-management-common-codes.ts` | +| `app/(map-layout)/map/components/inftwtResult/InftwtResultPopupPage.tsx` | `app/(map-layout)/map/components/inftwt-result/inftwt-result-popup-page.tsx` | +| `app/(map-layout)/map/components/inftwtResult/inftwtResultTypes.ts` | `app/(map-layout)/map/components/inftwt-result/inftwt-result-types.ts` | +| `app/(map-layout)/map/components/inftwtResult/useInftwtResultMap.ts` | `app/(map-layout)/map/components/inftwt-result/use-inftwt-result-map.ts` | +| `app/(map-layout)/map/components/InputSection.tsx` | `app/(map-layout)/map/components/input-section.tsx` | +| `app/(map-layout)/map/components/layerSetting/LayerSettingCard.tsx` | `app/(map-layout)/map/components/layer-setting/layer-setting-card.tsx` | +| `app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisDetailPopupPage.tsx` | `app/(map-layout)/map/components/pipe-analysis/pipe-analysis-detail-popup-page.tsx` | +| `app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisPopupPage.tsx` | `app/(map-layout)/map/components/pipe-analysis/pipe-analysis-popup-page.tsx` | +| `app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisSummaryTable.tsx` | `app/(map-layout)/map/components/pipe-analysis/pipe-analysis-summary-table.tsx` | +| `app/(map-layout)/map/components/pipeAnalysis/pipeAnalysisUtils.ts` | `app/(map-layout)/map/components/pipe-analysis/pipe-analysis-utils.ts` | +| `app/(map-layout)/map/components/popupComponent/chart/aplcnYms.ts` | `app/(map-layout)/map/components/popup-component/chart/aplcn-yms.ts` | +| `app/(map-layout)/map/components/popupComponent/chart/infilChart.tsx` | `app/(map-layout)/map/components/popup-component/chart/infil-chart.tsx` | +| `app/(map-layout)/map/components/popupComponent/chart/rdiiChart.tsx` | `app/(map-layout)/map/components/popup-component/chart/rdii-chart.tsx` | +| `app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx` | `app/(map-layout)/map/components/popup-component/feature-click-event-popup.tsx` | +| `app/(map-layout)/map/components/popupComponent/MsSwagePpl.tsx` | `app/(map-layout)/map/components/popup-component/ms-swage-ppl.tsx` | +| `app/(map-layout)/map/components/popupComponent/sewagePump.tsx` | `app/(map-layout)/map/components/popup-component/sewage-pump.tsx` | +| `app/(map-layout)/map/components/popupComponent/smh.tsx` | `app/(map-layout)/map/components/popup-component/smh.tsx` | +| `app/(map-layout)/map/components/popupComponent/Ssc.tsx` | `app/(map-layout)/map/components/popup-component/ssc.tsx` | +| `app/(map-layout)/map/components/popupComponent/stormWaterPump.tsx` | `app/(map-layout)/map/components/popup-component/storm-water-pump.tsx` | +| `app/(map-layout)/map/components/popupComponent/SwtrmtFclt.tsx` | `app/(map-layout)/map/components/popup-component/swtrmt-fclt.tsx` | +| `app/(map-layout)/map/components/popupComponent/SwtrmtPrcsZoneAnalysis.tsx` | `app/(map-layout)/map/components/popup-component/swtrmt-prcs-zone-analysis.tsx` | +| `app/(map-layout)/map/components/popupComponent/swtrmtPrcsZoneAnalysisApi.ts` | `app/(map-layout)/map/components/popup-component/swtrmt-prcs-zone-analysis-api.ts` | +| `app/(map-layout)/map/components/rdiiResult/RdiiResultPopupPage.tsx` | `app/(map-layout)/map/components/rdii-result/rdii-result-popup-page.tsx` | +| `app/(map-layout)/map/components/rdiiResult/rdiiResultTypes.ts` | `app/(map-layout)/map/components/rdii-result/rdii-result-types.ts` | +| `app/(map-layout)/map/components/rdiiResult/useRdiiResultMap.ts` | `app/(map-layout)/map/components/rdii-result/use-rdii-result-map.ts` | +| `app/(map-layout)/map/components/repairHistory/RepairHistoryPopupPage.tsx` | `app/(map-layout)/map/components/repair-history/repair-history-popup-page.tsx` | +| `app/(map-layout)/map/components/rightPanel/RightPanelBookmarkTabs.tsx` | `app/(map-layout)/map/components/right-panel/right-panel-bookmark-tabs.tsx` | +| `app/(map-layout)/map/components/sbmrgDngrMap/SbmrgDngrMapPopupPage.tsx` | `app/(map-layout)/map/components/sbmrg-dngr-map/sbmrg-dngr-map-popup-page.tsx` | +| `app/(map-layout)/map/components/sbmrgDngrMap/sbmrgDngrMapTypes.ts` | `app/(map-layout)/map/components/sbmrg-dngr-map/sbmrg-dngr-map-types.ts` | +| `app/(map-layout)/map/components/sinkhole/SinkholeDetailPanel.tsx` | `app/(map-layout)/map/components/sinkhole/sinkhole-detail-panel.tsx` | +| `app/(map-layout)/map/components/sinkhole/SinkholeLayerSettingPanel.tsx` | `app/(map-layout)/map/components/sinkhole/sinkhole-layer-setting-panel.tsx` | +| `app/(map-layout)/map/components/sinkhole/SinkholePopupPage.tsx` | `app/(map-layout)/map/components/sinkhole/sinkhole-popup-page.tsx` | +| `app/(map-layout)/map/components/sinkhole/sinkholeTypes.ts` | `app/(map-layout)/map/components/sinkhole/sinkhole-types.ts` | +| `app/(map-layout)/map/components/sinkhole/useSinkholeCommonCodes.ts` | `app/(map-layout)/map/components/sinkhole/use-sinkhole-common-codes.ts` | +| `app/(map-layout)/map/components/waterRisk/waterRiskClient.ts` | `app/(map-layout)/map/components/water-risk/water-risk-client.ts` | +| `app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage.tsx` | `app/(map-layout)/map/components/water-risk/water-risk-popup-page.tsx` | +| `app/(map-layout)/map/components/waterRisk/waterRiskTypes.ts` | `app/(map-layout)/map/components/water-risk/water-risk-types.ts` | +| `app/(map-layout)/map/searchModal/basicTab.tsx` | `app/(map-layout)/map/search-modal/basic-tab.tsx` | +| `app/(map-layout)/map/searchModal/fldngWtlvMap.tsx` | `app/(map-layout)/map/search-modal/fldng-wtlv-map.tsx` | +| `app/(map-layout)/map/searchModal/operationTab.tsx` | `app/(map-layout)/map/search-modal/operation-tab.tsx` | +| `app/(map-layout)/map/searchModal/reportTab.tsx` | `app/(map-layout)/map/search-modal/report-tab.tsx` | +| `app/(map-layout)/map/searchModal/searchModal.tsx` | `app/(map-layout)/map/search-modal/search-modal.tsx` | +| `app/(map-layout)/map/searchModal/stofcbr.tsx` | `app/(map-layout)/map/search-modal/stofcbr.tsx` | +| `app/(map-layout)/map/searchModal/swageMhl.tsx` | `app/(map-layout)/map/search-modal/swage-mhl.tsx` | +| `app/(map-layout)/map/searchModal/swagePmstn.tsx` | `app/(map-layout)/map/search-modal/swage-pmstn.tsx` | +| `app/(map-layout)/map/searchModal/swagePpl.tsx` | `app/(map-layout)/map/search-modal/swage-ppl.tsx` | +| `app/(map-layout)/map/searchModal/swagePrcsFclt.tsx` | `app/(map-layout)/map/search-modal/swage-prcs-fclt.tsx` | +| `app/(map-layout)/map/searchModal/swagePrcsZone.tsx` | `app/(map-layout)/map/search-modal/swage-prcs-zone.tsx` | +| `app/(map-layout)/map/searchModal/useBasicTab.ts` | `app/(map-layout)/map/search-modal/use-basic-tab.ts` | +| `app/(map-layout)/map/searchModal/useOperationTab.ts` | `app/(map-layout)/map/search-modal/use-operation-tab.ts` | +| `app/(map-layout)/map/searchModal/useReportTab.ts` | `app/(map-layout)/map/search-modal/use-report-tab.ts` | +| `app/(map-layout)/map/searchModal/useSearchModal.ts` | `app/(map-layout)/map/search-modal/use-search-modal.ts` | +| `app/(map-layout)/map/ui/MapControlStyles.tsx` | `app/(map-layout)/map/ui/map-control-styles.tsx` | +| `app/(map-layout)/map/ui/MapPageClient.tsx` | `app/(map-layout)/map/ui/map-page-client.tsx` | +| `app/(map-layout)/map/waterDepth/waterDepth.tsx` | `app/(map-layout)/map/water-depth/water-depth.tsx` | +| `app/(with-base-layout)/fileValidation.tsx` | `app/(with-base-layout)/file-validation.tsx` | +| `app/(with-base-layout)/ii/analysisResultView.ts` | `app/(with-base-layout)/ii/analysis-result-view.ts` | +| `app/(with-base-layout)/ii/BaseLayout.tsx` | `app/(with-base-layout)/ii/base-layout.tsx` | +| `app/(with-base-layout)/ii/useAnalysisCompletionWatcher.tsx` | `app/(with-base-layout)/ii/use-analysis-completion-watcher.tsx` | +| `app/(with-base-layout)/oper/components/SearchDatePicker.tsx` | `app/(with-base-layout)/oper/components/search-date-picker.tsx` | +| `app/(with-base-layout)/oper/dredging/components/DredgingDetailPopupPage.tsx` | `app/(with-base-layout)/oper/dredging/components/dredging-detail-popup-page.tsx` | +| `app/(with-base-layout)/oper/dredging/components/DredgingLocationMap.tsx` | `app/(with-base-layout)/oper/dredging/components/dredging-location-map.tsx` | +| `app/(with-base-layout)/oper/dredging/components/DredgingRegisterModal.tsx` | `app/(with-base-layout)/oper/dredging/components/dredging-register-modal.tsx` | +| `app/(with-base-layout)/oper/dredging/components/DredgingRegisterPopupPage.tsx` | `app/(with-base-layout)/oper/dredging/components/dredging-register-popup-page.tsx` | +| `app/(with-base-layout)/oper/dredging/lib/useDredgingCommonCodes.ts` | `app/(with-base-layout)/oper/dredging/lib/use-dredging-common-codes.ts` | +| `app/(with-base-layout)/oper/floodPre/tableModal.tsx` | `app/(with-base-layout)/oper/floodPre/table-modal.tsx` | +| `app/(with-base-layout)/oper/oldPp/headList.ts` | `app/(with-base-layout)/oper/oldPp/head-list.ts` | +| `app/(with-base-layout)/oper/oldPp/tableModal.tsx` | `app/(with-base-layout)/oper/oldPp/table-modal.tsx` | +| `app/(with-base-layout)/selectionValidation.tsx` | `app/(with-base-layout)/selection-validation.tsx` | +| `app/components/lib/common/apiHelpers.ts` | `app/components/lib/common/api-helpers.ts` | +| `app/components/lib/utils/excelToJson.ts` | `app/components/lib/utils/excel-to-json.ts` | +| `app/components/lib/utils/fileDeleteUtil.tsx` | `app/components/lib/utils/file-delete-util.tsx` | +| `app/components/lib/utils/fileDownload.ts` | `app/components/lib/utils/file-download.ts` | +| `app/components/lib/utils/fileUploadUtil.tsx` | `app/components/lib/utils/file-upload-util.tsx` | +| `app/components/lib/utils/formatDisplayValue.tsx` | `app/components/lib/utils/format-display-value.tsx` | +| `app/components/ui/ctpvSggPrcsFcltSelectBox.tsx` | `app/components/ui/ctpv-sgg-prcs-fclt-select-box.tsx` | +| `app/components/ui/EditableFormTable.tsx` | `app/components/ui/editable-form-table.tsx` | +| `app/components/ui/fileUploadForm.tsx` | `app/components/ui/file-upload-form.tsx` | +| `app/components/ui/LoadingBar/index.tsx` | `app/components/ui/loading-bar/index.tsx` | +| `app/components/ui/operFileUploadForm.tsx` | `app/components/ui/oper-file-upload-form.tsx` | +| `app/components/ui/verticalTable.tsx` | `app/components/ui/vertical-table.tsx` | +| `app/components/ui/YearRangeSelectBox.tsx` | `app/components/ui/year-range-select-box.tsx` | +| `app/store/useOnsemiroAddrStore.ts` | `app/store/use-onsemiro-addr-store.ts` | +| `app/store/useReportTabStore.ts` | `app/store/use-report-tab-store.ts` | +| `app/store/useSearchModalStore.ts` | `app/store/use-search-modal-store.ts` | +| `app/store/useStore.ts` | `app/store/use-store.ts` | +| `src/features/cfmtnBgt/api/cfmtn-bgt-api.ts` | `src/features/cfmtn-bgt/api/cfmtn-bgt-api.ts` | +| `src/features/cfmtnBgt/lib/cfmtn-bgt-excel.ts` | `src/features/cfmtn-bgt/lib/cfmtn-bgt-excel.ts` | +| `src/features/cfmtnBgt/model/cfmtn-bgt-types.ts` | `src/features/cfmtn-bgt/model/cfmtn-bgt-types.ts` | +| `src/features/cfmtnBgt/ui/CfmtnBgtPopupContent.tsx` | `src/features/cfmtn-bgt/ui/cfmtn-bgt-popup-content.tsx` | +| `src/features/ctyFldngPrvtnBiz/api/cty-fldng-prvtn-biz-api.ts` | `src/features/cty-fldng-prvtn-biz/api/cty-fldng-prvtn-biz-api.ts` | +| `src/features/ctyFldngPrvtnBiz/lib/cty-fldng-prvtn-biz-excel.ts` | `src/features/cty-fldng-prvtn-biz/lib/cty-fldng-prvtn-biz-excel.ts` | +| `src/features/ctyFldngPrvtnBiz/model/cty-fldng-prvtn-biz-types.ts` | `src/features/cty-fldng-prvtn-biz/model/cty-fldng-prvtn-biz-types.ts` | +| `src/features/ctyFldngPrvtnBiz/model/xlsx-populate-browser.d.ts` | `src/features/cty-fldng-prvtn-biz/model/xlsx-populate-browser.d.ts` | +| `src/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizFormPage.tsx` | `src/features/cty-fldng-prvtn-biz/ui/cty-fldng-prvtn-biz-form-page.tsx` | +| `src/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizListPage.tsx` | `src/features/cty-fldng-prvtn-biz/ui/cty-fldng-prvtn-biz-list-page.tsx` | +| `src/features/dredging/ui/DredgingRegistrationBody.tsx` | `src/features/dredging/ui/dredging-registration-body.tsx` | +| `src/features/exmn/lib/useExmnCommonCodes.ts` | `src/features/exmn/lib/use-exmn-common-codes.ts` | +| `src/features/exmn/ui/ExmnBusinessRegisterPopupPage.tsx` | `src/features/exmn/ui/exmn-business-register-popup-page.tsx` | +| `src/features/exmn/ui/ExmnPplUnitDetailTable.tsx` | `src/features/exmn/ui/exmn-ppl-unit-detail-table.tsx` | +| `src/features/exmn/ui/ExmnRegistrationPage.tsx` | `src/features/exmn/ui/exmn-registration-page.tsx` | +| `src/features/exmn/ui/ExmnResultRegisterPopupPage.tsx` | `src/features/exmn/ui/exmn-result-register-popup-page.tsx` | +| `src/features/exmn/ui/ExmnStandardDataTable.tsx` | `src/features/exmn/ui/exmn-standard-data-table.tsx` | +| `src/features/exmn/ui/ExmnStatusDetailPanel.tsx` | `src/features/exmn/ui/exmn-status-detail-panel.tsx` | +| `src/features/exmn/ui/ExmnStatusPage.tsx` | `src/features/exmn/ui/exmn-status-page.tsx` | +| `src/features/exmn/ui/ExmnSurveyDetailPopupPage.tsx` | `src/features/exmn/ui/exmn-survey-detail-popup-page.tsx` | +| `src/features/exmn/ui/ExmnSurveyStandardDataTable.tsx` | `src/features/exmn/ui/exmn-survey-standard-data-table.tsx` | +| `src/features/exmn/ui/TrtdstManagementPage.tsx` | `src/features/exmn/ui/trtdst-management-page.tsx` | +| `src/features/fldngPredcPrst/lib/fldng-predc-prst-excel.ts` | `src/features/fldng-predc-prst/lib/fldng-predc-prst-excel.ts` | +| `src/features/fldngPredcPrst/model/analysisTimeRange.ts` | `src/features/fldng-predc-prst/model/analysis-time-range.ts` | +| `src/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent.tsx` | `src/features/fldng-predc-prst/ui/fldng-predc-prst-popup-content.tsx` | +| `src/features/gisEdit/index.ts` | `src/features/gis-edit/index.ts` | +| `src/features/gisEdit/lib/edit-model.ts` | `src/features/gis-edit/lib/edit-model.ts` | +| `src/features/gisEdit/lib/feature-snapshot.ts` | `src/features/gis-edit/lib/feature-snapshot.ts` | +| `src/features/gisEdit/lib/geometry-mutations.ts` | `src/features/gis-edit/lib/geometry-mutations.ts` | +| `src/features/gisEdit/lib/preview-styles.ts` | `src/features/gis-edit/lib/preview-styles.ts` | +| `src/features/gisEdit/lib/vertex-handles.ts` | `src/features/gis-edit/lib/vertex-handles.ts` | +| `src/features/gisEdit/model/types.ts` | `src/features/gis-edit/model/types.ts` | +| `src/features/gisEdit/model/vertex-handles.ts` | `src/features/gis-edit/model/vertex-handles.ts` | +| `src/features/gisManagement/api/gis-download.ts` | `src/features/gis-management/api/gis-download.ts` | +| `src/features/gisManagement/index.ts` | `src/features/gis-management/index.ts` | +| `src/features/gisManagement/lib/popup-refresh.ts` | `src/features/gis-management/lib/popup-refresh.ts` | +| `src/features/gisManagement/lib/upload-display.ts` | `src/features/gis-management/lib/upload-display.ts` | +| `src/features/gisManagement/model/upload-retry-guard.ts` | `src/features/gis-management/model/upload-retry-guard.ts` | +| `src/features/gisManagement/model/upload-types.ts` | `src/features/gis-management/model/upload-types.ts` | +| `src/features/mapLayers/index.ts` | `src/features/map-layers/index.ts` | +| `src/features/mapLayers/lib/flow-direction-listener.ts` | `src/features/map-layers/lib/flow-direction-listener.ts` | +| `src/features/mapLayers/lib/flow-direction-styles.ts` | `src/features/map-layers/lib/flow-direction-styles.ts` | +| `src/features/mapLayers/lib/layer-info.ts` | `src/features/map-layers/lib/layer-info.ts` | +| `src/features/obsltPpl/api/obslt-ppl-api.ts` | `src/features/obslt-ppl/api/obslt-ppl-api.ts` | +| `src/features/obsltPpl/lib/obslt-ppl-excel.ts` | `src/features/obslt-ppl/lib/obslt-ppl-excel.ts` | +| `src/features/obsltPpl/lib/obslt-ppl-region.ts` | `src/features/obslt-ppl/lib/obslt-ppl-region.ts` | +| `src/features/obsltPpl/model/obslt-ppl-types.ts` | `src/features/obslt-ppl/model/obslt-ppl-types.ts` | +| `src/features/obsltPpl/ui/ObsltPplFormPage.tsx` | `src/features/obslt-ppl/ui/obslt-ppl-form-page.tsx` | +| `src/features/obsltPpl/ui/ObsltPplListPage.tsx` | `src/features/obslt-ppl/ui/obslt-ppl-list-page.tsx` | +| `src/features/windowPopup/index.ts` | `src/features/window-popup/index.ts` | +| `src/features/windowPopup/lib/openWindowPopup.ts` | `src/features/window-popup/lib/open-window-popup.ts` | +| `src/features/windowPopup/lib/popupMessage.ts` | `src/features/window-popup/lib/popup-message.ts` | +| `src/features/windowPopup/model/popupDefinitions.ts` | `src/features/window-popup/model/popup-definitions.ts` | +| `src/features/windowPopup/model/popupTheme.ts` | `src/features/window-popup/model/popup-theme.ts` | +| `src/features/windowPopup/model/types.ts` | `src/features/window-popup/model/types.ts` | +| `src/features/windowPopup/registry.tsx` | `src/features/window-popup/registry.tsx` | +| `src/features/windowPopup/ui/AgedPipePopupContent.tsx` | `src/features/window-popup/ui/aged-pipe-popup-content.tsx` | +| `src/features/windowPopup/ui/AgedPipeStatusByCtpvPopupContent.tsx` | `src/features/window-popup/ui/aged-pipe-status-by-ctpv-popup-content.tsx` | +| `src/features/windowPopup/ui/DredgingEditPopupContent.tsx` | `src/features/window-popup/ui/dredging-edit-popup-content.tsx` | +| `src/features/windowPopup/ui/dredgingPopupRecord.ts` | `src/features/window-popup/ui/dredging-popup-record.ts` | +| `src/features/windowPopup/ui/DredgingRegisterPopupContent.tsx` | `src/features/window-popup/ui/dredging-register-popup-content.tsx` | +| `src/features/windowPopup/ui/ExmnBusinessRegisterPopupContent.tsx` | `src/features/window-popup/ui/exmn-business-register-popup-content.tsx` | +| `src/features/windowPopup/ui/ExmnPipeReportPopupContent.tsx` | `src/features/window-popup/ui/exmn-pipe-report-popup-content.tsx` | +| `src/features/windowPopup/ui/ExmnResultRegisterPopupContent.tsx` | `src/features/window-popup/ui/exmn-result-register-popup-content.tsx` | +| `src/features/windowPopup/ui/ExmnSurveyDetailPopupContent.tsx` | `src/features/window-popup/ui/exmn-survey-detail-popup-content.tsx` | +| `src/features/windowPopup/ui/FeaturePlaceholderPopupContent.tsx` | `src/features/window-popup/ui/feature-placeholder-popup-content.tsx` | +| `src/features/windowPopup/ui/FldngHstryPopupContent.tsx` | `src/features/window-popup/ui/fldng-hstry-popup-content.tsx` | +| `src/features/windowPopup/ui/FloodDngrMapPopupContent.tsx` | `src/features/window-popup/ui/flood-dngr-map-popup-content.tsx` | +| `src/features/windowPopup/ui/FloodPreTablePopupContent.tsx` | `src/features/window-popup/ui/flood-pre-table-popup-content.tsx` | +| `src/features/windowPopup/ui/FloodPreUploadPopupContent.tsx` | `src/features/window-popup/ui/flood-pre-upload-popup-content.tsx` | +| `src/features/windowPopup/ui/GisEditSessionPopupContent.tsx` | `src/features/window-popup/ui/gis-edit-session-popup-content.tsx` | +| `src/features/windowPopup/ui/GisManagementPopupContent.tsx` | `src/features/window-popup/ui/gis-management-popup-content.tsx` | +| `src/features/windowPopup/ui/InfilChartPopupContent.tsx` | `src/features/window-popup/ui/infil-chart-popup-content.tsx` | +| `src/features/windowPopup/ui/InftwtResultPopupContent.tsx` | `src/features/window-popup/ui/inftwt-result-popup-content.tsx` | +| `src/features/windowPopup/ui/MapPopupDataTable.tsx` | `src/features/window-popup/ui/map-popup-data-table.tsx` | +| `src/features/windowPopup/ui/OldPpTablePopupContent.tsx` | `src/features/window-popup/ui/old-pp-table-popup-content.tsx` | +| `src/features/windowPopup/ui/OldPpUploadPopupContent.tsx` | `src/features/window-popup/ui/old-pp-upload-popup-content.tsx` | +| `src/features/windowPopup/ui/PipeAnalysisDetailPopupContent.tsx` | `src/features/window-popup/ui/pipe-analysis-detail-popup-content.tsx` | +| `src/features/windowPopup/ui/PipeAnalysisPopupContent.tsx` | `src/features/window-popup/ui/pipe-analysis-popup-content.tsx` | +| `src/features/windowPopup/ui/popupContentUtils.ts` | `src/features/window-popup/ui/popup-content-utils.ts` | +| `src/features/windowPopup/ui/PopupMissingParams.tsx` | `src/features/window-popup/ui/popup-missing-params.tsx` | +| `src/features/windowPopup/ui/PopupSearchPanel.tsx` | `src/features/window-popup/ui/popup-search-panel.tsx` | +| `src/features/windowPopup/ui/PopupThemeSync.tsx` | `src/features/window-popup/ui/popup-theme-sync.tsx` | +| `src/features/windowPopup/ui/RdiiChartPopupContent.tsx` | `src/features/window-popup/ui/rdii-chart-popup-content.tsx` | +| `src/features/windowPopup/ui/RdiiResultPopupContent.tsx` | `src/features/window-popup/ui/rdii-result-popup-content.tsx` | +| `src/features/windowPopup/ui/RepairHistoryPopupContent.tsx` | `src/features/window-popup/ui/repair-history-popup-content.tsx` | +| `src/features/windowPopup/ui/ReportFilesPopupContent.tsx` | `src/features/window-popup/ui/report-files-popup-content.tsx` | +| `src/features/windowPopup/ui/ReportTabPopupContent.tsx` | `src/features/window-popup/ui/report-tab-popup-content.tsx` | +| `src/features/windowPopup/ui/SbmrgDngrMapPopupContent.tsx` | `src/features/window-popup/ui/sbmrg-dngr-map-popup-content.tsx` | +| `src/features/windowPopup/ui/SearchModalPopupContent.tsx` | `src/features/window-popup/ui/search-modal-popup-content.tsx` | +| `src/features/windowPopup/ui/SinkholePopupContent.tsx` | `src/features/window-popup/ui/sinkhole-popup-content.tsx` | +| `src/features/windowPopup/ui/SscDetailPopupContent.tsx` | `src/features/window-popup/ui/ssc-detail-popup-content.tsx` | +| `src/features/windowPopup/ui/WaterRiskPopupContent.tsx` | `src/features/window-popup/ui/water-risk-popup-content.tsx` | +| `src/features/windowPopup/ui/WindowPopupRenderer.tsx` | `src/features/window-popup/ui/window-popup-renderer.tsx` | diff --git a/apps/sn/docs/작업계획/SN-패키지구조진단/009-fe-SN-지도검색바-hydration보완.md b/apps/sn/docs/작업계획/SN-패키지구조진단/009-fe-SN-지도검색바-hydration보완.md new file mode 100644 index 000000000..764850fe5 --- /dev/null +++ b/apps/sn/docs/작업계획/SN-패키지구조진단/009-fe-SN-지도검색바-hydration보완.md @@ -0,0 +1,37 @@ +# 지도 검색바 Radix hydration 보완 + +작업일: 2026-09-29 + +## 1. 현상과 확인 범위 + +- 사용자 로그: 서버와 클라이언트의 SelectTrigger aria-controls 값이 서로 다른 Radix ID로 생성됨. +- 로그의 `flex h-[44px]`는 공통 ProcSearchBar의 integrated 검색바 마크업과 일치한다. SN 지도는 해당 검색바를 시도/시군구/하수처리시설 선택에 사용한다. +- 확인한 로컬 브라우저에서는 수정 전 동일 경고가 재현되지 않았다. 초기 렌더 트리가 달라진 세부 조건이나 최근 파일명 변경이 직접 원인이라는 사실은 확정하지 않았다. +- SSR Client Component에서도 서버 HTML과 최초 클라이언트 렌더가 일치해야 한다. useEffect 이후 Client 전용 UI를 렌더하는 방식은 [Next.js 공식 hydration 오류 안내](https://nextjs.org/docs/messages/react-hydration-error)의 대응 방법이다. + +## 2. 변경 + +| 파일 | 변경 내용 | +| --- | --- | +| app/(map-layout)/map/ui/map-search-bar.tsx | isMounted=false로 시작해 서버/최초 클라이언트에서 동일한 44×656 검색바 자리표시자 제공. effect 이후 기존 ProcSearchBar 렌더 | +| app/(map-layout)/map/ui/map-page-client.tsx | 기존 ProcSearchBar를 SN 전용 MapSearchBar 경계로 교체. 전달 props 유지 | + +- 경고를 숨기는 suppressHydrationWarning이나 고정 aria-controls 덮어쓰기는 사용하지 않는다. +- 지도 전체 SSR을 끄지 않고, 해당 브라우저 검색 컨트롤의 hydration 비교 대상만 제거한다. +- 공통 Select/ProcSearchBar, 타 앱, API·URL·DB·환경·인증·서버 프로세스는 수정하지 않는다. +- 검색바의 초기 조회 및 외부 이벤트 listener는 마운트 후 한 번 더 렌더되는 시점에 시작한다. 지도 준비 전에 화면이 먼저 표시되는 시간에는 자리표시자가 보인다. 검색 조건·업무 데이터 계약은 유지한다. + +## 3. 검증 + +- 실제 `/map` 서버 HTTP 200. 서버 HTML의 검색바 자리표시자 존재, role=combobox 0개, Radix aria-controls 0개를 확인했다. +- 수정 후 브라우저 새로고침 2회 수행. 초기 자리표시자 후 실제 검색바와 지도·레이어 트리가 나타나는 흐름을 확인했다. +- 시도 드롭다운을 열어 옵션 표출, 대전광역시 선택 후 선택값·시군구·처리시설 연동 UI 유지 확인. +- 수정 후 시각 기준 콘솔 hydration 관련 오류 0건. TypeScript 검사 `pnpm exec tsc --noEmit --incremental false --pretty false` 통과(exit 0). 수정 전 미재현이므로 특정 재현 조건을 찾아 제거한 회귀 테스트로 표현하지 않는다. +- 점검 시작 시 CMS 사업코드/레이어 API 조회 실패 로그도 관측했으나 hydration ID와 별개이며, 이 변경으로 해당 서버 오류까지 수정했다고 주장하지 않는다. +- 사용자 dev 서버가 실행 중이므로 next build·재시작·.next 삭제는 수행하지 않는다. + +## 4. 롤백·정리·남은 위험 + +- 기존 ProcSearchBar import/JSX를 복구하고 이번 wrapper만 제거하면 롤백된다. 앞선 지도 분리·네이밍 변경은 그대로 유지한다. +- 테스트용 파일·캡처·원시 로그를 만들지 않았다(삭제 대상 0개). 제품 wrapper와 결과 문서는 유지한다. 별도 서버 기동 없음. 검증용 브라우저 탭 종료. +- 직접 외부 조사 진입 이벤트가 매우 이르게 전송되는 경우의 전체 end-to-end 검증은 미수행이다. 현재 mapReady 300ms 감지와 부모 effect 기반 흐름은 변경하지 않았다. diff --git a/apps/sn/docs/작업계획/SN-패키지구조진단/010-fe-SN-API상태소유권정리-구현결과.md b/apps/sn/docs/작업계획/SN-패키지구조진단/010-fe-SN-API상태소유권정리-구현결과.md new file mode 100644 index 000000000..4f99a2180 --- /dev/null +++ b/apps/sn/docs/작업계획/SN-패키지구조진단/010-fe-SN-API상태소유권정리-구현결과.md @@ -0,0 +1,101 @@ +# 2번 API·상태 소유권 정리 + +작업일: 2026-09-29. 범위: SN의 공통 API 진입점·인증 정책 연결·스토어 소유권. 1번의 미검증 지도/편집 시나리오는 이번 완료 범위와 별개로 유지한다. + +## 1. 목적·근거·변경 범위 + +- 사용자 지시: 2번 진행. 기준은 006 설계계획의 API·상태 소유권 항목이다. +- 원본 재확인: 요구사항정의서 기능 추적표 84행 SFR-010-012·96행 SFR-012-001, 화면설계서 36쪽 SN_MN010303·53쪽 SN_MN040201. 이번 구조 변경은 시설 조회·준설 업무 의미를 변경하지 않는다. +- SN 기준문서 003 API사용·005 상태관리정책 및 현재 네이밍 규칙을 적용했다. 모든 신규 파일은 kebab-case다. +- 수정은 apps/sn 내부만 수행했다. BE/API URL/응답·DB·공유 패키지·다른 앱·의존성 버전·환경·배포 미변경. +- 현재 working tree의 앞선 지도 분리·kebab-case·hydration 변경을 유지했다. 전체 reset이나 사용자 서버 재시작은 하지 않았다. + +## 2. API 책임 변경 + +| 이전 | 현재 소유 위치 | 보존한 계약 | +| --- | --- | --- | +| app/components/lib/api/index.ts의 JSON 함수 | shared/api/sewerage-api.ts | BASE URL, GET/POST wrapper 해제, PUT wrapper/raw, raw JSON, DELETE 204=true | +| 같은 파일의 Blob·multipart | shared/api/file-api.ts | 파일명 헤더, Blob·headers 반환, multipart FormData·10분 timeout·기존 오류 메시지 | +| 같은 파일의 CMS/DC/GeoServer | shared/api/external-service-api.ts | 서비스 prefix·원문 JSON/Blob·X-Site-Id | +| 통신 설정·401 직접 인증 참조 | shared/api/service-request-context.ts + app/providers/http-auth-bridge.tsx | shared는 401 통지만 발행. 앱이 인증 정책을 연결 | +| HTTP 유틸 안의 인증 정책 | entities/auth/lib/http-unauthorized.ts | 유효 토큰 401은 강제 로그아웃하지 않음, 만료/미존재 토큰 정리, 로그인 화면 중복 이동 방지 | +| HTTP 유틸 안의 React 쿠키 훅 | entities/auth/lib/use-cookie-auth.ts | 기존 사업 첨부 화면의 Authorization 쿠키 읽기 | +| app/components/lib/common/api-helpers.ts | entities/code/api/common-code-api.ts | 코드 배열 검증·문자열 trim·부모 코드·빈 코드 제거·searchCmmnCd | +| generic searchData | shared/api/search-page.ts | 검색 query 생성·content/totalElements 변환 | +| app/components/lib/api/attachment | entities/attachment/api 및 model | 기존 첨부 서비스·DTO, 동작 변경 없음 | +| shared/api index의 client export | shared/api/client.ts 직접 import | Axios Client 초기화는 실제 소비자가 명시적으로 import | + +`shared/api/index.ts`는 순수 HTTP 함수/오류 타입만 export한다. client.ts의 cookie import도 shared/lib barrel이 아닌 cookie 모듈을 직접 참조한다. HTTP transport 본문은 변경하지 않았다. + +```text +feature/domain API → shared 통신 adapter → 기존 서비스 URL + └─ 401 통지 +앱의 HttpAuthBridge → 기존 인증 만료 판정·토큰 정리·로그인 이동 +``` + +HttpAuthBridge는 AppProvider에서 업무 컴포넌트보다 앞에 둔다. 마운트 시 listener 등록, 해제 시 정확히 제거하며, 현재 화면 요청은 effect/사용자 동작에서 시작하는 전제를 유지한다. AppProvider 밖에서 독립적으로 호출하는 검증/서버 코드에는 브라우저 로그인 이동 정책이 자동으로 설치되지 않는 경계가 있다. SSR에서는 기존과 동일하게 브라우저 이동을 수행하지 않는다. + +## 3. 상태 소유권 변경 + +| 이전 파일 | 현재 파일 | 판단 | +| --- | --- | --- | +| app/store/use-store.ts | entities/reference-data/model/reference-data-store.ts | GeoServer 시도/시군구와 기존 사업코드 상태의 단일 원본 | +| app/store/index.ts | entities/reference-data/api/reference-data-api.ts | 기존 기준정보 조회/변환, 시도 정렬과 AQAAA 제외 유지 | +| app/store/use-onsemiro-addr-store.ts | entities/reference-data/model/onsemiro-address-store.ts | 온세미로는 다른 원천 계약이므로 별도 store 유지 | +| app/store/use-search-modal-store.ts | features/map-search/model/search-store.ts | 검색 draft·applied 조건/페이지/선택/리셋의 기능 소유권 | +| app/store/use-report-tab-store.ts | features/map-search/model/report-store.ts | 보고서 트리·파일 조회/상태의 기능 소유권 | +| 화면 훅이 export하던 보고서 타입 | features/map-search/model/report-types.ts | store가 화면 hook의 타입을 역참조하지 않음 | +| src/store/tab-store.ts | entities/menu/model/tab-store.ts | 탭은 메뉴·권한 도메인으로 배치. 인증에서 app을 역참조하지 않음 | +| shared/lib의 통계 메뉴·게시판 메뉴 판별 | entities/menu/lib | 메뉴 의미를 가진 로직을 공통 기술 기반에서 분리 | +| shared/lib/use-oper-business-common-codes.ts | entities/business/lib | 두 사업 기능이 공유하는 코드 표시 정책을 도메인으로 이동 | + +설계에서 tab store 위치는 app/model 후보였지만, 실제 auth-service의 사용자 전환 초기화 호출이 존재했다. 이를 app/model로 옮겨 역방향 의존을 만들지 않고 메뉴 도메인에 두었다. persist key `st-tab-store`, version=1, 최대 탭 수, 저장 정규화, 사용자 변경 시 clear 호출은 그대로다. + +### 미사용 중복 스토어 제거 + +- shared/store/data-store.ts는 CMS ADSTDSTRCD를 조회하는 다른 형태의 스토어였다. 실제 화면은 GeoServer 기반 reference-data store를 사용했다. +- 코드 검색에서 생성·export·Provider context 선언 외 소비자가 없음을 확인해 data-store.ts와 Provider의 미사용 인스턴스를 제거했다. +- 서로 다른 원천의 지역 데이터를 같은 값으로 가정하여 합치지 않았다. 사용자/메뉴/첨부/알림의 기존 Context store는 유지한다. +- React Query 전환이나 전역 store의 request별 재생성을 이번 이동에 섞지 않았다. 기존 캐시/상태 수명·draft와 applied 의미를 보존한다. + +## 4. 참조 이관·정리 + +- AST module resolver를 사용해 이동 11개 파일과 새 책임별 adapter를 향하도록 103개 소스의 import/export를 갱신했다. 추가로 Provider·공통 barrel·보고서 타입 선언을 정리했다. +- 이전 app/components/lib/api/index.ts, api-helpers.ts 및 미사용 data-store.ts는 삭제했다. 호환 재export 껍데기는 남기지 않았다. +- app/store, src/store, app/components/lib/api/attachment, api, common의 비어 있는 디렉터리 5개를 검증 후 비재귀 삭제했다. +- 이전 API/helper/@/store import 참조 0건. shared 41개 소스의 모듈 해석 결과 app/entities/features 역방향 의존 0건. +- tsconfig의 기존 @/store alias 설정 자체는 이번에 변경하지 않았다. 호출부는 모두 새 소유 경로로 이관했고 새 코드에서 옛 alias 사용을 금지한다. 환경/설정 정리는 별도 범위다. + +## 5. 검증 결과 + +| 검증 | 결과 | +| --- | --- | +| TypeScript | pnpm exec tsc --noEmit --incremental false --pretty false 통과 | +| shared 의존 방향 | 41개 파일 검사, 상위 계층 역참조 0건 | +| 스토어 본문 대조 | 이동한 store 5개 모두 import를 제외한 AST 본문이 기존 Git 원본과 동일 | +| JSON/raw | wrapper 해제, raw 유지, 상대/절대 URL, 헤더·AbortSignal 보존 검증 통과 | +| 401 정책 | 유효 토큰 유지, 만료 시 정리/로그인 이동, 로그인 화면 재이동 방지, listener cleanup 검증 통과 | +| 순수 import | shared/api import로 Axios client 초기화 모듈이 로드되지 않음 확인 | +| 외부 서비스/파일 | CMS·GeoServer·DC prefix 및 raw/Blob, Content-Disposition 파일명, multipart 600000ms·헤더·FormData 보존 확인 | +| 코드/검색 상태 | CMS trim·배열 계약, draft 변경 시 applied 조건 보존, reset 시 applied=null·page=1 확인 | +| 로그인·지도 | 실제 localhost 로그인 후 지도·레이어 관리·시도/시군구/시설 검색바 표시 확인 | +| 정보검색 팝업 | 관경 450~450 입력 중 2페이지(11~20) 이동, 조회 적용 시 첫 페이지로 돌아오는 흐름 확인 | +| 정보검색 조회 결과 | 조회 적용 후 첫 페이지 관경 값 450 표시 확인 | +| 준설관리 | 실제 목록 로딩 및 2페이지의 순번 11·실제 관로 값, 다음/마지막 페이지 비활성 확인 | +| production build | 사용자 dev 서버 실행 중이므로 미수행 | + +검증 스크립트는 메모리 fetch/axios/window mock으로 HTTP 계약과 상태를 검사하며 실서버에 쓰기 요청을 보내지 않았다. 최초 검증 스크립트 자체의 괄호 오류를 고친 뒤 최종 통과했다. mock 검증을 실제 로그아웃·파일 업로드·서버 오류 강제 발생 검증으로 확대하지 않는다. + +## 6. 남은 범위·롤백 + +- 일부 지도/레거시 화면이 직접 소유한 업무 URL을 모두 별도 feature API 파일로 옮긴 것은 아니다. 이번에는 공통 transport/API helper와 상태 소유권을 우선 확정했다. 시설 상세·조사 팝업 등의 전용 endpoint 이동은 설계 3~6번의 화면 분리와 함께 진행한다. +- 인증 실시간 만료·다중 탭·전체 첨부 업로드/삭제·모든 Excel 다운로드를 이번 실제 브라우저에서 전수 실행하지 않았다. 1번의 부모·자식 지도 popup/편집 검증도 완료로 변경하지 않는다. +- 파일 업로드와 외부 서비스의 기존 오류 반환 차이는 보존했다. 통일된 오류 정책으로 추가 변경하려면 별도 계약 검증이 필요하다. +- 롤백은 새 adapter/bridge와 11개 이동 파일·모든 소비자 import·Provider/barrel 변경을 함께 되돌린다. 기존 정리 이전 단일 API 파일과 state를 복원하되, 앞선 케밥케이스/지도 변경을 포함한 전체 reset은 하지 않는다. +- 현재 사용자 서버 3002/8883은 유지하고 신규 서버는 실행하지 않았다. 배포·push 없음. 최종 정리 결과는 아래에 기록한다. + +## 7. 최종 정리 + +- 임시 변환 스크립트·계약 검증 스크립트 2개는 결과 요약 후 삭제했다. 임시 원시 로그·이미지·API 응답 파일은 생성하지 않았다. +- 제품의 이전 API/helper/미사용 data-store 소스 3개와 이동 후 빈 디렉터리 5개는 정리했다. 이전 파일은 Git 이력과 이동/분리 기록을 기준으로 복구 가능하나 사용자 변경을 포함한 일괄 복구는 하지 않는다. +- 제품 신규 모듈과 최종 문서는 보존한다. 임시 스크립트는 별도 백업하지 않았다. 검증용 탭도 종료했다. diff --git a/apps/sn/docs/작업계획/SN-패키지구조진단/011-fe-SN-잔여구조개선-구현및검증결과.md b/apps/sn/docs/작업계획/SN-패키지구조진단/011-fe-SN-잔여구조개선-구현및검증결과.md new file mode 100644 index 000000000..906b690c3 --- /dev/null +++ b/apps/sn/docs/작업계획/SN-패키지구조진단/011-fe-SN-잔여구조개선-구현및검증결과.md @@ -0,0 +1,127 @@ +# SN 잔여 구조 개선 구현·검증 결과 + +후속 안내: 이 문서는 첫 배치의 중간 기록이다. 아래 잔여 항목에 대한 후속 구현·최종 검증·의도적 예외는 [012 최종 결과](./012-fe-SN-잔여구조개선-최종결과.md)를 따른다. + +작성일: 2026-09-29. **중간 결과이며 1~8번 전체 완료 보고가 아니다.** + +## 1. 목적·범위·근거 + +- 사용자 승인 설계 006의 잔여 항목을 진행했다. 화면 URL, API URL, DB 계약을 유지하고 조회·표·시설 상세·지도 레이어의 책임을 분리한다. +- 수정 범위는 SN 내부다. 타 앱과 공유 UI는 사용처를 읽기 전용으로 조사했다. 백엔드, DB, Java, Docker, 환경값, 의존성 버전, 개발서버 배포는 변경하지 않았다. +- 원본 확인: 요구사항정의서 `3. (기능)요구사항정의 및 추적표` 84행 SFR-010-012, 96행 SFR-012-001, 화면설계서 36쪽 SN_MN010303 및 53쪽 SN_MN040201. 지도 편집·유하방향의 앞선 확인 근거는 007의 34·37쪽 추적을 함께 따른다. 모듈 분리 방식은 사용자 승인 구조 개선안이며 원본 기능 요구사항 자체가 아니다. +- 작업 전부터 존재하던 변경을 보존했다. 전체 worktree diff에는 이전 케밥케이스 변경 등이 포함되므로 이번 변경량과 혼동하지 않는다. + +## 2. 단계별 실제 상태 + +| 단계 | 이번 적용 | 상태·남은 범위 | +| --- | --- | --- | +| 1 지도 | 유하방향 요청·캐시·이동 이벤트를 `use-flow-direction-layer`로, 통수위험 레이어·카드·팝업을 `use-water-risk-layer`로 분리 | 부분 완료. GIS 편집 상호작용과 침하·침수·조사 레이어의 잔여 조립 로직은 map-page-client에 남음 | +| 2 API·상태 | 앞선 010의 API 경계·상태 소유권 유지 | 앞선 구현 결과 참조 | +| 3 조사 상세 | 조회 hook, 검색 UI, 표 메타데이터, Excel API adapter 분리 | 구현 및 대표 조회 검증 완료. 실제 Excel 파일 내용 재검증은 미수행 | +| 4 시설 상세 | 상세 패널을 feature로 이동, 선택 resolver와 자체입력 API 분리, 늦은 응답의 상태 반영 차단 | 구현 완료 범위만 반영. 모든 시설·연속 클릭·저장 브라우저 검증은 남음 | +| 5 컬럼·코드 | 40개 기존 정의를 9개 entity 모듈로 분리. 확인된 CMS 5개 그룹 hook 및 검색·준설 목록 연결 | 부분 완료. 기존 상세·편집·역변환 등 일부 정적 사전 소비처 유지, 펌프 코드 의미 차이 미확정 | +| 6 준설 | route를 진입점으로 축소, feature UI·조회 hook·표 모델로 이동, 조회/다운로드 로딩 분리 | 구현 및 목록 페이징 검증 완료. 실데이터 등록·수정·삭제·업로드는 미수행 | +| 7 공유 UI | SN/WR/FO/DM 직접 사용처와 서로 다른 계약 확인 | 현행 유지 판정. 공통 API 변경·강제 통합 없음. 전 앱 build/청크 분석은 미수행 | +| 8 정리 | 이동 후 빈 폴더 및 중복 wrapper 정리, 임시 검증 파일 정리 | 이번 배치 정리 완료. 전체 저장소 불용 파일 전수 삭제를 뜻하지 않음 | + +## 3. 주요 수정 코드와 이유 + +### 조사 상세 + +- `src/features/exmn/lib/use-exmn-detail-data.ts`: 시설 페이지·본단위구간 조회와 로딩·오류·전체 합계를 소유한다. 모드별 기존 API와 active 플래그를 유지하여 과거 응답이 현재 조회를 덮어쓰지 않는다. +- `ui/exmn-detail-search-panel.tsx`: draft 조건을 표시하고 변경/조회 명령만 전달한다. applied 조건과 페이지 초기화의 원본은 부모에 둔다. +- `model/exmn-detail-view.ts`: 표 표시 메타데이터와 형식 변환을 분리했다. 본단위구간의 그룹 수와 실제 행 수는 별개로 유지한다. +- `api/exmn-detail-export.ts`: 현재 페이지 배열이 아니라 기존 applied 조건 전체를 작업형 Excel API에 전달한다. + +```tsx +const detail = useExmnDetailData(queryOptions); +// 화면은 결과를 조립하고 조회 hook이 요청·오류·전체 합계를 소유한다. +``` + +위 코드는 책임 분리를 설명하는 축약 예시이며 실제 호출 인자 목록은 구현 파일을 따른다. + +### 시설 상세·컬럼 + +- 기존 `map/components/common/table.ts`의 40개 정의를 `entities/facility/model`의 관로/처리시설/처리구역/침수/맨홀/펌프/토실/형식/코드 사전 9개 모듈로 분리했다. 기존 값 자체는 비교 검증으로 보존했다. +- `features/facility-detail/model/resolve-facility-selection.ts`: 지도 피처와 목록 선택에서 기존 레이어·필드 매핑을 순수 결과 `{ layerName, patch }`로 반환한다. +- `features/facility-detail/api/facility-input-api.ts`: 기존 시설별 API 경로와 `cqlFiter` 서버 파라미터를 유지한다. 미지원 시설은 잘못된 URL을 호출하기 전에 차단한다. +- `feature-click-event-popup.tsx`: 선택 변경 또는 unmount 후 과거 응답의 상태 반영을 차단한다. 네트워크 요청 자체를 모두 취소하는 구현과는 구별한다. + +```tsx +useEffect(() => { + let active = true; + const commitFetchData: typeof setFetchData = (value) => { + if (active) setFetchData(value); + }; + // 기존 조회 결과를 commitFetchData 등 보호된 setter에 전달 + return () => { active = false; }; +}, [featureId, processedData, mngInstNameMap]); +``` + +### 지도·준설 + +- `features/map-layers/lib/use-flow-direction-layer.ts`: 트리 선택, BBOX 재조회, 캐시, 이동 이벤트를 한 소유자에 모았다. 화면 이탈 시 요청 번호 무효화·AbortController 취소·자신의 레이어 제거를 수행한다. 목록 페이징 값을 지도 요청에 추가하지 않았다. +- `app/(map-layout)/map/lib/use-water-risk-layer.ts`: 통수위험 결과의 레이어·선택 정보 카드·메시지·창 종료 감시를 담당한다. 다른 상세 패널을 닫는 명령은 부모 callback으로 전달한다. +- 준설 `page.tsx`는 feature의 목록 화면을 불러오는 진입점이다. 등록/상세/지도 UI도 동일 feature 아래로 이동했고 기존 route lib wrapper 7개를 제거했다. +- `use-dredging-list-data.ts`는 목록 조회만 소유한다. `isQueryLoading || isDownloading`으로 표시 상태를 합쳐 다운로드 완료가 진행 중 조회 로딩을 지우지 않도록 했다. + +## 4. CMS 코드 정책·예외 + +| 그룹 | 의미 | 이번 연결 | +| --- | --- | --- | +| OGC004 | 관로 재질 | 기본시설 검색·준설 목록 | +| OGC011 | 관로 용도 | 기본시설 검색 | +| OGC002 | 관로 형상 | 기본시설 검색 | +| OGC008 | 맨홀 뚜껑 | 기본시설 검색 | +| OGC007 | 맨홀 용도 | 기본시설 검색 | + +React Query key는 `sn / facility-code / 그룹`이며 staleTime은 5분, retry는 false다. 초기 조회 전·오류·빈 그룹은 기존 사전을 사용한다. 5분마다 자동 polling한다는 뜻은 아니다. 기존 사전의 저장 코드값은 변경하지 않았다. + +상세/편집/역변환의 정적 사전 소비처까지 모두 전환한 것은 아니다. 펌프 OGC021은 이전 감사에서 2개 의미 차이가 기록되어 자동 전환하지 않았다. FE 표시명과 서버 Excel 표시명의 전 그룹 실파일 대조도 남았다. + +## 5. 공유 컴포넌트 유지 근거 + +| 컴포넌트 | 직접 import 확인 | 판단 | +| --- | --- | --- | +| StandardDataTable / UiTableSort | SN 15 / 14 | TanStack table 외부 주입 및 정렬 계약 유지 | +| UiTable | WR 35, FO 18 | headlist·data/count·page callback 계약. StandardDataTable과 즉시 병합하지 않음 | +| SelectBox | SN 13, WR 41, FO 13, DM 1 | 공유 영향이 커 기존 props 유지 | +| Form / FormField / FormUi | SN Form 1, WR 13/16/15, DM FormField 1/FormUi 2 | RHF 연결과 입력/배치 역할이 다르므로 중복 이름만으로 삭제하지 않음 | +| StandardFormField / StandardFormSection | SN 각 9 | SN의 섹션 배치 역할 유지 | +| table_div의 ContentsDiv | FO/WR 실제 사용 확인 | TableDiv라는 미사용 컴포넌트로 오인하여 삭제하지 않음 | + +위 수치는 소스의 직접 import 수이며 실제 런타임 사용자 수나 모든 간접 소비자 수가 아니다. 다른 앱은 수정하지 않았다. 공유 SearchBar의 서비스 의존 분리와 전체 번들 영향 검증은 별도 잔여 항목이다. + +## 6. 실제 검증 + +| 메뉴 > 화면 | 결과 | 한계 | +| --- | --- | --- | +| 하수관망도 > 초기화·재진입 | 지도·검색·레이어 트리 표시 확인 | 전체 레이어 도형 전수 검증 아님 | +| 하수관망도 > 유하방향 | 체크 ON 및 줌15 미만 안내 확인 | 실제 화살표 도형과 확대/이동 재조회는 미검증 | +| 하수관망도 > GIS 편집 | 진입 시 세션0건·저장 비활성, 취소 시 도구 닫힘 확인 | 추가/이동/꼭지점/삭제/저장 미수행 | +| 조사관리 > 관로조사 상세 팝업 | 덕풍처리분구 3건·합계82.9m 표시 확인 | 창 메시지 기반 부모 지도 강조 미검증 | +| 조사관리 > 본단위구간 상세 | 모드 변경 4개 구간, 관로1060 검색 2개 구간 확인 | 실파일 Excel 다운로드 미검증 | +| 유지관리 > 준설 목록 | 1페이지1~10, 2페이지11 표시 확인 | 등록·삭제 등 DB 쓰기 미수행 | + +- 정상 `pnpm exec tsc --noEmit --incremental false`: 최종 수정 후 exit 0. +- `git diff --check`: 공백 오류 없음. 일부 파일의 기존 LF/CRLF 전환 경고는 발생했다. +- 임시 계약 검사: 컬럼·코드 정의 40개 동일, 시설 선택 20케이스 동일, 과거 응답 무시·전체 합계·단위구간 그룹·준설 페이지 보존·CMS fallback 5그룹 통과. +- 앞선 `.next/types/routes.d.ts` 자동 생성 파일 중복으로 정상 타입 검사가 막혔다. 사용자 승인 후 SN 프론트를 재시작하고 해당 자동생성 파일만 재생성한 뒤 정상 전체 타입 검사를 수행했다. 그 이전 source-only 검사는 전체 타입 검사로 대체 보고하지 않는다. +- 브라우저 로그에는 초기 진입의 AxiosError와 편집 중 HMR dependency-array 길이 변경 경고가 기록됐다. 완전 지도 재진입 후 새 경고는 관측되지 않았다. 최초 AxiosError의 요청별 원인까지 확정한 것은 아니므로 전체 콘솔 무오류로 보고하지 않는다. +- 개발 서버가 실행 중이므로 production build·청크 크기 검증은 수행하지 않았다. 실제 Excel 파일 대조와 모든 시설 저장 회귀도 완료로 처리하지 않는다. + +## 7. 정리·실행 상태·롤백 + +- 이동으로 빈 `map/components/common`, `popup-component/chart`, `popup-component`, `oper/dredging/components`, `oper/dredging/lib` 5개 폴더를 실제 비어 있음·SN 내부·링크 아님을 확인하고 삭제했다. 파일 내용 손실은 없다. +- 이번 임시 스크립트 3개(`sn-remaining-refactor-20260929.cjs`, `sn-source-check-20260929.cjs`, `sn-remaining-contracts-20260929.cjs`)는 검증 결과를 본 문서에 요약 후 삭제한다. 휴지통 복구는 지원하지 않으며 필요 시 재작성한다. 기존 사용자 테스트 자산과 공유 캐시는 삭제하지 않는다. +- 최종 문서와 제품 구현은 보존한다. 임시 원시 증거 파일을 현재 존재하는 링크로 제시하지 않는다. +- 사용자 승인으로 재시작한 로컬 SN 프론트는 **PID 79040 / 포트3002**, 계속 실행한다. 종료가 필요하면 먼저 PID의 명령줄이 SN Next 서버인지 확인한 뒤 해당 PID만 종료한다. 백엔드는 이번 작업에서 재시작하지 않았다. +- 롤백은 이번 이동의 import와 대상 파일을 함께 되돌리는 단위로 수행한다. 기존 사용자 변경이 많으므로 저장소 전체 reset/checkout을 하지 않는다. 원본 route URL, DB, 배포 설정은 변경하지 않아 해당 롤백은 필요 없다. + +## 8. 다음에 완료해야 할 항목 + +1. map-page-client의 GIS 편집 interaction·미리보기·스냅·원본 마스킹을 작은 책임 단위로 분리하고 편집 종료/재진입 cleanup 검증. +2. 침하·침수·조사 레이어의 요청/선택/해제 소유권 분리 및 실제 도형 검증. +3. 남은 시설 코드 소비처·역변환과 Excel 코드명 정합성 검증. 의미가 불명확한 펌프 코드는 별도 확인 대상으로 유지. +4. 시설별 연속 선택, 조사/준설 Excel 실파일, 팝업과 부모 지도 연동 검증. +5. 공유 UI의 전 앱 호환성·번들 검증이 필요한 변경은 실제 필요성과 권한 범위를 먼저 확정. diff --git a/apps/sn/docs/작업계획/SN-패키지구조진단/012-fe-SN-잔여구조개선-최종결과.md b/apps/sn/docs/작업계획/SN-패키지구조진단/012-fe-SN-잔여구조개선-최종결과.md new file mode 100644 index 000000000..a72a68b99 --- /dev/null +++ b/apps/sn/docs/작업계획/SN-패키지구조진단/012-fe-SN-잔여구조개선-최종결과.md @@ -0,0 +1,136 @@ +# SN 잔여 구조 개선 최종 결과 + +작성일: 2026-09-29 + +## 1. 결론과 범위 + +006 설계의 잔여 구현을 반영하고 대표 사용자 흐름을 검증했다. 011은 이전 중간 보고이며 현재 상태는 이 문서를 따른다. **모든 운영 데이터·시설 유형·외부 팝업 조합을 전수 통과했다는 의미는 아니다.** 미수행·의도적 유지 범위는 5~7절에 분리했다. + +- 프론트 수정은 SN 내부로 제한했다. business_components·타 앱은 변경하지 않았다. +- 사용자 추가 승인으로 백엔드 `ExmnGridService.java` 한 파일의 소수 등급 합산 오류를 수정하고 로컬 백엔드를 재기동했다. +- 라우트 URL, 업무 API URL, 요청·응답 필드, DB 구조, Java 버전, Docker·배포·환경설정, 의존성 버전은 변경하지 않았다. 실행 시에도 기존 Java 11과 local profile을 사용했다. +- 커밋·푸시·개발서버 배포는 하지 않았다. 기존 사용자·타 작업자의 변경을 보존했다. + +## 2. 단계별 정리 + +| 단계 | 결과 | 적용·유지 판단 | +| --- | --- | --- | +| 1 지도 책임 분리 | 반영 | 미리보기·원본 마스킹·스냅 조회·추가·이동/꼭지점·선택과 조사/침하/침수 레이어를 별도 hook으로 분리. map-page-client에 OpenLayers layer/overlay/interaction 생성 및 `.on()` 등록 코드가 남지 않음 | +| 2 API·상태 소유권 | 앞선 반영 유지 | 010의 transport/업무 API/상태 소유권 구조 유지 | +| 3 조사 상세 | 반영·검증 보완 | 검색 UI·조회 hook·표 모델·Excel adapter 분리 유지. 실제 다운로드·그룹 수·합계 대조 | +| 4 시설 상세 | 반영·안전성 보완 | 시설 선택 resolver·패널·자체입력 API 분리. 선택 변경 후 늦은 응답 차단을 관로 운영정보와 토실 측정 목록까지 보완 | +| 5 컬럼·코드 | 반영, 명시 예외 유지 | 시설별 9개 모델·40개 정의 유지. CMS 5그룹을 검색·상세·노후관로·분석 상세·보수이력·준설 상세·편집 화면에 연결. 펌프 의미 차이는 임의 변경하지 않음 | +| 6 준설 | 반영·검증 보완 | 목록·상세·등록을 feature로 집중. 조회와 다운로드 로딩 분리. 3개 조회구분과 실파일 확인 | +| 7 공유 표·폼 | 사용처 조사 후 유지 | 서로 다른 계약의 실제 소비자가 있어 강제 통합/삭제하지 않음. 타 앱 수정·공통 export 변경 없음 | +| 8 정리 | 반영 | 앞선 이동 후 빈 폴더 5개 정리 유지. 이번 검증용 소스·중간 JSON·다운로드 사본을 결과 요약 후 삭제 | + +지도 화면에는 우측 탭, 검색 적용조건, 팝업과 업무 선택을 연결하는 조립 코드가 남는다. 모든 코드를 무조건 features로 옮기거나 줄 수만 줄이는 방식은 적용하지 않았다. 지도 인스턴스와 화면 전용 메시지의 조립 hook은 route-local `app/(map-layout)/map/lib`에 두며, 재사용 가능한 geometry/style·세션 모델은 기존 `features/gis-edit`, `features/map-layers`를 재사용한다. + +## 3. 이번 지도 모듈의 소유권 + +아래 파일은 모두 `app/(map-layout)/map/lib/` 하위다. + +| 파일 | 맡는 역할 | 종료·취소 처리 | +| --- | --- | --- | +| use-gis-edit-original-mask.ts | 편집 대상 원본 관로의 WMS 필터·피처 스타일 보관/복원 | 편집 해제·화면 이탈 시 원복 | +| use-gis-edit-pipe-snap.ts | 지도 영역의 스냅 후보 WFS 조회·미리보기 후보 결합 | AbortController, 요청 순번, moveend 이벤트 해제 | +| use-gis-edit-preview.ts | 편집 미리보기 source·label·강조와 신규 관로 자동 초점 | 자신의 레이어·source·커서 정리 | +| use-gis-edit-transform.ts | 이동/꼭지점 interaction, 선택 핸들, Delete 키 처리 | Translate/Snap/핸들·이벤트 해제 | +| use-gis-edit-draw.ts | 신규 관로 Draw·Snap 생성/종료 | 도구 전환·이탈 시 자신이 등록한 interaction 제거 | +| use-gis-edit-selection.ts | 편집 클릭 우선순위와 공통 Select 활성 상태 연결 | 클릭 이벤트 해제, 공통 선택 상태 복원 | +| use-exmn-result-layers.ts | 관로·맨홀 조사 결과 조회·레이어 표시 | 요청 순번 무효화 및 소유 레이어 제거 | +| use-sinkhole-layers.ts | 사고점·반경·주변관로 도형 표시 | 이전 요청 취소·지연 응답 무시·소유 레이어 제거 | +| use-flood-history-selection.ts | 침수 이력 선택 도형·침수심 강조 | 선택 source 정리 | +| use-flood-history-visibility.ts | 연도 조건·침수흔적 위선/심선 표시 동기화 | 캡처한 지도에서 관리 대상 필터 레이어 정리 | +| use-flood-history-click.ts | 침수 이력 강조 도형 클릭과 상세 카드 위치 | singleclick 이벤트 해제 | +| use-flood-prediction-grid-overlay.ts | 침수예측 격자 중앙 정보 카드 | 지연 응답 무시·자신의 overlay 제거 | + +편집 hook은 `gisEdit` 객체 전체의 변경으로 도구를 재등록하지 않도록 실제 사용하는 상태·명령을 구조 분해하고 dependency에 명시했다. 각 hook의 지도 ref는 화면이 전달하는 안정된 인스턴스이며 Zustand나 영속 저장소에 저장하지 않는다. + +## 4. 수정 이유와 코드 + +### 4.1 지반침하 조회의 늦은 응답 + +기존에는 검색조건을 연속 변경하거나 레이어를 꺼도 먼저 시작한 요청이 나중에 source를 갱신할 수 있었다. 요청마다 순번·AbortController를 부여하고, 현재 요청이 아니면 도형을 반영하지 않는다. + +```ts +const sequence = ++requestSequence.current; +requestController.current?.abort(); +const controller = new AbortController(); +requestController.current = controller; +// 기존 URL·검색조건 유지 +const rawResponse = await getRawJson(url, { signal: controller.signal }); +if (controller.signal.aborted || sequence !== requestSequence.current) return; +``` + +예시의 `url`은 설명용 축약이다. 실제 요청 경로와 모든 조건은 `use-sinkhole-layers.ts`를 따른다. 이 수정은 목록의 page/size를 지도 도형 조회에 전파하는 변경이 아니다. + +### 4.2 CMS 코드명과 편집 초안 + +- 확인된 OGC004/011/002/008/007만 CMS에서 읽고, 초기·빈 응답·실패 시 기존 사전을 사용한다. +- React Query `staleTime=5분`, 자동 retry 없음. 주기적 polling이나 서버 캐시 갱신을 보장하는 설정은 아니다. +- `code-mapper.ts`는 선택적 catalog를 받아 표시·역변환을 수행한다. 미등록 코드는 원문을 유지한다. +- GIS 편집은 CMS 목록이 갱신되어도 이미 작성 중인 초안을 초기화하지 않는다. 초기화는 시설 원본/편집 change가 바뀌는 경우로 제한한다. +- 미지원 시설 종류는 `facility-input-api.ts`에서 잘못된 `undefined/...` URL로 전송하기 전에 예외로 차단한다. + +### 4.3 조사 상세 소수 등급 합계 오류 + +실파일 비교에서 화면 합계 `0 / 0`, Excel 합계 `9.5 / 3.0` 불일치를 발견했다. 백엔드가 `"3.00"` 등급을 `Integer.parseInt()`로 처리하다 예외를 0으로 바꾼 것이 원인이다. 사용자 승인 후 해당 서비스만 수정했다. + +```java +// 수정 전 +int structural = 0, operational = 0; +structural += grade(row.getStructuralGrade()); +// grade: Integer.parseInt(value), 실패 시 0 + +// 수정 후 +BigDecimal structural = BigDecimal.ZERO, operational = BigDecimal.ZERO; +structural = structural.add(grade(row.getStructuralGrade())); +operational = operational.add(grade(row.getOperationalGrade())); +// grade: new BigDecimal(value.trim()), 누락·비수치는 기존대로 ZERO +// 응답 문자열은 toPlainString()으로 반환 +``` + +현재 페이지를 프론트에서 합산하여 전체 합계를 위조하는 보정은 하지 않았다. Controller/DTO/Repository/SQL/DB는 변경하지 않았다. 별도 백엔드 결과: `IIPBS/sewerage-service/docs/작업계획/SFR-010-004~SFR-010-007-조사사업/002-be-SN-조사상세소수등급합계-수정결과.md`. + +## 5. 메뉴 > 화면별 실제 검증 + +| 메뉴 > 화면 | 직접 수행한 시나리오 | 결과·제한 | +| --- | --- | --- | +| 인증 > 로그인 | 기존 로컬 계정 로그인 후 지도 이동 | 통과. 검증 전 만료 세션은 정상 재로그인 | +| 하수관망도 > 초기화/재진입 | 최종 코드 적용 후 새로고침, 지도·검색·레이어 트리 표시 | 확인. 개발 중 HMR 경고와 백엔드 재시작 중 연결 오류는 최종 새 페이지와 구분 | +| 하수관망도 > 유하방향 | 경기도/하남시 조회, 줌15.3 확대, 유하방향 체크 | 실제 관로 위 화살표 확인 | +| 하수관망도 > GIS 편집 | 신규 임시 관로 1건, 드래그 이동, 꼭지점 조정, 취소 | 화면 상태 메시지와 실제 도형 변경 확인. 서버 저장 없이 취소 | +| 하수관망도 > 노후관로/정보검색/시설 상세 | 타입·API 가드·코드 변환·요청 경계 점검 | 이번 회차에서 모든 시설 유형 실데이터 클릭·저장 전수 검증은 하지 않음 | +| 유지관리 > 조사 상세 | 덕풍처리분구 관로 3건, Excel 실파일, 화면 소계 대조 | 연장82.85m, 수정 후 구조9.5/운영3으로 파일과 일치 | +| 유지관리 > 조사 상세 > 본단위구간 | 4개 그룹 조회, Excel 파일 검사 | 상세19행·14열. 그룹별 식별자가 유지되는 행 단위 파일이며 실제 파일에 병합 셀은 없음 | +| 유지관리 > 준설 > 지자체별 | 목록 2페이지 순번11, 그 상태에서 전체 Excel 다운로드 | 파일 데이터11행·13열. 현재 페이지1행만 내보내지 않음 | +| 유지관리 > 준설 > 관경별 | 구분 전환·목록·다운로드 | 데이터5행·9열 집계 파일 확인 | +| 유지관리 > 준설 > 경과년도별 | 구분 전환·목록·다운로드 | 데이터5행·7열 집계 파일 확인 | +| 지도 > 외부 창 팝업 연동 | 정보검색 열기 및 탭 목록 관측 | 현재 in-app 브라우저에서 window.open 창이 조작 가능한 탭으로 노출되지 않음. 직접 URL 팝업 검증을 부모/자식 메시지 연동 통과로 대체하지 않음 | +| 준설·시설 > 등록/수정/삭제 | 저장 코드·계약 유지, 모의 계약 검사 | 공용 개발 DB의 기존 업무자료를 변경하는 저장/삭제는 수행하지 않음 | + +Excel은 실제 다운로드 완료 후 파일을 열어 검사했다. 조사 상세는 데이터3행+소계/합계 및86열, 합계 행의 기존 병합 범위도 확인했다. 준설의 관재질 한글 표시와 순번·시설 식별자 열을 확인했다. 전체 과거 자료·모든 코드에 대한 전수 동일성 검사로 확대 해석하지 않는다. + +## 6. 자동 검사와 정리 + +- SN 정상 `pnpm exec tsc --noEmit --incremental false`: 최종 코드 통과. +- 분리한 지도 hook의 ESLint dependency 검사: 12개 파일 오류0·경고0. 기존 OpenLayers `any` 경계는 검사에서 `no-explicit-any`만 제외했으며 프로젝트 전체 lint 무경고를 뜻하지 않는다. +- 코드 변환 계약 검사: CMS 정방향/역방향/미등록/빈값/fallback 6케이스, 시설 지원·차단 7케이스, CQL 인용부호/단건 page·size, 저장 URL·본문 유지 통과. +- 백엔드 `gradlew.bat compileJava`: 통과. 기존 deprecated/unchecked 경고는 별도로 관측됐다. +- 실제 컴파일된 ExmnGridService를 Java11 JShell로 검사: 등급6케이스, 합계2케이스 통과. `0.10+0.20=0.30`, `3.00+3.00+3.50=9.50`, 누락·비수치0 확인. DB 호출 없음. +- 두 저장소 `git diff --check`: 공백 오류 없음. 기존 CRLF 전환 경고와 구분한다. +- 실행 중인 프론트와 충돌하는 production build는 수행하지 않았다. +- 임시 검증 파일 총11개(보조 스크립트/JSON/JShell6개, 다운로드 XLSX5개)를 삭제했다. 테스트 결과는 본 문서에 요약했으며 원시 파일 링크는 남기지 않는다. 휴지통 복구는 지원하지 않지만 테스트·다운로드를 재실행할 수 있다. 제품 코드·최종 문서·기존 사용자 자료는 유지한다. + +## 7. 의도적 유지와 운영 인계 + +1. OGC021 펌프 용도는 기존 사전과 CMS 의미 차이가 있어 임의로 저장 코드/표시 의미를 바꾸지 않았다. fallback 사전은 초기 렌더·장애·과거 한글명 역변환에 필요한 호환 자산이며 불용 파일이 아니다. +2. 공유 UiTable/StandardDataTable, Form/FormUi/StandardForm, ContentsDiv는 타 앱의 실제 소비 계약이 다르므로 유지한다. SN·WR·FO·DM 직접 사용처 근거는 011 5절을 따른다. 공통 API 변경이 없어 타 앱 강제 이관·빌드는 수행하지 않았다. +3. 지도·집계·Excel의 API 계약 및 기존 서버 가공 페이징 예외는 이번 구조 개선에서 바꾸지 않았다. 성능 향상·DB LIMIT/OFFSET 전환으로 보고하지 않는다. +4. 사용자 지시대로 로컬 프론트 **PID79040/3002**, 재기동한 로컬 백엔드 **PID90648/8883**을 계속 실행한다. 종료 시 PID와 명령줄을 확인한 후 정확한 프로세스만 종료한다. 포트 기준 일괄 종료는 하지 않는다. +5. 원본 확인: 요구사항정의서 `3. (기능)요구사항정의 및 추적표` 76~79행 SFR-010-004~007,84행 SFR-010-012,96/98/100행 SFR-012-001/003/005. 화면설계서34/36/37/53쪽 SN_MN010301/010303/010304/040201. 스프레드시트·프레젠테이션 읽기 지침에 따라 원본을 읽기 전용으로 대조했다. + +## 8. 롤백 + +이번 hook 분리의 원래 선언·호출·import를 한 단위로 복원하고 해당 신규 hook을 제거한다. API·코드명 변경은 catalog 주입과 호출자를 함께 되돌린다. 백엔드는 ExmnGridService의 합산/파싱 부분만 복원 후 재컴파일·재기동한다. 사용자 변경이 함께 있으므로 저장소 전체 `reset --hard`나 일괄 checkout을 사용하지 않는다. diff --git a/apps/sn/docs/작업내용/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-통합구현기록.md b/apps/sn/docs/작업내용/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-통합구현기록.md index b61216ca4..1567284ac 100644 --- a/apps/sn/docs/작업내용/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-통합구현기록.md +++ b/apps/sn/docs/작업내용/SFR-010-004~SFR-010-007-조사사업/001-fe-SFR-010-004~007-조사사업-통합구현기록.md @@ -960,3 +960,38 @@ - 조사 코드값, 상세표 열 순서, API 파라미터와 백엔드 Excel 파서·분류 목록은 변경하지 않았다. - CMS 조회 실패 또는 미등록 코드에는 기존 화면 라벨과 서버 제공값을 fallback으로 유지한다. - 백엔드 API와 DB는 변경하지 않았다. + +## 2026-10-01 조사 레이어 검색 독립 표출 구현 결과 +- 일반 /map에서 상단 검색/URL 지역 없이 PPL·MNHL 체크만으로 전체 활성 조사결과를 조회한다. 기존 레이어 색상·사업차수·구조/운영 AND 필터, 설정 토글 OFF의 추가 필터 해제 계약을 유지한다. +- ExmnMapController의 sggCd를 선택값으로 변경하고 exmnBizId/trtdstId 선택 조건을 추가했다. 처리분구 단독 요청·잘못된 사업 연결·범위 밖 등급은 400으로 처리한다. +- ExmnMapLayerService는 기존 AdministrativeDistrictRepository의 시도+시군구 기준정보로 자료별 지역을 해석하고 지역별 500건 배치를 사용한다. 같은 번호의 다른 지역은 분리하고 동일 지역의 다중 도형은 임의 선택하지 않는다. +- missingGeometryCount와 diagnostics(regionUnresolvedCount/identifierMissingCount/geometryNotFoundCount/ambiguousGeometryCount)를 반환한다. MNHL은 기존 상류맨홀만 사용하며 GIS 원천을 임의 보정하지 않는다. +- FE 전용 useExmnResultMap hook으로 기본 레이어·선택 강조·유형별 요청 취소/순번 무효화·0건/실패/누락 안내를 분리했다. 지도보기는 URL의 사업/분구를 별도 조회해 전체 extent로 최초 1회 이동한다. 일반 레이어를 선택 사업으로 제한하거나 첫 피처로 대신 이동하지 않는다. +- 수정 파일: BE ExmnMapController.java, ExmnFacilityRepository.java, ExmnMapLayerService.java; FE map/page.tsx, exmnMapApi.ts, exmnMapTypes.ts, ExmnLayerSettingPanel.tsx, 신규 useExmnResultMap.ts. +- DB DDL/DML·상단 공통 검색바·권한 설정 변경 없음. 사용자 서버를 재시작/종료하지 않았고, 신규 서버/전체 FE build는 실행하지 않았다. +- 롤백은 FE를 기존 지역 포함 호출로 복원한 뒤 BE 변경을 복원한다. 순차 배포는 BE를 먼저 반영한다. + +### 2026-10-01 사용자 확인 및 지도보기 강조 제거 +- 사용자 확인: 조사 레이어 ON/OFF에 따라 시설이 정상 표시됨. +- 후속 지시로 지도보기의 별도 파란 강조 레이어를 제거했다. 선택 사업·처리분구 조회와 전체 extent 위치 이동은 유지하고 기존 조사 레이어의 색상/스타일로 표시한다. +- 앞선 설계·구현 기록의 별도 선택 강조 생성 내용은 이 결정으로 대체한다. 백엔드·DB 추가 변경 없음. + +## 2026-10-01 조사결과 업로드 양식 미준비 안내 +- 사용자 요청은 양식 생성 절차를 생략하는 수정이 아니라 미생성 상태에 ‘양식 받기’ 선행 안내를 제공하는 것이다. +- 실제 다운로드 이력 대신 활성 처리분구의 pplExmnSheetNm/mnhlExmnSheetNm 존재 여부를 기존 사업 상세 API로 확인한다. +- 팝업 진입, 데이터 검토 전, 저장 확인·첨부 업로드 전에 확인한다. 검토 후 사업 수정으로 시트 연결이 초기화됐으면 이전 검토 상태를 폐기하고 안내한다. +- 문구: “조사 입력양식이 준비되지 않았습니다. 사업정보에서 ‘양식 받기’를 먼저 실행한 후 조사결과를 등록해 주세요. 사업정보를 수정한 경우에도 양식을 다시 받아야 합니다.” +- FE ExmnResultRegisterPopupPage.tsx와 exmn-business-validation.ts에 안내/사전 확인을 추가하고 BE ExmnSurveyResultParser의 SHEET_NAME_NOT_ASSIGNED 메시지도 같은 행동 안내로 보완했다. 자동 양식 생성·DB 변경·기존 저장 필수조건 변경은 하지 않았다. +- 검증: 미생성/부분생성/공백/정상/처리분구 없음 5건 통과, FE 타입 검사와 BE compileJava --offline 및 diff --check 통과. 임시 파일 생성 없음. 실제 파일 업로드/저장 및 브라우저 회귀는 미실행이다. +- 롤백은 이번 안내·사전 확인과 parser 오류문구만 복원한다. 실제 다운로드 성공 여부를 추적하는 기능은 아니다. + +### 2026-10-01 후속 지시: 저장 시에만 조사양식 안내 +- 앞선 초기 진입/데이터 검토 단계의 양식 준비 안내와 프론트 사전 차단을 제거했다. 저장을 누른 경우에만 공통 alert로 “조사관리에서 해당 사업 행 선택 → 수정 → 조사양식 받기 후 결과등록 재진행”을 안내한다. 검토 결과는 보존한다. +- 검토 API는 양식 생성과 같은 명명 규칙으로 미등록 시트명만 메모리에서 보완한다. 저장된 시트명은 우선 사용하고 검토 중 시트명/DB를 저장하지 않는다. 실제 파일의 시트·필수값·중복·PDF 검증은 유지한다. +- 저장 API는 실제 시트명 등록을 계속 요구하며 미준비면 기존 결과를 변경하기 전에 차단한다. 프론트는 첨부 업로드 전에 최신 상태를 확인하며 서버에서 같은 오류가 반환돼도 alert로 안내한다. +- 검증: ExmnTemplateGateTest 7건 통과(Excel/PPL PDF/MNHL 검토 독립, 미준비 commit 무쓰기 차단, 기존 시트명 보존, 준비 완료 commit, 해시 불일치 차단), FE 타입 검사 통과. 테스트는 mock 기반이며 실제 파일 저장·DB 변경 및 브라우저 끝단 저장은 수행하지 않았다. +- 임시 테스트 소스·산출물은 요약 후 정리한다. 이전 ‘검토 전 양식 확인’ 기록은 이 지시로 대체한다. + +### 2026-10-01 조사양식 안내창 폭 조정 +- 해당 안내만 56rem 폭과 화면 여백 최대폭을 적용한 모달로 표시한다. 단어 유지와 균형 줄바꿈으로 두 줄 표기를 유도하며 문구와 저장 시점은 유지한다. 다른 공통 알림창은 변경하지 않는다. +- git diff --check 통과. 좁은 화면에서는 잘림 방지를 위해 추가 줄바꿈을 허용한다. diff --git a/apps/sn/docs/작업내용/SFR-011-002-관로분석/001-fe-SFR-011-002-관로분석-통합구현기록.md b/apps/sn/docs/작업내용/SFR-011-002-관로분석/001-fe-SFR-011-002-관로분석-통합구현기록.md index 504b89518..229c41871 100644 --- a/apps/sn/docs/작업내용/SFR-011-002-관로분석/001-fe-SFR-011-002-관로분석-통합구현기록.md +++ b/apps/sn/docs/작업내용/SFR-011-002-관로분석/001-fe-SFR-011-002-관로분석-통합구현기록.md @@ -85,3 +85,13 @@ - 동일 관로의 상세 효과가 개발 모드에서 반복 실행돼도 시설관리번호·관리기관코드별 요청 Promise를 재사용해 중복 API 호출을 막는다. 실패한 요청은 캐시에서 제거해 재시도를 허용한다. - 대상 관로 `2015303665`의 화면 표출값은 `시종점 관저고`이며, 계산코드는 `DEPTH`이다. - 수정 파일은 `app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx`이며, `map/page.tsx`는 관로 선택 행을 그대로 상세 컴포넌트에 전달한다. + +## 2026-09-30 관로분석 시도 기본값 부산 적용 + +- quick_vibe: 사용자 지시로 시도 전체 옵션을 제거하고 기본 시도를 부산광역시(26)로 설정했다. +- `PipeAnalysisPopupPage.tsx`: 시도 전체 option 및 공통 목록의 값 0을 제외한다. 시군구·처리구역 전체 선택은 유지한다. +- `pipeAnalysisUtils.ts`: 집계 기본 필터 ctpvCd=26. 최초 자동 조회와 초기화에서 같은 기본값을 사용한다. 계정 지역 제한은 기존 applyScopeToSummaryFilters를 우선한다. +- API/백엔드/DB 및 상세 필터 기본값은 변경하지 않는다. +- 원본: 요구사항정의서 기능 추적표 88행 SFR-011-002, 화면설계서 10번 SN_MN010103. 부산 기본값은 이번 사용자 지시를 적용한다. +- 검증: git diff --check 통과. ESLint는 eslint-plugin-react-hooks 설치 누락으로 실행 실패. 실제 브라우저 확인과 전체 빌드는 미실행이며 사용자 개발 서버를 유지했다. +- 롤백: 집계 기본 시도를 0으로 되돌리고 시도 전체 option을 복원한다. diff --git a/apps/sn/docs/작업내용/SFR-011-007-통수위험분석/001-fe-SFR-011-007-통수위험분석-통합구현기록.md b/apps/sn/docs/작업내용/SFR-011-007-통수위험분석/001-fe-SFR-011-007-통수위험분석-통합구현기록.md index ce9365f91..717993ddc 100644 --- a/apps/sn/docs/작업내용/SFR-011-007-통수위험분석/001-fe-SFR-011-007-통수위험분석-통합구현기록.md +++ b/apps/sn/docs/작업내용/SFR-011-007-통수위험분석/001-fe-SFR-011-007-통수위험분석-통합구현기록.md @@ -266,3 +266,18 @@ - 현재·과거분석이력이 공유하는 결과표 우측 안내문 뒤에 `(GIS 매핑 관로 중 Capacity 0.8 이상이 1개라도 있으면 위험, 없으면 정상)`을 추가했다. - 지도 요약카드와 동일하게 `위험`은 빨간색(`#ff1c15`), `정상`은 파란색(`#1677ff`)의 굵은 글씨로 표시한다. - 지도 요약카드에만 있던 위험 판정기준을 결과 목록에서도 확인할 수 있게 했으며, 판정 로직·API·DB 계약은 변경하지 않았다. + +## 2026-09-30 과거분석이력 기본 강우발생기간 + +- 사용자 지시로 과거분석이력 최초 진입 기간만 2026-08-15 ~ 2026-08-17로 지정했다. 후속 사용자 지시에 따라 초기화는 실행 시점의 최근 1년·전체 검색조건을 새로 생성해 첫 페이지를 조회한다. +- 수정 파일: `frontend/apps/sn/app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage.tsx`. +- 현재 탭의 조회기간은 기존 최근 1년 기준으로 별도 생성해 과거 고정기간에 의해 현재 데이터가 제외되지 않도록 했다. +- 과거 날짜 직접 변경·조회와 최신순 정렬을 유지한다. 백엔드·API 형식·DB 변경 없음. +- 검증: git diff --check 통과 및 최초 initialFilters와 초기화 buildDefaultResultFilters 분리 및 초기화 후 재조회 흐름 확인. 브라우저 확인 미실행, ESLint는 앞선 작업에서 확인한 eslint-plugin-react-hooks 누락으로 재실행하지 않았다. 개발 서버와 빌드는 조작하지 않았다. +- 롤백: 이 파일의 기본기간 및 조회기간 분리 변경만 복원한다. + +## 2026-10-01 강우 발생기간 조회 제한 +- WaterRiskPopupPage.tsx: 고정 초기기간을 최근 1년으로 변경, 시작일 min/max 및 종료일 max 적용, 종료일 기준 시작일 보정, 조회 전 유효성 검사, 초기화 시 오류 제거·재조회, 오류 메시지를 목록 위로 이동했다. +- waterRiskDateRange.ts: UTC 날짜 계산으로 포함 366일 경계·윤년을 처리하고 잘못된 날짜/미래/역전/기간 초과를 검증한다. +- 참고 화면: 침수이력의 min/max 입력과 준설의 최근 1년 기본기간. 공통 컴포넌트·BE·DB는 변경하지 않았다. +- 롤백: 위 두 소스와 이번 문서 절만 복원한다. DB 작업 없음. diff --git a/apps/sn/docs/작업내용/SFR-011-008-침수예측/002-fe-SFR-011-008-침수예측현황-분석시각초기화-구현기록.md b/apps/sn/docs/작업내용/SFR-011-008-침수예측/002-fe-SFR-011-008-침수예측현황-분석시각초기화-구현기록.md new file mode 100644 index 000000000..80e0a5d6b --- /dev/null +++ b/apps/sn/docs/작업내용/SFR-011-008-침수예측/002-fe-SFR-011-008-침수예측현황-분석시각초기화-구현기록.md @@ -0,0 +1,39 @@ +# 침수예측현황 분석시각 초기화 구현기록 + +## 작업 목적 + +- 침수예측현황 팝업을 처음 열거나 검색조건을 초기화할 때 분석시각 범위를 자동 입력하지 않는다. +- 분석시각 제한 없이 조회한 결과는 최근 분석 데이터부터 표시한다. + +## 원본 및 변경 근거 + +- 요구사항정의서 `SFR-011-008 위험도 산정` +- 화면설계서 슬라이드 24, `SN_MN010205 침수예측현황` +- 사용자 승인사항: 분석시각 기본 검색조건 제거 및 최근 데이터 우선 표시 + +## 변경 내용 + +- 최초·초기화 검색조건의 `analysisFrom`, `analysisTo`를 빈 값으로 변경했다. +- 목록과 Excel 전체 조회에 `analysisDateTime,desc` 정렬을 명시했다. +- 사용자가 필요할 때 직접 분석시각 범위를 입력할 수 있도록 검색 입력란은 유지했다. + +## 영향 범위 + +- `apps/sn/src/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent.tsx` +- 백엔드 API와 응답 구조는 변경하지 않는다. +- 시도, 시군구, 강우시각, 초과 빈도 검색조건은 기존 동작을 유지한다. + +## 검증 + +- `pnpm --filter sn run typecheck` 통과 +- `git diff --check` 통과 +- 실행 중인 개발 서버를 유지하기 위해 별도 빌드는 수행하지 않는다. + +## 롤백 + +- `buildInitialFilters`에서 분석시각의 금일 기본 범위를 다시 생성하고 명시적 최신순 정렬 파라미터를 제거한다. + +## 남은 확인 + +- 팝업 최초 진입 시 분석시각 입력란이 비어 있는지 확인한다. +- 초기화 버튼을 누르면 분석시각이 비워지고 첫 페이지에 최신 분석 데이터가 표시되는지 확인한다. diff --git a/apps/sn/docs/작업내용/SFR-011-008-침수예측/003-fe-SFR-011-008-침수예측지도-오버레이시각표시-구현기록.md b/apps/sn/docs/작업내용/SFR-011-008-침수예측/003-fe-SFR-011-008-침수예측지도-오버레이시각표시-구현기록.md new file mode 100644 index 000000000..31f23e41b --- /dev/null +++ b/apps/sn/docs/작업내용/SFR-011-008-침수예측/003-fe-SFR-011-008-침수예측지도-오버레이시각표시-구현기록.md @@ -0,0 +1,46 @@ +# 침수예측지도 오버레이 시각 표시 구현기록 + +## 작업 목적 + +- 침수예측현황 팝업에서 행을 선택했을 때 지도 오버레이의 분석시각과 예측 관련 시각을 생략 없이 표시한다. + +## 원본 및 변경 근거 + +- 요구사항정의서 `SFR-011-008 위험도 산정` +- 화면설계서 슬라이드 24, `SN_MN010205 침수예측현황` +- 사용자 요청사항: 지도 오버레이에서 월 뒤로 생략되는 분석시각 값을 전체 표시 + +## 원인 + +- SN 목록 테이블의 데이터 셀에 적용되는 전역 말줄임 규칙이 지도 오버레이 내부 테이블에도 적용되었다. +- 오버레이의 기존 날짜 변환 함수는 `YYYY-MM-DD HH:mm` 전체 값을 만들고 있었으나, 좁은 셀에서 화면 표시만 생략되었다. + +## 변경 내용 + +- 지도 오버레이 폭을 기존과 동일한 `230px`로 유지했다. +- 고정된 폭 안에서 라벨 열 55%, 값 열 45%로 배분하도록 `colgroup`과 `table-layout: fixed`를 적용했다. +- 오버레이의 라벨·값 셀을 목록용 전역 말줄임 대상에서 제외했다. +- 전체 날짜와 시각이 한 줄에 표시되도록 값 셀의 줄바꿈과 말줄임을 해제했다. +- 기존 날짜 변환 형식과 침수위험도 배지 동작은 유지했다. + +## 영향 범위 + +- `apps/sn/src/features/fldngPredcPrst/lib/use-flood-prediction-map.ts` +- 침수예측 격자 선택 후 표시되는 지도 오버레이에만 적용한다. +- 일반 목록 그리드의 말줄임 정책은 변경하지 않는다. + +## 검증 + +- `pnpm --filter sn run typecheck` 통과 +- `git diff --check` 통과 +- 실행 중인 개발 서버의 `/map` 경로 HTTP 200 응답 확인 +- 실행 중인 개발 서버를 유지하기 위해 별도 빌드는 수행하지 않는다. + +## 롤백 + +- 오버레이 셀의 `data-sn-cell-wrap` 속성과 표·셀 너비 스타일을 제거하고 기존 최소 너비를 복원한다. + +## 남은 확인 + +- 침수예측현황 행 선택 후 분석시각, 강우시작시각, 예측시각이 `YYYY-MM-DD HH:mm` 형식으로 모두 보이는지 확인한다. +- 지도의 오른쪽 경계 부근에서도 오버레이가 화면 안에 정상적으로 표시되는지 확인한다. diff --git a/apps/sn/docs/작업내용/SFR-011-008-침수예측/004-fe-SFR-011-008-침수예측지도-기능분리-구현기록.md b/apps/sn/docs/작업내용/SFR-011-008-침수예측/004-fe-SFR-011-008-침수예측지도-기능분리-구현기록.md new file mode 100644 index 000000000..2fab0e07a --- /dev/null +++ b/apps/sn/docs/작업내용/SFR-011-008-침수예측/004-fe-SFR-011-008-침수예측지도-기능분리-구현기록.md @@ -0,0 +1,52 @@ +# 침수예측지도 기능 분리 구현기록 + +## 작업 목적 + +- 하수관망도 `map/page.tsx`에 포함된 침수예측현황 전용 로직을 기능 디렉터리로 이동한다. +- 지도 페이지에는 공통 지도 구성과 침수예측 기능 연결 코드만 남겨 기능별 책임을 분명히 한다. + +## 원본 및 변경 근거 + +- 요구사항정의서 `SFR-011-008 위험도 산정` +- 화면설계서 슬라이드 24, `SN_MN010205 침수예측현황` +- 화면설계서 슬라이드 28, `SN_MN010206 침수예측지도` +- 사용자 요청사항: `map/page.tsx`의 침수예측현황 관련 함수와 상태를 `features` 하위 해당 기능으로 이동 + +## 변경 내용 + +- 침수예측 팝업 메시지 payload 검증을 기능 훅으로 이동했다. +- 도시침수지도 자동 활성화 요청 이후의 재시도 상태와 팝업 응답 대상을 기능 훅에서 관리한다. +- 도시침수지도 피처 선택, 지도 범위 이동, 분석결과 API 조회와 오버레이 생성·삭제를 기능 훅으로 이동했다. +- 침수위험도 배지 색상 계산을 별도 기능 유틸리티로 분리하여 팝업 목록과 지도 오버레이가 함께 사용하도록 했다. +- `map/page.tsx`에는 도시침수지도 레이어 활성화 요청과 팝업 메시지 위임만 남겼다. + +## 수정 대상 파일 + +- `apps/sn/app/(map-layout)/map/page.tsx` +- `apps/sn/src/features/fldngPredcPrst/lib/use-flood-prediction-map.ts` +- `apps/sn/src/features/fldngPredcPrst/lib/flood-risk-grade.ts` +- `apps/sn/src/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent.tsx` + +## 영향 범위 + +- 침수예측현황 행 선택 후 도시침수지도 자동 선택·이동·분석 오버레이 표시 흐름 +- 침수예측현황 목록과 지도 오버레이의 위험도 배지 색상 +- API 경로, 팝업 메시지 타입, 레이어명·레이어 타입과 사용자 동작은 변경하지 않는다. + +## 검증 + +- 신규 기능 파일 ESLint 검사 통과 +- `pnpm --filter sn run typecheck` 통과 +- `git diff --check` 확인 +- 실행 중인 개발 서버의 `/map` 경로 HTTP 응답 확인 +- 실행 중인 개발 서버를 유지하기 위해 별도 빌드는 수행하지 않는다. + +## 롤백 + +- `useFloodPredictionMap`의 상태·콜백·재시도 effect를 `map/page.tsx`로 되돌린다. +- 오버레이 DOM 생성 함수와 날짜 포맷터를 `map/page.tsx`로 되돌린다. +- 위험도 배지 유틸리티를 팝업 컴포넌트 내부 함수로 복원한다. + +## 남은 리스크 + +- 실제 로그인 세션에서 팝업 행을 선택해 레이어 자동 체크, 지도 이동, 오버레이 표시가 연속으로 유지되는지 최종 화면 확인이 필요하다. diff --git a/apps/sn/docs/작업내용/SFR-011-008-침수예측도시침수지도자동선택/001-fe-SFR-011-008-침수예측도시침수지도자동선택-구현기록.md b/apps/sn/docs/작업내용/SFR-011-008-침수예측도시침수지도자동선택/001-fe-SFR-011-008-침수예측도시침수지도자동선택-구현기록.md new file mode 100644 index 000000000..7c5e1187d --- /dev/null +++ b/apps/sn/docs/작업내용/SFR-011-008-침수예측도시침수지도자동선택/001-fe-SFR-011-008-침수예측도시침수지도자동선택-구현기록.md @@ -0,0 +1,42 @@ +# 침수예측현황 도시침수지도 자동 선택 구현기록 + +## 작업 목적 + +- 침수예측현황 팝업에서 행을 선택하면 사용자가 레이어 트리에서 직접 조작하지 않아도 도시침수지도 레이어를 자동으로 체크한다. +- 레이어 활성화 후 선택한 시군구와 빈도에 맞는 위치 이동, 피처 선택, 침수예측 오버레이 표시를 이어서 실행한다. + +## 원본 근거 + +- 요구사항정의서 `3. (기능)요구사항정의 및 추적표` 94행, `SFR-011-008 위험도 산정` +- 화면설계서 슬라이드 24, `SN_MN010205 침수예측현황` +- 화면설계서 슬라이드 28, `SN_MN010206 침수예측지도` +- 자동 레이어 선택 비교 화면: 슬라이드 47, `SN_MN040106 조사결과 지도보기` + +## 변경 내용 + +- 하수관망도에 이미 연결된 `checkedLayerRequest`를 사용하여 `도시침수지도(정보제공포털)` 레이어를 기존 체크 상태에 추가한다. +- 도시침수지도 레이어가 활성화되기 전에 도착한 행 선택 정보는 임시 상태에 보관한다. +- 레이어가 준비되면 기존 시군구·빈도 피처 선택, 지도 범위 이동, 침수예측 오버레이 표시 로직을 실행한다. +- 제한 시간 안에 레이어가 준비되지 않으면 기존 수동 선택 안내 메시지를 팝업에 전달한다. + +## 영향 범위 + +- `apps/sn/app/(map-layout)/map/page.tsx` +- 백엔드 API와 메시지 payload 계약은 변경하지 않는다. +- 기존에 사용자가 체크한 다른 지도 레이어는 유지한다. +- 도시침수지도 미제공 빈도에 대한 기존 안내는 유지한다. + +## 검증 + +- `pnpm --filter sn exec tsc --noEmit` 통과 +- `git diff --check` 통과 +- 개발 서버가 3002 포트에서 실행 중이므로 별도 빌드는 수행하지 않는다. + +## 롤백 + +- `map/page.tsx`의 도시침수지도 자동 체크 요청, 보류 상태, 재시도 effect를 제거하고 기존 레이어 가시성 검사와 수동 선택 안내 흐름으로 복원한다. + +## 남은 확인 + +- 로그인된 브라우저에서 도시침수지도가 꺼진 상태와 이미 켜진 상태를 각각 확인한다. +- 행을 연속 선택했을 때 마지막 선택 행 기준으로 지도와 오버레이가 갱신되는지 확인한다. diff --git a/apps/sn/docs/작업내용/SFR-011-노후관로관리/001-fe-SFR-011-노후관로관리-통합구현기록.md b/apps/sn/docs/작업내용/SFR-011-노후관로관리/001-fe-SFR-011-노후관로관리-통합구현기록.md index bde98f381..762fb9422 100644 --- a/apps/sn/docs/작업내용/SFR-011-노후관로관리/001-fe-SFR-011-노후관로관리-통합구현기록.md +++ b/apps/sn/docs/작업내용/SFR-011-노후관로관리/001-fe-SFR-011-노후관로관리-통합구현기록.md @@ -115,3 +115,12 @@ - 실제 로그인 지도에서 노후관로 범례와 지도 강조선의 색상 일치 여부를 육안 확인해야 한다. - 사용자 스크린샷 기준의 정확한 위치 체감은 실제 브라우저 수동 확인이 필요하다. - 최소화 후 다시 열 때 사용하는 메인 화면의 미니 카드 위치는 이번 라운드 범위에 포함하지 않았다. + +## 2026-10-01 DB 레이어 설정 이관 +- 변경 파일: map/page.tsx, agedPipe/agedPipeUtils.ts, agedPipe/AgedPipePopupPage.tsx, 신규 agedPipe/agedPipeLayer.ts. +- appendAgedPipeControlNode, SN_AGED_PIPE 및 SN_AGED_PIPE_HIGHLIGHT 생성, 스타일 상수 덮어쓰기를 제거했다. +- 기존 설정 API 응답의 레이어 ID·원천·스타일을 사용하고 최초 요청 전에 sql_filt의 ${cutoffYmd}를 치환한다. 조건 변경 시 같은 ID의 레이어만 갱신한다. +- 숨긴 상태의 조건 변경도 재표시할 때 적용한다. 노후관로를 유하방향 관종 스타일 수집에서 제외한다. +- DB 미전환/필터 오류는 해당 행만 요청 차단하고 안내하며 다른 시설물 트리는 유지한다. +- 독립 목록 팝업도 레이어 설정 API를 사용한다. Excel API와 위치 이동 동작은 유지한다. 백엔드/공통 GIS 코드는 변경하지 않았다. +- DB작업.sql에 INSERT 및 기존 139번용 UPDATE를 작성했다. 실제 DB 변경은 미실행이다. diff --git a/apps/sn/package.json b/apps/sn/package.json index 6b461acd4..549ca5749 100644 --- a/apps/sn/package.json +++ b/apps/sn/package.json @@ -9,7 +9,7 @@ "start:sn": "next start -p 3002", "lint": "next lint", "typecheck": "tsc --noEmit --incremental false --pretty false", - "lint:refactor": "eslint src/shared/api/http-transport.ts src/app/providers/app-provider.tsx src/features/dredging src/features/mapLayers src/features/gisManagement --rule \"@typescript-eslint/no-explicit-any:warn\" --max-warnings 22" + "lint:refactor": "eslint src/shared/api/http-transport.ts src/app/providers/app-provider.tsx src/features/dredging src/features/map-layers src/features/gis-management --rule \"@typescript-eslint/no-explicit-any:warn\" --max-warnings 22" }, "dependencies": { "@common/assets": "workspace:*", diff --git a/apps/sn/src/app/providers/app-provider.tsx b/apps/sn/src/app/providers/app-provider.tsx index 1c00693db..edba63a71 100644 --- a/apps/sn/src/app/providers/app-provider.tsx +++ b/apps/sn/src/app/providers/app-provider.tsx @@ -14,6 +14,7 @@ import { PromptDialog } from "@/shared/ui/prompt-dialog"; import { ConfirmDialog } from "@/shared/ui/confirm-dialog"; import { BusinessUserMessageBridge } from "@/shared/ui/business-user-message-bridge"; import { GridCellTooltip } from "@/shared/ui/grid-cell-tooltip"; +import { HttpAuthBridge } from "./http-auth-bridge"; // 개발 도구는 실제 로컬 개발 환경에서만 별도 청크로 불러와 최초 페이지 컴파일 범위를 줄인다. const LazyReactQueryDevtools = dynamic( @@ -29,6 +30,7 @@ const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => { const [queryClient] = useState(() => new QueryClient()); return ( + diff --git a/apps/sn/src/app/providers/http-auth-bridge.tsx b/apps/sn/src/app/providers/http-auth-bridge.tsx new file mode 100644 index 000000000..29eb59342 --- /dev/null +++ b/apps/sn/src/app/providers/http-auth-bridge.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { useEffect } from "react"; +import { HTTP_UNAUTHORIZED_EVENT } from "@/shared/api/service-request-context"; +import { handleUnauthorizedResponse } from "@/entities/auth/lib/http-unauthorized"; + +/** 공통 HTTP의 401 통지와 기존 인증 정책을 앱 조립에서 연결한다. + * 자식 화면의 요청 effect보다 먼저 구독하도록 AppProvider의 첫 자식에 배치한다. + */ +export function HttpAuthBridge() { + useEffect(() => { + // HMR/재마운트 때도 등록한 함수와 정확히 같은 참조로 해제한다. + const onUnauthorized = handleUnauthorizedResponse; + window.addEventListener(HTTP_UNAUTHORIZED_EVENT, onUnauthorized); + return () => window.removeEventListener(HTTP_UNAUTHORIZED_EVENT, onUnauthorized); + }, []); + return null; +} diff --git a/apps/sn/app/components/lib/api/attachment/attachment-service.tsx b/apps/sn/src/entities/attachment/api/attachment-service.ts similarity index 90% rename from apps/sn/app/components/lib/api/attachment/attachment-service.tsx rename to apps/sn/src/entities/attachment/api/attachment-service.ts index 40e3ea639..ce9b71d5b 100644 --- a/apps/sn/app/components/lib/api/attachment/attachment-service.tsx +++ b/apps/sn/src/entities/attachment/api/attachment-service.ts @@ -14,13 +14,8 @@ * - 첨부 파일 삭제: DELETE /attach-service/api/v1/atfl/attachments/atflCd (attach-service) */ import type { AxiosResponse } from "axios"; -import type { - Attachment, - AttachmentList, - AttachmentParams, - AttachmentService, -} from "../attachment/attachment-interface"; -import { getData, deleteData } from "../index"; +import type { Attachment, AttachmentList, AttachmentParams, AttachmentService } from "@/entities/attachment/model/attachment-interface"; +import { getData, deleteData } from "@/shared/api/sewerage-api"; export const ENDPOINT = { ATTACH_SERVICE: { diff --git a/apps/sn/app/components/lib/api/attachment/attachment-interface.tsx b/apps/sn/src/entities/attachment/model/attachment-interface.ts similarity index 100% rename from apps/sn/app/components/lib/api/attachment/attachment-interface.tsx rename to apps/sn/src/entities/attachment/model/attachment-interface.ts diff --git a/apps/sn/src/entities/auth/api/auth-service.ts b/apps/sn/src/entities/auth/api/auth-service.ts index 0320aa474..d42435376 100644 --- a/apps/sn/src/entities/auth/api/auth-service.ts +++ b/apps/sn/src/entities/auth/api/auth-service.ts @@ -19,7 +19,7 @@ import { tokenManager, } from "../lib/token-manager"; -import { clearTabStorePersist } from "../../../store/tab-store"; +import { clearTabStorePersist } from "@/entities/menu/model/tab-store"; async function login(payload: AuthPayload): Promise { const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, { diff --git a/apps/sn/src/entities/auth/lib/auth-wrapper.tsx b/apps/sn/src/entities/auth/lib/auth-wrapper.tsx index 534e1c776..fb6edd37a 100644 --- a/apps/sn/src/entities/auth/lib/auth-wrapper.tsx +++ b/apps/sn/src/entities/auth/lib/auth-wrapper.tsx @@ -5,7 +5,7 @@ import { useRouter } from "next/navigation"; import { isAxiosError } from "axios"; -import { client } from "@/shared/api"; +import { client } from "@/shared/api/client"; import { AUTH_LOGIN_REDIRECT_URL, AUTH_USER_ID, diff --git a/apps/sn/src/entities/auth/lib/http-unauthorized.ts b/apps/sn/src/entities/auth/lib/http-unauthorized.ts new file mode 100644 index 000000000..f0c090efa --- /dev/null +++ b/apps/sn/src/entities/auth/lib/http-unauthorized.ts @@ -0,0 +1,26 @@ +import { CLAIM_NAME } from "@/shared/config/env"; +import { getRemainingMs } from "@common/auth"; +import { tokenManager } from "./token-manager"; + +const LOGIN_PAGE_URL = + process.env.NEXT_PUBLIC_CM_LOGIN_PAGE_URL || "/auth/login"; + +export const handleUnauthorizedResponse = () => { + if (typeof window === "undefined") return; + + const accessToken = tokenManager.getToken(CLAIM_NAME); + const remainingMs = accessToken ? getRemainingMs(accessToken) : null; + const isExpired = remainingMs === null || remainingMs <= 0; + + if (!isExpired) return; + + tokenManager.clearAuthToken(); + + const isLoginPage = + window.location.pathname === "/auth/login" || + window.location.pathname.startsWith("/auth/login/"); + + if (!isLoginPage) { + window.location.assign(LOGIN_PAGE_URL); + } +}; diff --git a/apps/sn/src/entities/auth/lib/use-cookie-auth.ts b/apps/sn/src/entities/auth/lib/use-cookie-auth.ts new file mode 100644 index 000000000..35f78d7ff --- /dev/null +++ b/apps/sn/src/entities/auth/lib/use-cookie-auth.ts @@ -0,0 +1,29 @@ +"use client"; + +import { useEffect, useState } from "react"; + +/** 기존 사업 첨부 화면에서 사용하는 쿠키 hook. HTTP 모듈과 React hook을 분리한다. */ +export function getCookie(name: string) { + const match = document.cookie.match(new RegExp("(^| )" + name + "=([^;]+)")); + if (match && match[2]) { + return decodeURIComponent(match[2]); + } + return null; +} + +export const useCookieAuth = () => { + const [data, setData] = useState(""); + + useEffect(() => { + const fetch = async () => { + const result = getCookie("Authorization"); + if (result) { + setData(result); + } + }; + fetch(); + console.log("useCookieAuth 호출"); + }, []); + + return data; +}; diff --git a/apps/sn/src/shared/lib/use-oper-business-common-codes.ts b/apps/sn/src/entities/business/lib/use-oper-business-common-codes.ts similarity index 97% rename from apps/sn/src/shared/lib/use-oper-business-common-codes.ts rename to apps/sn/src/entities/business/lib/use-oper-business-common-codes.ts index 8959288af..3fe50b3b7 100644 --- a/apps/sn/src/shared/lib/use-oper-business-common-codes.ts +++ b/apps/sn/src/entities/business/lib/use-oper-business-common-codes.ts @@ -2,10 +2,7 @@ import { useEffect, useMemo, useState } from "react"; -import { - getCommonCodes, - type CommonCodeItem, -} from "@/components/lib/common/apiHelpers"; +import { getCommonCodes, type CommonCodeItem } from "@/entities/code/api/common-code-api"; type CodeLabelMap = Record; type OperCodeGroup = diff --git a/apps/sn/app/components/lib/common/apiHelpers.ts b/apps/sn/src/entities/code/api/common-code-api.ts similarity index 54% rename from apps/sn/app/components/lib/common/apiHelpers.ts rename to apps/sn/src/entities/code/api/common-code-api.ts index 9094b75de..f5aa545aa 100644 --- a/apps/sn/app/components/lib/common/apiHelpers.ts +++ b/apps/sn/src/entities/code/api/common-code-api.ts @@ -1,19 +1,6 @@ -/** - * 📄 API Helpers - * 공통 코드 cs-service API 호출 유틸리티 - * - * - 검색 데이터 조회 및 파라미터 처리 - * - 상위 코드로 하위 코드 조회 기능 - * - URL 파라미터 생성 및 필터링 - * - 데이터 페이징 및 총 개수 반환 - * - Axios 기반 HTTP 요청 처리 - * - 타입 안전성 보장을 위한 제네릭 활용 - * - * 🔗 사용 API - * - 하위코드페이지 캐시 목록 조회: GET /api/v1/code/cache/{upCd} (cms-service) - */ -import { getCmsData, getData } from "../api"; +import { getCmsData } from "@/shared/api/external-service-api"; +/** CMS 코드 도메인의 조회·정규화 계약. 기존 값/필터/실패 처리를 유지한다. */ /** CMS 공통코드 캐시 API가 반환하는 하위코드의 공통 계약이다. */ export type CommonCodeItem = { lwpoCd: string; @@ -37,7 +24,9 @@ export const getCommonCodes = async ( `/api/v1/code/cache/${encodeURIComponent(normalizedUpCd)}`, ); if (!Array.isArray(data)) { - throw new Error(`공통코드 응답 형식이 올바르지 않습니다: ${normalizedUpCd}`); + throw new Error( + `공통코드 응답 형식이 올바르지 않습니다: ${normalizedUpCd}`, + ); } return data @@ -54,20 +43,7 @@ export const getCommonCodes = async ( .filter((item) => item.lwpoCd.length > 0); }; -export const searchData = async>(url: string, paramObj: T) => { - const params = new URLSearchParams( - Object.entries(paramObj) - .filter(([_, value]) => value !== undefined && value !== '') // 값이 있는 항목만 포함 - .reduce((acc, [key, value]) => { - acc[key] = String(value); - return acc; - }, {} as Record) - ); - const data = await getData(`${url}?${params.toString()}`); - return data && { data: data.content, total: data.totalElements } -} - -// 상위코드로 하위코드 조회 +// 상위코드로 하위코드 조회 export const searchCmmnCd = async (upCd: string) => { const data = await getCommonCodes(upCd); const filteredData = data.map((item: any) => ({ diff --git a/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeUtils.ts b/apps/sn/src/entities/facility/lib/aged-pipe-utils.ts similarity index 76% rename from apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeUtils.ts rename to apps/sn/src/entities/facility/lib/aged-pipe-utils.ts index 8fb0f963a..bba420f60 100644 --- a/apps/sn/app/(map-layout)/map/components/agedPipe/agedPipeUtils.ts +++ b/apps/sn/src/entities/facility/lib/aged-pipe-utils.ts @@ -2,11 +2,11 @@ "use client"; import type { SelectDataType } from "@common/business_components"; -import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue"; +import { formatDisplayData } from "@/components/lib/utils/format-display-value"; import { MS_SWAGE_PPL_CODES, MS_SWAGE_PPL_USE_CODES, -} from "../common/table"; +} from "@/entities/facility/model/facility-code-fallbacks"; export const AGED_PIPE_LAYER_TYPE = "tb_ms_swage_ppl"; export const AGED_PIPE_DEFAULT_ELAPSED_YEARS = 30; @@ -39,8 +39,12 @@ export type AgedPipeRow = { type PipeSource = Record; -const useMap = new Map(MS_SWAGE_PPL_USE_CODES.map((item) => [item.val.trim(), item.text])); -const materialMap = new Map(MS_SWAGE_PPL_CODES.map((item) => [item.val.trim(), item.text])); +const useMap = new Map( + MS_SWAGE_PPL_USE_CODES.map((item) => [item.val.trim(), item.text]), +); +const materialMap = new Map( + MS_SWAGE_PPL_CODES.map((item) => [item.val.trim(), item.text]), +); export const AGED_PIPE_DEFAULT_FILTERS: AgedPipeFilters = { elapsedYears: AGED_PIPE_DEFAULT_ELAPSED_YEARS, @@ -80,7 +84,9 @@ export const getAgedPipeManagementName = ( const directName = String(row.mngInstNm ?? row.mng_inst_nm ?? "").trim(); if (directName && directName !== "-") return directName; const code = String(row.mngInstCd ?? row.mng_inst_cd ?? "").trim(); - const agency = agencies.find((item) => String(item.mngInstCd).trim() === code); + const agency = agencies.find( + (item) => String(item.mngInstCd).trim() === code, + ); return String(agency?.lwrInstNm ?? "").trim() || code || "-"; }; @@ -168,11 +174,15 @@ export const AGED_PIPE_EXCEL_HEADER_RANGE = `A1:${getExcelColumnLetter(AGED_PIPE const pad2 = (value: number) => String(value).padStart(2, "0"); const normalizeYmd = (value: unknown) => - String(value ?? "").replace(/[^0-9]/g, "").slice(0, 8); + String(value ?? "") + .replace(/[^0-9]/g, "") + .slice(0, 8); const getToday = () => new Date(); -export const normalizeAgedPipeFilters = (filters: AgedPipeFilters): AgedPipeFilters => { +export const normalizeAgedPipeFilters = ( + filters: AgedPipeFilters, +): AgedPipeFilters => { const elapsedYears = Math.trunc(Number(filters.elapsedYears)); return { @@ -191,7 +201,11 @@ export const validateElapsedYears = (value: number) => export const getCutoffYmd = (elapsedYears: number) => { const today = getToday(); - const cutoff = new Date(today.getFullYear() - elapsedYears, today.getMonth(), today.getDate()); + const cutoff = new Date( + today.getFullYear() - elapsedYears, + today.getMonth(), + today.getDate(), + ); return `${cutoff.getFullYear()}${pad2(cutoff.getMonth() + 1)}${pad2(cutoff.getDate())}`; }; @@ -220,7 +234,9 @@ export const isInvalidInstallYmd = (value: unknown) => { const normalized = normalizeYmd(value); return ( normalized.length < 8 || - AGED_PIPE_INVALID_INSTALL_YMD_PREFIXES.some((prefix) => normalized.startsWith(prefix)) + AGED_PIPE_INVALID_INSTALL_YMD_PREFIXES.some((prefix) => + normalized.startsWith(prefix), + ) ); }; @@ -229,13 +245,25 @@ export const getElapsedYearsDisplayText = (value: unknown) => { return elapsedYears == null ? "확인불가" : `${elapsedYears}년`; }; -export const buildAgedPipeCqlFilters = (filters: AgedPipeFilters) => { +/** DB의 SQL 필터 템플릿을 해석한다. 누락/미지원 토큰은 전체 관로 조회로 대체하지 않는다. */ +export const buildAgedPipeCqlFilters = ( + filters: AgedPipeFilters, + filterTemplate: string, +) => { const normalized = normalizeAgedPipeFilters(filters); - const cqlFilters = [ - "instl_ymd IS NOT NULL", - ...AGED_PIPE_INVALID_INSTALL_YMD_PREFIXES.map((prefix) => `instl_ymd NOT LIKE '${prefix}%'`), - `instl_ymd <= '${getCutoffYmd(normalized.elapsedYears)}'`, - ]; + const template = String(filterTemplate ?? "").trim(); + if (!template.includes("${cutoffYmd}")) { + throw new Error( + "노후관로 DB sql_filt에 ${cutoffYmd} 조건을 등록해 주세요.", + ); + } + const filter = template + .split("${cutoffYmd}") + .join(getCutoffYmd(normalized.elapsedYears)); + if (/\$\{[^}]*\}/.test(filter)) { + throw new Error("노후관로 DB sql_filt에 지원하지 않는 치환값이 있습니다."); + } + const cqlFilters = [`(${filter})`]; if (normalized.sggCd !== "0") { cqlFilters.push(`pbadms_emd_cd LIKE '${normalized.sggCd}%'`); @@ -249,14 +277,23 @@ export const buildAgedPipeCqlFilters = (filters: AgedPipeFilters) => { const getRegionCode = (source: PipeSource) => String(source.pbadmsEmdCd ?? source.pbadms_emd_cd ?? "").trim(); -export const matchesAgedPipeFilters = (source: PipeSource, filters: AgedPipeFilters) => { +export const matchesAgedPipeFilters = ( + source: PipeSource, + filters: AgedPipeFilters, +) => { const normalized = normalizeAgedPipeFilters(filters); const instlYmd = normalizeYmd(source.instl_ymd ?? source.instlYmd); - if (!instlYmd || isInvalidInstallYmd(instlYmd) || instlYmd > getCutoffYmd(normalized.elapsedYears)) return false; + if ( + !instlYmd || + isInvalidInstallYmd(instlYmd) || + instlYmd > getCutoffYmd(normalized.elapsedYears) + ) + return false; const regionCode = getRegionCode(source); if (normalized.sggCd !== "0") return regionCode.startsWith(normalized.sggCd); - if (normalized.ctpvCd !== "0") return regionCode.startsWith(normalized.ctpvCd); + if (normalized.ctpvCd !== "0") + return regionCode.startsWith(normalized.ctpvCd); return true; }; @@ -279,16 +316,28 @@ export const toDisplayPipeRow = ( page: number, ctpvOptions: SelectDataType[], sggOptions: SelectDataType[], + catalog?: { + MS_SWAGE_PPL_USE_CODES: { val: string; text: string }[]; + MS_SWAGE_PPL_CODES: { val: string; text: string }[]; + }, ): AgedPipeRow => { const formatted = formatDisplayData(source) as PipeSource; const codePrefix = getRegionCode(source); - const instlYmd = formatted.instlYmd ?? source.instlYmd ?? formatted.instl_ymd ?? source.instl_ymd; - const invalidInstallYmd = isInvalidInstallYmd(source.instlYmd ?? source.instl_ymd ?? instlYmd); + const instlYmd = + formatted.instlYmd ?? + source.instlYmd ?? + formatted.instl_ymd ?? + source.instl_ymd; + const invalidInstallYmd = isInvalidInstallYmd( + source.instlYmd ?? source.instl_ymd ?? instlYmd, + ); const trimmedUse = String(source.usgCd ?? source.usg_cd ?? "").trim(); const trimmedMaterial = String(source.mqltCd ?? source.mqlt_cd ?? "").trim(); const pdmtVl = formatted.pdmtVl ?? source.pdmtVl ?? source.pdmt_vl ?? "-"; const prlgVl = formatted.prlgVl ?? source.prlgVl ?? source.prlg_vl ?? "-"; - const fcltMngNo = String(formatted.fcltMngNo ?? source.fcltMngNo ?? source.fclt_mng_no ?? "-"); + const fcltMngNo = String( + formatted.fcltMngNo ?? source.fcltMngNo ?? source.fclt_mng_no ?? "-", + ); return { ...source, @@ -301,11 +350,22 @@ export const toDisplayPipeRow = ( instlYmd: invalidInstallYmd ? "확인불가" : String(instlYmd ?? "-"), pdmtVl: typeof pdmtVl === "number" ? pdmtVl : String(pdmtVl), prlgVl: typeof prlgVl === "number" ? prlgVl : String(prlgVl), - usgCd: useMap.get(trimmedUse) ?? String(formatted.usgCd ?? source.usgCd ?? source.usg_cd ?? "-"), + usgCd: + (catalog + ? catalog.MS_SWAGE_PPL_USE_CODES.find( + (item) => item.val.trim() === trimmedUse, + )?.text + : useMap.get(trimmedUse)) ?? + String(formatted.usgCd ?? source.usgCd ?? source.usg_cd ?? "-"), mqltCd: - materialMap.get(trimmedMaterial) ?? + (catalog + ? catalog.MS_SWAGE_PPL_CODES.find( + (item) => item.val.trim() === trimmedMaterial, + )?.text + : materialMap.get(trimmedMaterial)) ?? String(formatted.mqltCd ?? source.mqltCd ?? source.mqlt_cd ?? "-"), - elapsedYears: getElapsedYears(source.instlYmd ?? source.instl_ymd) ?? "확인불가", + elapsedYears: + getElapsedYears(source.instlYmd ?? source.instl_ymd) ?? "확인불가", ctpvNm: resolveRegionName( codePrefix, formatted.ctpvNm ?? source.ctpvNm ?? source.ctpv_nm, @@ -336,8 +396,5 @@ export const isAgedPipeLayerNode = (node: any) => { const layerName = String(data?.lyrNm ?? node?.text ?? ""); const layerTypeName = String(data?.lyrTyNm ?? ""); - return ( - layerTypeName === "tb_old_pipe" || - layerName.includes("노후관로") - ); + return layerTypeName === "tb_old_pipe" || layerName.includes("노후관로"); }; diff --git a/apps/sn/app/(map-layout)/map/components/common/codeMapper.ts b/apps/sn/src/entities/facility/lib/code-mapper.ts similarity index 52% rename from apps/sn/app/(map-layout)/map/components/common/codeMapper.ts rename to apps/sn/src/entities/facility/lib/code-mapper.ts index df355381e..6ddb3402c 100644 --- a/apps/sn/app/(map-layout)/map/components/common/codeMapper.ts +++ b/apps/sn/src/entities/facility/lib/code-mapper.ts @@ -1,9 +1,22 @@ /** 지도 시설 상세정보의 코드값을 업무 표시명으로 변환한다. */ -import { MS_SWAGE_PPL_USE_CODES, MS_SWAGE_PPL_CODES, MS_SWAGE_MHL_CODES, MS_SWAGE_MHL_USE_CODES, DRTW_SEWAGE_PUMP_USE_CODES } from './table'; +import { + MS_SWAGE_PPL_USE_CODES, + MS_SWAGE_PPL_CODES, + MS_SWAGE_MHL_CODES, + MS_SWAGE_MHL_USE_CODES, + DRTW_SEWAGE_PUMP_USE_CODES, +} from "@/entities/facility/model/facility-code-fallbacks"; interface CodeMapperProps { layerName: string; data: any; + /** 호출 화면에서 확인한 CMS 사전. 미주입 시 원래 코드 사전을 유지한다. */ + catalog?: { + MS_SWAGE_PPL_USE_CODES: { val: string; text: string }[]; + MS_SWAGE_PPL_CODES: { val: string; text: string }[]; + MS_SWAGE_MHL_CODES: { val: string; text: string }[]; + MS_SWAGE_MHL_USE_CODES: { val: string; text: string }[]; + }; } /** @@ -12,18 +25,22 @@ interface CodeMapperProps { * @param data - 변환할 데이터 객체 * @returns 변환된 데이터 객체 */ -export const codeMapper = ({ layerName, data }: CodeMapperProps): any => { +export const codeMapper = ({ + layerName, + data, + catalog, +}: CodeMapperProps): any => { if (!data) return data; const processedData = { ...data }; switch (layerName) { - case '하수관로': + case "하수관로": // usg_cd 값 변환 (용도코드) if (processedData.usg_cd) { - const usageCodeItem = MS_SWAGE_PPL_USE_CODES.find( - code => code.val === processedData.usg_cd.trim() - ); + const usageCodeItem = ( + catalog?.MS_SWAGE_PPL_USE_CODES ?? MS_SWAGE_PPL_USE_CODES + ).find((code) => code.val === processedData.usg_cd.trim()); if (usageCodeItem) { processedData.usg_cd = usageCodeItem.text; } @@ -31,21 +48,21 @@ export const codeMapper = ({ layerName, data }: CodeMapperProps): any => { // mqlt_cd 값 변환 (재질코드) if (processedData.mqlt_cd) { - const materialCodeItem = MS_SWAGE_PPL_CODES.find( - code => code.val === processedData.mqlt_cd.trim() - ); + const materialCodeItem = ( + catalog?.MS_SWAGE_PPL_CODES ?? MS_SWAGE_PPL_CODES + ).find((code) => code.val === processedData.mqlt_cd.trim()); if (materialCodeItem) { processedData.mqlt_cd = materialCodeItem.text; } } break; - case '하수맨홀': + case "하수맨홀": // usg_cd 값 변환 (용도코드) if (processedData.usg_cd) { - const usageCodeItem = MS_SWAGE_MHL_USE_CODES.find( - code => code.val.trim() === processedData.usg_cd.trim() - ); + const usageCodeItem = ( + catalog?.MS_SWAGE_MHL_USE_CODES ?? MS_SWAGE_MHL_USE_CODES + ).find((code) => code.val.trim() === processedData.usg_cd.trim()); if (usageCodeItem) { processedData.usg_cd = usageCodeItem.text; } @@ -53,20 +70,20 @@ export const codeMapper = ({ layerName, data }: CodeMapperProps): any => { // mqlt_cd 값 변환 (재질코드) if (processedData.mqlt_cd) { - const materialCodeItem = MS_SWAGE_MHL_CODES.find( - code => code.val === processedData.mqlt_cd.trim() - ); + const materialCodeItem = ( + catalog?.MS_SWAGE_MHL_CODES ?? MS_SWAGE_MHL_CODES + ).find((code) => code.val === processedData.mqlt_cd.trim()); if (materialCodeItem) { processedData.mqlt_cd = materialCodeItem.text; } } break; - case '하수펌프장': + case "하수펌프장": // usg_cd 값 변환 (용도코드) if (processedData.usg_cd) { const usageCodeItem = DRTW_SEWAGE_PUMP_USE_CODES.find( - code => code.val === processedData.usg_cd.trim() + (code) => code.val === processedData.usg_cd.trim(), ); if (usageCodeItem) { processedData.usg_cd = usageCodeItem.text; @@ -74,26 +91,30 @@ export const codeMapper = ({ layerName, data }: CodeMapperProps): any => { } break; - case '우수토실': + case "우수토실": // prrtymngYn 값 변환 (중점여부) if (processedData.prrtymngYn) { - if (processedData.prrtymngYn === 'Y') { - processedData.prrtymngYn = '중점우수토실'; - } else if (processedData.prrtymngYn === 'N') { - processedData.prrtymngYn = '우수토실'; + if (processedData.prrtymngYn === "Y") { + processedData.prrtymngYn = "중점우수토실"; + } else if (processedData.prrtymngYn === "N") { + processedData.prrtymngYn = "우수토실"; } } break; - case '중점우수토실': + case "중점우수토실": if (processedData.manage_no) { processedData.mqlt_cd = processedData.manage_no; } if (processedData.mesure_spot_se) { - const mesureSpotSe = [{ id: '0', text: '합류식' }, { id: '1', text: '분류식' }]; + const mesureSpotSe = [ + { id: "0", text: "합류식" }, + { id: "1", text: "분류식" }, + ]; const mesureSpotSeItem = mesureSpotSe.find( - code => code.id === processedData.mesure_spot_se.trim() + (code) => code.id === processedData.mesure_spot_se.trim(), ); - if (mesureSpotSeItem) processedData.mesure_spot_se = mesureSpotSeItem.text; + if (mesureSpotSeItem) + processedData.mesure_spot_se = mesureSpotSeItem.text; } break; @@ -111,40 +132,44 @@ export const codeMapper = ({ layerName, data }: CodeMapperProps): any => { * @param data - 역변환할 데이터 객체 * @returns 역변환된 데이터 객체 */ -export const reverseCodeMapper = ({ layerName, data }: CodeMapperProps): any => { +export const reverseCodeMapper = ({ + layerName, + data, + catalog, +}: CodeMapperProps): any => { if (!data) return data; const processedData = { ...data }; switch (layerName) { - case '하수맨홀': + case "하수맨홀": // mqlt_cd 값 역변환 (텍스트 → 코드) - if (processedData.mqlt_cd && typeof processedData.mqlt_cd === 'string') { - const materialCodeItem = MS_SWAGE_MHL_CODES.find( - code => code.text === processedData.mqlt_cd.trim() - ); + if (processedData.mqlt_cd && typeof processedData.mqlt_cd === "string") { + const materialCodeItem = ( + catalog?.MS_SWAGE_MHL_CODES ?? MS_SWAGE_MHL_CODES + ).find((code) => code.text === processedData.mqlt_cd.trim()); if (materialCodeItem) { processedData.mqlt_cd = materialCodeItem.val; } } break; - case '우수토실': + case "우수토실": // prrtymngYn 값 역변환 (텍스트 → Y/N) if (processedData.prrtymngYn) { - if (processedData.prrtymngYn === '중점우수토실') { - processedData.prrtymngYn = 'Y'; - } else if (processedData.prrtymngYn === '우수토실') { - processedData.prrtymngYn = 'N'; + if (processedData.prrtymngYn === "중점우수토실") { + processedData.prrtymngYn = "Y"; + } else if (processedData.prrtymngYn === "우수토실") { + processedData.prrtymngYn = "N"; } } break; - case '하수펌프장': + case "하수펌프장": // usg_cd 값 역변환 (텍스트 → 코드) - if (processedData.usg_cd && typeof processedData.usg_cd === 'string') { + if (processedData.usg_cd && typeof processedData.usg_cd === "string") { const usageCodeItem = DRTW_SEWAGE_PUMP_USE_CODES.find( - code => code.text === processedData.usg_cd.trim() + (code) => code.text === processedData.usg_cd.trim(), ); if (usageCodeItem) { processedData.usg_cd = usageCodeItem.val; diff --git a/apps/sn/src/entities/facility/lib/use-facility-code-catalog.ts b/apps/sn/src/entities/facility/lib/use-facility-code-catalog.ts new file mode 100644 index 000000000..d2e3a97d6 --- /dev/null +++ b/apps/sn/src/entities/facility/lib/use-facility-code-catalog.ts @@ -0,0 +1,84 @@ +"use client"; + +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { getCommonCodes } from "@/entities/code/api/common-code-api"; +import * as fallback from "../model/facility-code-fallbacks"; + +type CodeOption = { val: string; text: string }; + +/** 코드명은 CMS가 소유한다. 초기 렌더/장애 시에는 기존 사전을 유지하고 저장 코드값은 바꾸지 않는다. */ +function useFacilityCodeOptions(group: string, initial: CodeOption[]) { + const query = useQuery({ + queryKey: ["sn", "facility-code", group], + queryFn: async () => { + const rows = await getCommonCodes(group); + if (!rows.length) + throw new Error(`시설 코드그룹이 비어 있습니다: ${group}`); + return rows.map((row) => ({ val: row.lwpoCd, text: row.lwpoCdNm })); + }, + staleTime: 5 * 60 * 1000, + retry: false, + }); + return { + options: query.data ?? initial, + isFallback: !query.data, + error: query.error, + }; +} + +/** 확인된 5개 OGC 그룹만 조회한다. 의미 차이가 기록된 펌프 용도 OGC021은 자동 전환하지 않는다. */ +export function useFacilityCodeCatalog() { + const material = useFacilityCodeOptions( + "OGC004", + fallback.MS_SWAGE_PPL_CODES, + ); + const pipeUse = useFacilityCodeOptions( + "OGC011", + fallback.MS_SWAGE_PPL_USE_CODES, + ); + const shape = useFacilityCodeOptions("OGC002", fallback.MS_SWAGE_PPL_SHAPE); + const cover = useFacilityCodeOptions("OGC008", fallback.MS_SWAGE_MHL_CODES); + const manholeUse = useFacilityCodeOptions( + "OGC007", + fallback.MS_SWAGE_MHL_USE_CODES, + ); + return useMemo( + () => ({ + MS_SWAGE_PPL_CODES: material.options, + MS_SWAGE_PPL_USE_CODES: pipeUse.options, + MS_SWAGE_PPL_SHAPE: shape.options, + MS_SWAGE_MHL_CODES: cover.options, + MS_SWAGE_MHL_USE_CODES: manholeUse.options, + isFallback: + material.isFallback || + pipeUse.isFallback || + shape.isFallback || + cover.isFallback || + manholeUse.isFallback, + error: + material.error || + pipeUse.error || + shape.error || + cover.error || + manholeUse.error, + }), + [ + material.options, + pipeUse.options, + shape.options, + cover.options, + manholeUse.options, + material.isFallback, + pipeUse.isFallback, + shape.isFallback, + cover.isFallback, + manholeUse.isFallback, + material.error, + pipeUse.error, + shape.error, + cover.error, + manholeUse.error, + ], + ); +} diff --git a/apps/sn/src/entities/facility/model/facility-code-fallbacks.ts b/apps/sn/src/entities/facility/model/facility-code-fallbacks.ts new file mode 100644 index 000000000..cbd9846da --- /dev/null +++ b/apps/sn/src/entities/facility/model/facility-code-fallbacks.ts @@ -0,0 +1,358 @@ +/** 기존 시설 코드명 fallback 계약. 구조 분리 시 값은 유지한다. */ +// 하수관로 용도 코드표 +export const MS_SWAGE_PPL_USE_CODES = [ + { val: "SBA000", text: "미분류" }, + { val: "SBA001", text: "합류관" }, + { val: "SBA002", text: "차집관" }, + { val: "SBA003", text: "우수관" }, + { val: "SBA004", text: "오수관" }, + { val: "SBA999", text: "기타" }, +]; + +// 하수관로 재질 코드표 +export const MS_SWAGE_PPL_CODES = [ + { val: "MOP000", text: "미분류" }, + { val: "MOP001", text: "닥타일주철관(DCIP)" }, + { val: "MOP002", text: "강관(SP)" }, + { val: "MOP003", text: "아연도강관(GS)" }, + { val: "MOP004", text: "염화비닐관(PVC)" }, + { val: "MOP005", text: "폴리에틸렌관(PE)" }, + { val: "MOP006", text: "스테인레스관(SSP)" }, + { val: "MOP007", text: "동관(COP)" }, + { val: "MOP008", text: "P.S.콘크리트관(PC)" }, + { val: "MOP009", text: "석면시멘트관(AS)" }, + { val: "MOP010", text: "회주철관(CIP)" }, + { val: "MOP011", text: "연관(LP)" }, + { val: "MOP012", text: "흉관(HP)" }, + { val: "MOP013", text: "철근콘크리트관(RC)" }, + { val: "MOP014", text: "강관(ST)" }, + { val: "MOP015", text: "주철관(CI)" }, + { val: "MOP016", text: "화학제품류관(PV)" }, + { val: "MOP017", text: "도관(VC)" }, + { val: "MOP018", text: "석축(SE)" }, + { val: "MOP019", text: "도복장 강관(SP)" }, + { val: "MOP020", text: "흉관(EP)" }, + { val: "MOP021", text: "아연도강관(GP)" }, + { val: "MOP022", text: "청동관(BR)" }, + { val: "MOP023", text: "고무몰딩(RM)" }, + { val: "MOP024", text: "주강(CS)" }, + { val: "MOP025", text: "VR관" }, + { val: "MOP026", text: "HDPE관" }, + { val: "MOP027", text: "시멘트라이닝주철관(C)" }, + { val: "MOP028", text: "수도용 PE관(PE)" }, + { val: "MOP029", text: "석면시멘트관(AC)" }, + { val: "MOP030", text: "폴리에틸렌분체라이닝강관(PEP)" }, + { val: "MOP031", text: "에폭시코팅강관(EPS)" }, + { val: "MOP032", text: "HI-3P" }, + { val: "MOP033", text: "철근콘크리트박스(RCB)" }, + { val: "MOP034", text: "콘크리트중선함(CD)" }, + { val: "MOP035", text: "후랙시불전선함(FP)" }, + { val: "MOP036", text: "전선함(FD)" }, + { val: "MOP038", text: "이중보온관" }, + { val: "MOP039", text: "폴리에틸렌피복강관(PLP)" }, + { val: "MOP041", text: "FRP" }, + { val: "MOP042", text: "강재" }, + { val: "MOP999", text: "기타" }, + { val: "MOP800", text: "2중벽PE관" }, + { val: "MOP801", text: "3중벽PE관" }, + { val: "MOP802", text: "가스배관용 강관" }, + { val: "MOP803", text: "강관(피복 Double, 용접접합)" }, + { val: "MOP804", text: "강관(피복 Single, 용접접합)" }, + { val: "MOP805", text: "강관(CP)" }, + { val: "MOP806", text: "강철" }, + { val: "MOP807", text: "강화플라스틱복합관" }, + { val: "MOP808", text: "경질염화비닐관(HI-VP)" }, + { val: "MOP809", text: "고강성PVC이중벽관" }, + { val: "MOP810", text: "고광도PVC이중벽관" }, + { val: "MOP811", text: "고밀도폴리에틸렌관(THP)" }, + { val: "MOP812", text: "고밀도폴리에틸렌파상형이중벽관" }, + { val: "MOP813", text: "공장제 박스관거" }, + { val: "MOP814", text: "관로없음(알케이블)" }, + { val: "MOP815", text: "나선형강관" }, + { val: "MOP816", text: "내충격수도관" }, + { val: "MOP817", text: "내충격용하수도용경질염화비닐관(HI-VG)" }, + { val: "MOP818", text: "내충격HI-VG2관" }, + { val: "MOP819", text: "내충격PVC하수관" }, + { val: "MOP820", text: "녹석(돌산호과 일종)" }, + { val: "MOP821", text: "동관(구리관, 용접)" }, + { val: "MOP822", text: "둥근잔돌" }, + { val: "MOP823", text: "무근콘크리트" }, + { val: "MOP824", text: "백관" }, + { val: "MOP825", text: "복합철판파형관" }, + { val: "MOP826", text: "분체라이닝관" }, + { val: "MOP827", text: "석축박스" }, + { val: "MOP828", text: "석축철콘" }, + { val: "MOP829", text: "수도용경질염화비관" }, + { val: "MOP830", text: "수도용경질염화비닐이음관" }, + { val: "MOP831", text: "수도용엑포시수지분체내외면코팅강관" }, + { val: "MOP832", text: "수지파형강관(PCS)" }, + { val: "MOP833", text: "수지CSP" }, + { val: "MOP834", text: "스테인레스 주름관" }, + { val: "MOP835", text: "스테인레스관(STS)" }, + { val: "MOP836", text: "스테인레스스틸파이프관" }, + { val: "MOP837", text: "아스콘" }, + { val: "MOP838", text: "아스팔트" }, + { val: "MOP839", text: "아스팔트도복장강관(ASP)" }, + { val: "MOP840", text: "아연" }, + { val: "MOP841", text: "아연도금(백관)" }, + { val: "MOP842", text: "알루미늄" }, + { val: "MOP843", text: "알루미늄합금" }, + { val: "MOP844", text: "압력배관용 탄소강관" }, + { val: "MOP845", text: "양철관" }, + { val: "MOP846", text: "양철주름관(CSP)" }, + { val: "MOP847", text: "연합인슈강판" }, + { val: "MOP848", text: "와사관(GSP)" }, + { val: "MOP849", text: "원심력철근콘크리트" }, + { val: "MOP850", text: "유리섬유관(GRP)" }, + { val: "MOP851", text: "유리섬유복합관(GRP)" }, + { val: "MOP852", text: "이음매없는동및동합금관" }, + { val: "MOP853", text: "일반배관용스테인리스강관" }, + { val: "MOP854", text: "전압 콘크리트관" }, + { val: "MOP855", text: "주름관" }, + { val: "MOP856", text: "주름주철파형강관" }, + { val: "MOP857", text: "지중 저압케이블 입상관" }, + { val: "MOP858", text: "진동및전압철근콘크리트관" }, + { val: "MOP859", text: "철근콘크리트" }, + { val: "MOP860", text: "철근콘크리트 블록" }, + { val: "MOP861", text: "철근콘크리트조립흙막이" }, + { val: "MOP862", text: "철판매입관" }, + { val: "MOP863", text: "청동" }, + { val: "MOP864", text: "코아식프리스트레스트콘크리트관" }, + { val: "MOP865", text: "콘크리트" }, + { val: "MOP866", text: "콘크리트 폴륨관" }, + { val: "MOP867", text: "콘크리트블록" }, + { val: "MOP868", text: "탄소강" }, + { val: "MOP869", text: "탄소강(카본스틸)" }, + { val: "MOP870", text: "탄소주강" }, + { val: "MOP871", text: "트러후" }, + { val: "MOP872", text: "파선형강관(RCF)" }, + { val: "MOP873", text: "파형강관(CSP)" }, + { val: "MOP874", text: "파형관" }, + { val: "MOP875", text: "파형플라스틱관" }, + { val: "MOP876", text: "폴리머관" }, + { val: "MOP877", text: "폴리에스테르수지콘크리트관" }, + { val: "MOP878", text: "폴리에틸렌 전선관" }, + { val: "MOP879", text: "폴리에틸렌 피복강관" }, + { val: "MOP880", text: "폴리에틸렌관 1호관" }, + { val: "MOP881", text: "폴리에틸렌관 2호관" }, + { val: "MOP882", text: "폴리에틸렌관(2중벽)" }, + { val: "MOP883", text: "폴리에틸렌관(3중벽)" }, + { val: "MOP884", text: "폴리에틸렌분체라이닝" }, + { val: "MOP885", text: "폴리에틸렌이중벽하수관" }, + { val: "MOP886", text: "폴리탄산에스테르" }, + { val: "MOP887", text: "프리스트레스트콘크리트실린더관" }, + { val: "MOP888", text: "플라스틱" }, + { val: "MOP889", text: "플라스틱주름관" }, + { val: "MOP890", text: "하수도용고강성경질염화비닐이중벽주름관" }, + { val: "MOP891", text: "현장타설관" }, + { val: "MOP892", text: "현장타설철근콘크리트관" }, + { val: "MOP893", text: "흑관" }, + { val: "MOP894", text: "흙" }, + { val: "MOP895", text: "AP" }, + { val: "MOP896", text: "API관" }, + { val: "MOP897", text: "ASP" }, + { val: "MOP898", text: "CMCIP" }, + { val: "MOP899", text: "CSP" }, + { val: "MOP900", text: "DRP" }, + { val: "MOP901", text: "DSP" }, + { val: "MOP902", text: "DTC관" }, + { val: "MOP903", text: "ELP" }, + { val: "MOP904", text: "EPP" }, + { val: "MOP905", text: "FC관" }, + { val: "MOP906", text: "H" }, + { val: "MOP907", text: "H6" }, + { val: "MOP908", text: "HCP" }, + { val: "MOP909", text: "HI_2P관" }, + { val: "MOP910", text: "HI-KP" }, + { val: "MOP911", text: "HL" }, + { val: "MOP912", text: "IP" }, + { val: "MOP913", text: "KCGP" }, + { val: "MOP914", text: "KP관" }, + { val: "MOP915", text: "P2S관" }, + { val: "MOP916", text: "PC박스(PCB)" }, + { val: "MOP917", text: "PCS" }, + { val: "MOP918", text: "PDP" }, + { val: "MOP919", text: "PE이중벽관" }, + { val: "MOP920", text: "PE적측벽1종관" }, + { val: "MOP921", text: "PFP관" }, + { val: "MOP922", text: "PG관" }, + { val: "MOP923", text: "PIP관(이중보온관)" }, + { val: "MOP924", text: "PLCSP" }, + { val: "MOP925", text: "PS" }, + { val: "MOP926", text: "PUB" }, + { val: "MOP927", text: "PVB" }, + { val: "MOP928", text: "PVC이중벽관(PVC2)" }, + { val: "MOP929", text: "PVC-DC" }, + { val: "MOP930", text: "PVC-DV" }, + { val: "MOP931", text: "SBK" }, + { val: "MOP932", text: "SCP" }, + { val: "MOP933", text: "SEB" }, + { val: "MOP934", text: "SIS관(이중보온관)" }, + { val: "MOP935", text: "SMC" }, + { val: "MOP936", text: "STW" }, + { val: "MOP937", text: "SUS" }, + { val: "MOP938", text: "THP나선관" }, + { val: "MOP939", text: "U관" }, + { val: "MOP940", text: "VG2" }, + { val: "MOP941", text: "WSP(와사피)" }, + { val: "MOP942", text: "Steel Grating" }, + { val: "MOP943", text: "모래" }, + { val: "MOP944", text: "인조석" }, + { val: "MOP945", text: "SPOL" }, + { val: "MOP946", text: "PCF관(강판보강 PE 나선형관)" }, + { val: "MOP947", text: "PVC3(PVC3중벽관)" }, + { val: "MOP948", text: "PE다중벽관" }, + { val: "MOP977", text: "전주용입상관" }, + { val: "MOP978", text: "MD관" }, +]; + +// 하수관로 'fcltyShpCd' '시설물형태코드' +export const MS_SWAGE_PPL_SHAPE = [ + { val: "FOR000 ", text: "미분류" }, + { val: "FOR001 ", text: "원형" }, + { val: "FOR002 ", text: "원추형" }, + { val: "FOR003 ", text: "원통형" }, + { val: "FOR004 ", text: "구형" }, + { val: "FOR005 ", text: "타원형" }, + { val: "FOR006 ", text: "난원" }, + { val: "FOR007 ", text: "반원형" }, + + { val: "FOR010 ", text: "피라미드형" }, + { val: "FOR011 ", text: "사면체형" }, + { val: "FOR012 ", text: "기둥,축" }, + { val: "FOR013 ", text: "돛대,활대처럼 둥글고 긴 원재" }, + { val: "FOR014 ", text: "아치형" }, + { val: "FOR015 ", text: "다중아치형" }, + { val: "FOR016 ", text: "돔형" }, + { val: "FOR017 ", text: "고형체형" }, + + { val: "FOR020 ", text: "사각형" }, + { val: "FOR021 ", text: "평방형" }, + { val: "FOR022 ", text: "다이아몬드형" }, + { val: "FOR023 ", text: "사다리꼴" }, + { val: "FOR024 ", text: "각형" }, + { val: "FOR025 ", text: "마제형관" }, + { val: "FOR026 ", text: "태형" }, + + { val: "FOR030 ", text: "A형" }, + { val: "FOR031 ", text: "H형" }, + { val: "FOR032 ", text: "I형" }, + { val: "FOR033 ", text: "Y형" }, + { val: "FOR034 ", text: "T형" }, + + { val: "FOR050 ", text: "불규칙한 형태" }, + + { val: "FOR900 ", text: "계란형" }, + { val: "FOR901 ", text: "구(사각)형" }, + { val: "FOR902 ", text: "높이조절철개형" }, + { val: "FOR903 ", text: "박스형(RCB)" }, + { val: "FOR904 ", text: "반월개거" }, + { val: "FOR905 ", text: "반월개거(측구)" }, + { val: "FOR906 ", text: "벽돌조" }, + { val: "FOR907 ", text: "사각철판" }, + { val: "FOR908 ", text: "석축개거" }, + { val: "FOR909 ", text: "석축개거(측구)" }, + { val: "FOR910 ", text: "승수로" }, + { val: "FOR911 ", text: "옹벽개거" }, + { val: "FOR912 ", text: "옹벽개거(측구)" }, + { val: "FOR913 ", text: "이단" }, + { val: "FOR914 ", text: "이중침전형" }, + { val: "FOR915 ", text: "자연개거" }, + { val: "FOR916 ", text: "자연개거(측구)" }, + { val: "FOR917 ", text: "조립식" }, + { val: "FOR918 ", text: "지상관" }, + { val: "FOR919 ", text: "지상단구" }, + { val: "FOR920 ", text: "지상쌍구" }, + { val: "FOR921 ", text: "지하단구" }, + { val: "FOR922 ", text: "지하쌍구" }, + { val: "FOR923 ", text: "직사각형" }, + { val: "FOR924 ", text: "타설식" }, + { val: "FOR925 ", text: "토류형" }, + { val: "FOR926 ", text: "흄관원형" }, + { val: "FOR927 ", text: "L형측구" }, + { val: "FOR928 ", text: "LO형측구" }, + { val: "FOR929 ", text: "LU형측구" }, + { val: "FOR930 ", text: "PVC이중벽관" }, + { val: "FOR931 ", text: "RCB 맨홀" }, + { val: "FOR932 ", text: "U형" }, + { val: "FOR933 ", text: "U형측구(미복개)" }, + { val: "FOR934 ", text: "U형측구(복개)" }, + + { val: "FOR999 ", text: "기타" }, +]; + +// 하수맨홀 뚜껑재질 코드표 +export const MS_SWAGE_MHL_CODES = [ + { val: "SBC000", text: "미분류" }, + { val: "SBC001", text: "주철" }, + { val: "SBC002", text: "철판" }, + { val: "SBC003", text: "콘크리트" }, + { val: "SBC004", text: "그레이팅" }, + { val: "SBC091", text: "칼라" }, + { val: "SBC092", text: "S.G" }, + { val: "SBC999", text: "기타" }, + { val: "SBC900", text: "각주철" }, + { val: "SBC901", text: "강재" }, + { val: "SBC902", text: "나무" }, + { val: "SBC903", text: "나무,널판지,합판" }, + { val: "SBC904", text: "대리석" }, + { val: "SBC905", text: "뚜껑없음" }, + { val: "SBC906", text: "보도용콘크리트" }, + { val: "SBC907", text: "스틸(ST)" }, + { val: "SBC908", text: "신형 CON" }, + { val: "SBC909", text: "알루미늄" }, + { val: "SBC910", text: "염화비닐(PE)" }, + { val: "SBC911", text: "인조석" }, + { val: "SBC912", text: "인조석(주철)" }, + { val: "SBC913", text: "인조CONC" }, + { val: "SBC914", text: "철, 철근" }, + { val: "SBC915", text: "칼라콘크리트" }, + { val: "SBC916", text: "플라스틱" }, + { val: "SBC917", text: "PVC" }, + { val: "SBC918", text: "SP" }, + { val: "SBC919", text: "SS" }, + { val: "SBC920", text: "닥타일주철" }, + { val: "SBC921", text: "철근G" }, + { val: "SBC922", text: "폴리에틸렌(PE)" }, + { val: "SBC923", text: "회주철" }, +]; + +// 하수맨홀 용도 코드표 +export const MS_SWAGE_MHL_USE_CODES = [ + { val: "SMU000 ", text: "미분류" }, + { val: "SMU001 ", text: "우수" }, + { val: "SMU002 ", text: "오수" }, + { val: "SMU003 ", text: "집수" }, + { val: "SMU004 ", text: "합류" }, + { val: "SMU005 ", text: "차집" }, + { val: "SMU999 ", text: "기타" }, + { val: "SMU900 ", text: "1호" }, + { val: "SMU901 ", text: "2호" }, + { val: "SMU902 ", text: "3호" }, + { val: "SMU903 ", text: "4호" }, + { val: "SMU904 ", text: "5호" }, + { val: "SMU905 ", text: "6호" }, + { val: "SMU906 ", text: "7호" }, + { val: "SMU907 ", text: "8호" }, + { val: "SMU908 ", text: "구거용맨홀" }, + { val: "SMU909 ", text: "부관부착맨홀" }, + { val: "SMU910 ", text: "조립식맨홀" }, + { val: "SMU911 ", text: "특1호" }, + { val: "SMU912 ", text: "특2호" }, + { val: "SMU913 ", text: "특3호" }, + { val: "SMU914 ", text: "특4호" }, + { val: "SMU915 ", text: "특5호" }, + { val: "SMU916 ", text: "하수맨홀용도" }, + { val: "SMU917 ", text: "현장타설관거용" }, +]; + +// 펌프장 용도 코드표 +export const DRTW_SEWAGE_PUMP_USE_CODES = [ + { val: "SBE000", text: "미분류" }, + { val: "SBE001", text: "빗물펌프장" }, + { val: "SBE002", text: "오수펌프장" }, + { val: "SBE003", text: "처리장내펌프장" }, + { val: "SBE900", text: "오수배제" }, + { val: "SBE901", text: "우수배제" }, + { val: "SBE999", text: "기타" }, +]; diff --git a/apps/sn/src/entities/facility/model/field-formats.ts b/apps/sn/src/entities/facility/model/field-formats.ts new file mode 100644 index 000000000..fba61349b --- /dev/null +++ b/apps/sn/src/entities/facility/model/field-formats.ts @@ -0,0 +1,142 @@ +/** 기존 시설 날짜·숫자 표시 필드 계약. 구조 분리 시 값은 유지한다. */ +// 🎯 날짜 필드들 (yyyymmdd -> yyyy년 mm월 dd일) +export const dateFields = [ + "instl_ymd", + "last_drdgn_ymd", + "cmcn_ymd", + "oprtng_strt_ymd", + "clsg_ymd", + "mesure_begin_dt", + "mesure_end_dt", + "qltwtr_begin_dt", + "qltwtr_end_dt", + "rain_begin_dt", + "rain_end_dt", + "mesureDt", + // 🎯 camelCase 버전 추가 + "instlYmd", + "lastDrdgnYmd", + "cmcnYmd", + "oprtngStrtYmd", + "clsgYmd", + "mesureBeginDt", + "mesureEndDt", + "qltwtrBeginDt", + "qltwtrEndDt", + "rainBeginDt", + "rainEndDt", + "flfmtBgngYmd", + "flfmtEndYmd", + "flfmtYr", + "bgng_ymd", + "end_ymd", + "bgngYmd", + "endYmd", +]; + +// 🎯 연월 필드들 (yyyymm -> yyyy년 m월) +export const yearMonthFields = ["aplcnYm"]; + +// 🎯 날짜시간 필드들 (yyyy-mm-dd hh:mm:ss -> yyyy년 m월 d일 hh:mm) +export const dateTimeFields = ["regDt"]; + +// 🎯 숫자 필드들 (천 단위 구분자) +export const numberFields = [ + "pdmt", + "prlg", + "avg_grdn", + "drtw_mltv_area", + "excln_mltv_area", + "tmpt_dpth", + "scend_dpth", + "trfln_cnt", + "vrtc_len", + "wdth_len", + "fclt_cpct", + "plln_area", + "prcs_zone_area_sum", + "swtrmt_ppltn_cnt", + "stty_crtr_bod", + "stty_crtr_toc", + "stty_crtr_ss", + "stty_crtr_tn", + "stty_crtr_tph", + "stty_crtr_clbcl_cnt", + "swage_mh_diam", + "wdth_len", + "vrtc_len", + "altd_vl", + "dpthlVl", + "plln_area", + "daily_prcs_cpct", + "max_impnd_cpct", + "altd_vl", + "wtlv_vl", + "excln_wpmp_ablt", + "worflw_wall_hgt", + "swr_ppl_eqp_ar", + "swr_trt_popl", + "weir_hg", + "weir_lt", + "precdng_dry", + "rain_fall", + "cntnc_time", + "rain_in", + "mesure_intrvl", + "qltwtr_co", + "mesureQy", + "mesureBod", + "mesureToc", + "mesureLoad", + // 🎯 camelCase 버전 추가 + "avgGrdn", + "drtwMltvArea", + "exclnMltvArea", + "tmptDpth", + "scendDpth", + "trflnCnt", + "vrtcLen", + "wdthLen", + "fcltCpct", + "pllnArea", + "prcsZoneAreaSum", + "swtrmtPpltnCnt", + "sttyCltrBod", + "sttyCltrToc", + "sttyCltrSs", + "sttyCltrTn", + "sttyCltrTph", + "sttyCltrClbclCnt", + "swageMhDiam", + "altdVl", + "dpthlVl", + "dailyPrcsCpct", + "maxImpndCpct", + "wtlvVl", + "exclnWpmpAblt", + "worflwWallHgt", + "swrPplEqpAr", + "swrTrtPopl", + "weirHg", + "weirLt", + "precdngDry", + "rainFall", + "cntncTime", + "rainIn", + "mesureIntrvl", + "qltwtrCo", + "rnysnFvlc", + "clarFvlc", + "bizAmt", + "count", + "chmbr_xcnts", + "chmbr_ydnts", + "fldng_hr", + "avg_fldng_wtlv", + "fldng_area", + "dam_amt", + "fldngHr", + "avgFldngWtlv", + "fldngArea", + "damAmt", +]; diff --git a/apps/sn/src/entities/facility/model/flood-columns.ts b/apps/sn/src/entities/facility/model/flood-columns.ts new file mode 100644 index 000000000..aa2bf0c45 --- /dev/null +++ b/apps/sn/src/entities/facility/model/flood-columns.ts @@ -0,0 +1,81 @@ +/** 기존 시설 컬럼 순서·필드·제목 계약. 구조 분리 시 값은 유지한다. */ +// 침수흔적도 +export const FldngWtlvMapCol = [ + { id: "fldng_yr", title: "침수년도" }, + { id: "fldng_dstr_nm", title: "침수재해명" }, + { id: "ctpv_nm", title: "시도명" }, + { id: "sgg_nm", title: "시군구명" }, + { id: "fldng_bgng_dtm", title: "침수시작일시" }, + { id: "fldng_end_dtm", title: "침수종료일시" }, + { id: "fldng_hr", title: "침수시간" }, + { id: "fldng_dstrct_nm", title: "침수지구명" }, + { id: "fldng_cs_dtl_cd", title: "침수원인상세코드" }, + { id: "dtl_ocrn_cs_cn", title: "침수발생원인" }, + { id: "avg_fldng_wtlv", title: "평균침수위" }, + { id: "fldng_area", title: "침수면적" }, + { id: "exmn_yr", title: "조사년도" }, + { id: "dam_amt", title: "피해금액" }, + { id: "rain_bgng_tm", title: "강우시작일시" }, + { id: "rain_end_tm", title: "강우종료일시" }, + { id: "rain_tot", title: "총강우량(mm)" }, + { id: "rain_str", title: "강우강도(mm/hr)" }, +]; + +export const FldngWtlvMapCol2 = [ + { id: "rowNumber", title: "순번" }, + { id: "fldngYr", title: "침수년도" }, + { id: "fldngDstrNm", title: "침수재해명" }, + { id: "ctpvNm", title: "시도" }, + { id: "sggNm", title: "시군구" }, + { id: "fldngBgngDtm", title: "침수시작일시" }, + { id: "fldngEndDtm", title: "침수종료일시" }, + { id: "fldngHr", title: "침수시간" }, + { id: "fldngDstrctNm", title: "침수지구명" }, + { id: "fldngCsDtlCd", title: "침수원인상세코드" }, + { id: "avgFldngWtlv", title: "평균침수위" }, + { id: "fldngArea", title: "침수면적" }, + { id: "exmnYr", title: "조사년도" }, + { id: "damAmt", title: "피해금액" }, + { id: "rainBgngTm", title: "강우시작일시" }, + { id: "rainEndTm", title: "강우종료일시" }, + { id: "rainTot", title: "총강우량(mm)" }, + { id: "rainStr", title: "강우강도(mm/hr)" }, +]; + +export const FldngDpthMapCol = [ + { id: "fldng_yr", title: "침수년도" }, + { id: "ctpv_nm", title: "시도명" }, + { id: "sgg_nm", title: "시군구명" }, + { id: "fldng_dpth", title: "침수깊이(m)" }, + { id: "fldng_area", title: "침수면적(㎡)" }, + // { id: 'shape_area', title: '형상면적(㎡)' }, + // { id: 'shape_len', title: '형상길이' }, +]; + +/** 기본정보 목록 API의 camelCase 속성을 침수흔적심선 그리드에 연결한다. */ +export const FldngDpthMapCol2 = [ + { id: "rowNumber", title: "순번" }, + { id: "fldngYr", title: "침수년도" }, + { id: "ctpvNm", title: "시도명" }, + { id: "sggNm", title: "시군구명" }, + { id: "fldngDpth", title: "침수깊이(m)" }, + { id: "fldngArea", title: "침수면적(㎡)" }, +]; + +export const FldngWtlvMapExcelCol = { + fldngYr: "침수년도", + fldngDstrNm: "침수재해명", + ctpvNm: "시도", + sggNm: "시군구", + bgngYmd: "침수시작일자", + fldngBgngHm: "침수시작시분", + endYmd: "침수종료일자", + fldngEndHm: "침수종료시분", + fldngHr: "침수시간", + fldngDstrctNm: "침수지구명", + fldngCsDtlCd: "침수원인상세코드", + avgFldngWtlv: "평균침수위", + fldngArea: "침수면적", + exmnYr: "조사년도", + damAmt: "피해금액", +}; diff --git a/apps/sn/src/entities/facility/model/manhole-columns.ts b/apps/sn/src/entities/facility/model/manhole-columns.ts new file mode 100644 index 000000000..fed7ca2c6 --- /dev/null +++ b/apps/sn/src/entities/facility/model/manhole-columns.ts @@ -0,0 +1,63 @@ +/** 기존 시설 컬럼 순서·필드·제목 계약. 구조 분리 시 값은 유지한다. */ +// 하수맨홀 +export const MsSwageMhlCol = [ + // { id: 'fid', title: 'FID' }, + // { id: 'geom', title: 'geometry' }, + // { id: 'ftre_smbol_cd', title: '지형지물부호코드' }, + { id: "fclt_mng_no", title: "시설관리번호" }, + // { id: 'pbadms_emd_cd', title: '행정읍면동코드' }, + { id: "mapsht_no", title: "도엽번호" }, + // { id: 'mng_inst_cd', title: '관리기관코드' }, + { id: "instl_ymd", title: "설치일자" }, + { id: "last_drdgn_ymd", title: "최종준설일자" }, + // { id: 'usg_cd', title: '용도코드' }, + // { id: 'fclty_shp_cd', title: '시설물형태코드' }, + // { id: 'mhl_knd_cd', title: '맨홀종류코드' }, + { id: "swage_mhL_diam_vl", title: "하수맨홀구경(mm)" }, + { id: "mqlt_cd", title: "재질" }, + { id: "wdth_len", title: "가로길이(m)" }, + { id: "vrtc_len", title: "세로길이(m)" }, + // { id: 'ang_dir', title: '표고' }, + { id: "altd_vl", title: "지표고(EL.m)" }, + { id: "dpthl_vl", title: "맨홀저고(EL.m)" }, + // { id: 'inst_mng_no', title: '기관관리번호' } +]; + +export const MsSwageMhlCol2 = [ + { id: "rowNumber", title: "순번" }, + // { id: 'fid', title: 'FID' }, + // { id: 'geom', title: 'geometry' }, + // { id: 'ftreSmbolCd', title: '지형지물부호코드' }, + { id: "fcltMngNo", title: "시설관리번호" }, + { id: "ctpvNm", title: "시도" }, + { id: "sggNm", title: "시군구" }, + // { id: 'pbadmsEmdCd', title: '행정읍면동코드' }, + // { id: 'mapshtNo', title: '도엽번호' }, + // { id: 'mngInstCd', title: '관리기관코드' }, + { id: "instlYmd", title: "설치일자" }, + // { id: 'lastDrdgnYmd', title: '최종준설일자' }, + { id: "usgCd", title: "용도" }, + // { id: 'fcltyShpCd', title: '시설물형태코드' }, + // { id: 'mhlKndCd', title: '맨홀종류코드' }, + { id: "swageMhlDiamVl", title: "하수맨홀구경(mm)" }, + // { id: 'mqltCd', title: '재질코드' }, + { id: "wdthLen", title: "가로길이(m)" }, + { id: "vrtcLen", title: "세로길이(m)" }, + // { id: 'angDir', title: '표고' }, + { id: "altdVl", title: "지표고(EL.m)" }, + { id: "dpthlVl", title: "맨홀저고(EL.m)" }, + // { id: 'instMngNo', title: '기관관리번호' } +]; + +export const MsSwageMhlExcelCol = { + fcltMngNo: "시설관리번호", + ctpvNm: "시도", + sggNm: "시군구", + instlYmd: "설치일자", + usgCd: "용도", + swageMhlDiamVl: "하수맨홀구경(mm)", + wdthLen: "가로길이(m)", + vrtcLen: "세로길이(m)", + altdVl: "지표고(EL.m)", + dpthlVl: "맨홀저고(EL.m)", +}; diff --git a/apps/sn/src/entities/facility/model/overflow-columns.ts b/apps/sn/src/entities/facility/model/overflow-columns.ts new file mode 100644 index 000000000..639ce6dbb --- /dev/null +++ b/apps/sn/src/entities/facility/model/overflow-columns.ts @@ -0,0 +1,99 @@ +/** 기존 시설 컬럼 순서·필드·제목 계약. 구조 분리 시 값은 유지한다. */ +// 우수토실 +export const stofcbrCol = [ + // { id: 'ftre_smbol_cd', title: '지형지물부호코드' }, + { id: "fclt_mng_no", title: "시설관리번호" }, + // { id: 'pbadms_emd_cd', title: '행정읍면동코드' }, + { id: "mapsht_no", title: "도엽번호" }, + // { id: 'mng_inst_cd', title: '관리기관코드' }, + { id: "instl_ymd", title: "설치일자" }, + // { id: 'fclty_shp_cd', title: '시설물형태코드' }, + { id: "worflw_wall_hgt", title: "월류벽높이(m)" }, + // { id: 'mhl_exst_yn_cd', title: '맨홀존재여부코드' }, + // { id: 'swage_mhl_ftre_smbol_cd', title: '하수맨홀지형지물부호코드' }, + { id: "swage_mhl_mng_no", title: "하수맨홀관리번호" }, + { id: "drtn_angl", title: "방향각도(°)" }, +]; + +export const stofcbrCol2 = [ + { id: "rowNumber", title: "순번" }, + // { id: 'ftreSmbolCd', title: '지형지물부호코드' }, + { id: "fcltMngNo", title: "시설관리번호" }, + // { id: 'pbadmsEmdCd', title: '행정읍면동코드' }, + { id: "mapshtNo", title: "도엽번호" }, + { id: "ctpvNm", title: "시도" }, + { id: "sggNm", title: "시군구" }, + // { id: 'mngInstCd', title: '관리기관코드' }, + { id: "instlYmd", title: "설치일자" }, + // { id: 'fcltyShpCd', title: '시설물형태코드' }, + { id: "worflwWallHgt", title: "월류벽높이(m)" }, + // { id: 'mhlExstYnCd', title: '맨홀존재여부코드' }, + // { id: 'swageMhlFtreSmbolCd', title: '하수맨홀지형지물부호코드' }, + { id: "swageMhlMngNo", title: "하수맨홀관리번호" }, + { id: "drtnAngl", title: "방향각도(°)" }, +]; + +export const stofcbrExcelCol = { + fcltMngNo: "시설관리번호", + mapshtNo: "도엽번호", + ctpvNm: "시도", + sggNm: "시군구", + instlYmd: "설치일자", + worflwWallHgt: "월류벽높이(m)", + swageMhlMngNo: "하수맨홀관리번호", + drtnAngl: "방향각도(°)", +}; + +// 중점우수토실 측정지점 +export const untrtSwgRainCol1 = [ + { id: "manage_no", title: "관리번호" }, + { id: "swr_ppl_eqp_nm", title: "처리구역" }, + { id: "swr_ppl_trt_nm", title: "처리분구" }, + { id: "addr", title: "인근주소" }, + { id: "chmbr_xcnts", title: "토실 위도" }, + { id: "chmbr_ydnts", title: "토실 경도" }, +]; + +// 중점우수토실 측정지점 시설현황 +export const untrtSwgRainCol2 = [ + { id: "mesure_spot_se", title: "구분" }, // 유량계측정방식 기타 + { id: "discharge_pipe_spec", title: "방류관로 규격(m)" }, + { id: "swr_ppl_eqp_ar", title: "배수구역 면적(m²)" }, + { id: "swr_trt_popl", title: "하수처리 인구(인)" }, + { id: "screen_instl_at", title: "스크린설치 여부" }, + { id: "weir_hg", title: "위어 높이(m)" }, + { id: "weir_lt", title: "위어 길이(m)" }, +]; + +// 중점우수토실 강우정보 (id값 틀릴 수 있음) +export const untrtSwgRainCol3 = [ + { id: "rainBeginDt", title: "강우시작일시" }, + { id: "rainEndDt", title: "강우종료일시" }, + { id: "precdngDry", title: "선행건기(h)" }, + { id: "rainInfo", title: "강우정보" }, + { id: "rainFall", title: "누적강우량(mm)" }, + { id: "cntncTime", title: "지속시간(h)" }, + { id: "rainIn", title: "강우강도(mm/h)" }, +]; + +// 중점우수토실 측정정보 (id값 틀릴 수 있음) +export const untrtSwgRainCol4 = [ + { id: "mesureBeginDt", title: "측정시작일시" }, + { id: "mesureEndDt", title: "측정종료일시" }, + { id: "mesureIntrvl", title: "측정간격(분)" }, + { id: "mesureMhrlsInstlLc", title: "측정기기 설치위치" }, + { id: "flwmtrCombined", title: "유량계 종류 및 모델명" }, // 합쳐진 컬럼 + { id: "qltwtrBeginDt", title: "채수시작일시" }, + { id: "qltwtrEndDt", title: "채수종료일시" }, + { id: "qltwtrCo", title: "채수횟수" }, +]; + +// 중점우수토실 수량/수질측정 정보 +export const untrtSwgRainCol5 = [ + { id: "odr", title: "회차" }, + { id: "mesureDt", title: "측정일자" }, + { id: "mesureQy", title: "수량(m³)" }, + { id: "mesureBod", title: "BOD(mg/L)" }, + { id: "mesureToc", title: "TOC(mg/L)" }, + { id: "mesureLoad", title: "오염부하량(kg)" }, +]; diff --git a/apps/sn/src/entities/facility/model/pipe-columns.ts b/apps/sn/src/entities/facility/model/pipe-columns.ts new file mode 100644 index 000000000..05d4818c5 --- /dev/null +++ b/apps/sn/src/entities/facility/model/pipe-columns.ts @@ -0,0 +1,85 @@ +/** 기존 시설 컬럼 순서·필드·제목 계약. 구조 분리 시 값은 유지한다. */ +/** 지도 시설 상세 및 목록에서 사용하는 컬럼·코드 정의를 제공한다. */ +// 하수관로 +export const MsSwagePplCol = [ + { id: "fclt_mng_no", title: "시설관리번호" }, + { id: "mngInstNm", title: "관리기관" }, + { id: "inst_mng_no", title: "기관관리번호" }, + { id: "flowDirectionBasis", title: "유하방향 근거" }, + { id: "instl_ymd", title: "설치일자" }, + { id: "elapsedYears", title: "경과년도" }, + { id: "mapsht_no", title: "도엽번호" }, + { id: "pdmt_vl", title: "관경(mm)" }, + { id: "prlg_vl", title: "연장(m)" }, + { id: "usg_cd", title: "용도" }, + { id: "mqlt_cd", title: "재질" }, + { id: "avg_grdn", title: "평균구배(m/m)" }, + { id: "drtw_mltv_area", title: "오수배수면적(m²)" }, + { id: "excln_mltv_area", title: "우수배수면적(m²)" }, + { id: "tmpt_dpth", title: "시점깊이(m)" }, + { id: "tmpt_mhl_mng_no", title: "시점맨홀관리번호" }, + { id: "scend_dpth", title: "종점깊이(m)" }, + { id: "scend_mhl_mng_no", title: "종점맨홀관리번호" }, + { id: "trfln_cnt", title: "차선수" }, + { id: "vrtc_len", title: "세로길이(m)" }, + { id: "wdth_len", title: "가로길이(m)" }, + // { id: 'rnyssn_fvlc', title: '우기 시 유속(m/s)' }, + // { id: 'clar_fvlc', title: '건기 시 유속(m/s)' }, + // { id: 'mng_inst_cd', title: '관리기관코드' }, + // { id: 'pbadms_emd_cd', title: '행정읍면동코드' }, + // { id: 'fclty_shp_cd', title: '시설물형태코드' }, + // { id: 'fid', title: 'FID' }, + // { id: 'ftre_smbol_cd', title: '지형지물부호코드' }, + // { id: 'lyrTypeNm', title: '레이어타입명' }, +]; + +// 하수관로 searchModal 기본정보tab +export const MsSwagePplCol2 = [ + { id: "rowNumber", title: "순번" }, + { id: "fcltMngNo", title: "시설\n관리번호" }, + { id: "instMngNo", title: "기관\n관리번호" }, + { id: "flowDirectionBasis", title: "유하방향\n근거" }, + { id: "ctpvNm", title: "시도" }, + { id: "sggNm", title: "시군구" }, + { id: "swtrmt_zone_nm", title: "처리구역" }, + { id: "instlYmd", title: "설치일자" }, + // { id: 'mapshtNo', title: '도엽번호' }, + { id: "usgCd", title: "용도" }, + { id: "pdmtVl", title: "관경\n(mm)" }, + { id: "prlgVl", title: "연장\n(m)" }, + // { id: 'avgGrdn', title: '평균구배\n(m/m)' }, + { id: "tmptDpth", title: "시점깊이\n(m)" }, + { id: "tmptMhlMngNo", title: "시점맨홀\n관리번호" }, + { id: "scendDpth", title: "종점깊이\n(m)" }, + { id: "scendMhlMngNo", title: "종점맨홀\n관리번호" }, + { id: "vrtcLen", title: "세로길이\n(m)" }, + { id: "wdthLen", title: "가로길이\n(m)" }, + { id: "fcltyShpCd", title: "시설물형태코드" }, + // { id: 'fid', title: 'FID' }, + // { id: 'ftreSmbolCd', title: '지형지물부호코드' }, + // { id: 'mngInstCd', title: '관리기관코드' }, + { id: "mqltCd", title: "재질코드" }, + // { id: 'pbadmsEmdCd', title: '행정읍면동코드' }, + // { id: 'usgCd', title: '용도코드' }, + // { id: 'lyrTypeNm', title: '레이어타입명' }, +]; + +export const MsSwagePplExcelCol = { + fcltMngNo: "시설 관리번호", + instMngNo: "기관 관리번호", + ctpvNm: "시도", + sggNm: "시군구", + swtrmt_zone_nm: "처리구역", + instlYmd: "설치일자", + usgCd: "용도", + pdmtVl: "관경(mm)", + prlgVl: "연장(m)", + tmptDpth: "시점 깊이(m)", + tmptMhlMngNo: "시점맨홀 관리번호", + scendDpth: "종점 깊이(m)", + scendMhlMngNo: "종점맨홀 관리번호", + vrtcLen: "세로길이(m)", + wdthLen: "가로길이(m)", + fcltyShpCd: "시설물형태코드", + mqltCd: "재질코드", +}; diff --git a/apps/sn/src/entities/facility/model/pump-columns.ts b/apps/sn/src/entities/facility/model/pump-columns.ts new file mode 100644 index 000000000..9c73de563 --- /dev/null +++ b/apps/sn/src/entities/facility/model/pump-columns.ts @@ -0,0 +1,105 @@ +/** 기존 시설 컬럼 순서·필드·제목 계약. 구조 분리 시 값은 유지한다. */ +// 우수펌프장 +export const ExclnSewagePumpCol = [ + // { id: 'fid', title: 'FID' }, + // { id: 'geom', title: 'geometry' }, + // { id: 'ftreSmbolCd', title: '지형지물부호코드' }, + { id: "fclt_mng_no", title: "시설관리번호" }, + // { id: 'pbadmsEmdCd', title: '행정읍면동코드' }, + { id: "mapsht_no", title: "도엽번호" }, + // { id: 'mngInstCd', title: '관리기관코드' }, + { id: "instl_ymd", title: "설치일자" }, + { id: "swage_pmstn_nm", title: "하수펌프장명" }, + { id: "plln_area", title: "부지면적(m²)" }, + // { id: 'opngSttsCd', title: '개통상태코드' }, + // { id: 'usgCd', title: '용도코드' }, + { id: "daily_prcs_cpct", title: "일일처리용량(㎥)" }, + { id: "max_impnd_cpct", title: "최대저수용량(㎥)" }, + { id: "altd_vl", title: "표고(EL.m)" }, + { id: "wtlv_vl", title: "수위(m)" }, + { id: "excln_wpmp_ablt_vl", title: "우수양수능력(m²/d)" }, + // { id: 'instMngNo', title: '기관관리번호' }, +]; + +export const ExclnSewagePumpCol2 = [ + { id: "rowNumber", title: "순번" }, + // { id: 'fid', title: 'FID' }, + // { id: 'geom', title: 'geometry' }, + // { id: 'ftreSmbolCd', title: '지형지물부호코드' }, + { id: "fcltMngNo", title: "시설관리번호" }, + { id: "ctpvNm", title: "시도" }, + { id: "sggNm", title: "시군구" }, + // { id: 'pbadmsEmdCd', title: '행정읍면동코드' }, + // { id: 'mapshtNo', title: '도엽번호' }, + // { id: 'mngInstCd', title: '관리기관코드' }, + { id: "swagePmstnNm", title: "하수펌프장명" }, + { id: "instlYmd", title: "설치일자" }, + // { id: 'pllnArea', title: '부지면적(m²)' }, + // { id: 'opngSttsCd', title: '개통상태코드' }, + // { id: 'usgCd', title: '용도코드' }, + { id: "dailyPrcsCpct", title: "일일처리용량(㎥)" }, + { id: "maxImpndCpct", title: "최대저수용량(㎥)" }, + { id: "altdVl", title: "표고(EL.m)" }, + { id: "wtlvVl", title: "수위(m)" }, + { id: "exclnWpmpAbltVl", title: "우수양수능력(m²/d)" }, + // { id: 'instMngNo', title: '기관관리번호' }, +]; + +export const ExclnSewagePumpExcelCol = { + fcltMngNo: "시설관리번호", + ctpvNm: "시도", + sggNm: "시군구", + swagePmstnNm: "하수펌프장명", + instlYmd: "설치일자", + dailyPrcsCpct: "일일처리용량(㎥)", + maxImpndCpct: "최대저수용량(㎥)", + altdVl: "표고(EL.m)", + wtlvVl: "수위(m)", + exclnWpmpAbltVl: "우수양수능력(m²/d)", +}; + +// 오수펌프장 +export const DrtwSewagePumpCol = [ + // { id: 'fid', title: 'FID' }, + // { id: 'geom', title: 'geometry' }, + // { id: 'ftreSmbolCd', title: '지형지물부호코드' }, + { id: "fclt_mng_no", title: "시설관리번호" }, + // { id: 'pbadmsEmdCd', title: '행정읍면동코드' }, + { id: "mapsht_no", title: "도엽번호" }, + // { id: 'mngInstCd', title: '관리기관코드' }, + { id: "instl_ymd", title: "설치일자" }, + { id: "swage_pmstn_nm", title: "하수펌프장명" }, + { id: "plln_area", title: "부지면적(m²)" }, + // { id: 'opngSttsCd', title: '개통상태코드' }, + // { id: 'usgCd', title: '용도코드' }, + { id: "daily_prcs_cpct", title: "일일처리용량(㎥)" }, + { id: "max_impnd_cpct", title: "최대저수용량(㎥)" }, + { id: "altd_vl", title: "표고(EL.m)" }, + { id: "wtlv_vl", title: "수위(m)" }, + { id: "excln_wpmp_ablt_vl", title: "오수양수능력(m²/d)" }, + // { id: 'instMngNo', title: '기관관리번호' }, +]; + +export const DrtwSewagePumpCol2 = [ + { id: "rowNumber", title: "순번" }, + // { id: 'fid', title: 'FID' }, + // { id: 'geom', title: 'geometry' }, + // { id: 'ftreSmbolCd', title: '지형지물부호코드' }, + { id: "fcltMngNo", title: "시설관리번호" }, + // { id: 'pbadmsEmdCd', title: '행정읍면동코드' }, + // { id: 'mapshtNo', title: '도엽번호' }, + { id: "ctpvNm", title: "시도" }, + { id: "sggNm", title: "시군구" }, + // { id: 'mngInstCd', title: '관리기관코드' }, + { id: "swagePmstnNm", title: "하수펌프장명" }, + { id: "instlYmd", title: "설치일자" }, + // { id: 'pllnArea', title: '부지면적(m²)' }, + // { id: 'opngSttsCd', title: '개통상태코드' }, + // { id: 'usgCd', title: '용도코드' }, + { id: "dailyPrcsCpct", title: "일일처리용량(㎥)" }, + { id: "maxImpndCpct", title: "최대저수용량(㎥)" }, + { id: "altdVl", title: "표고(EL.m)" }, + { id: "wtlvVl", title: "수위(m)" }, + { id: "exclnWpmpAbltVl", title: "오수양수능력(m²/d)" }, + // { id: 'instMngNo', title: '기관관리번호' }, +]; diff --git a/apps/sn/src/entities/facility/model/treatment-facility-columns.ts b/apps/sn/src/entities/facility/model/treatment-facility-columns.ts new file mode 100644 index 000000000..dbade0a4b --- /dev/null +++ b/apps/sn/src/entities/facility/model/treatment-facility-columns.ts @@ -0,0 +1,93 @@ +/** 기존 시설 컬럼 순서·필드·제목 계약. 구조 분리 시 값은 유지한다. */ +// 하수처리시설 +export const SwtrmtFcltCol = [ + { id: "swtrmt_fclt_nm", title: "하수처리시설명" }, + { id: "fclt_cpct", title: "시설용량(㎥/d)" }, + { id: "rgn_expln", title: "지역설명" }, + { id: "cmcn_ymd", title: "준공일자" }, + { id: "oprtng_strt_ymd", title: "가동개시일자" }, + { id: "clsg_ymd", title: "폐쇄일자" }, + // { id: 'prcs_msrc_cd', title: '처리공법코드' }, + // { id: 'lae_cd', title: '지자체코드' }, + { id: "lae_nm", title: "지자체명" }, + { id: "ctpv_nm", title: "시도명" }, + { id: "sgg_nm", title: "시군구명" }, + // { id: 'sarea_cd', title: '권역코드' }, + { id: "sarea_nm", title: "권역명" }, + { id: "lrge_sarea_nm", title: "대권역명" }, + { id: "rvr_nm", title: "하천명" }, + { id: "addr", title: "주소" }, + { id: "dtl_addr", title: "상세주소" }, + { id: "rdnm_addr", title: "도로명주소" }, + { id: "dtl_rdnm_addr", title: "상세도로명주소" }, + { id: "dschg_wtst_nm", title: "방류수계명" }, + { id: "plln_area", title: "부지면적(m²)" }, + { id: "mng_rprtv_bzenty_nm", title: "관리대행업제명" }, + { id: "swtrmt_ppltn_cnt", title: "하수처리인구수(인)" }, + { id: "prcs_zone_area_sum", title: "처리구역면적합계(m²)" }, + { id: "xcrd", title: "X좌표" }, + { id: "ycrd", title: "Y좌표" }, + // { id: 'oprtngSttsCd', title: '가동상태코드' }, + { id: "rprs_msrc_cn", title: "대표공법내용" }, + // { id: 'stty_crtr_bod', title: '법정기준생물화학적산소요구량(mg/L)' }, + // { id: 'stty_crtr_toc', title: '법정기준TOC((mg/L))' }, + // { id: 'stty_crtr_ss', title: '법정기준SS(mg/L)' }, + // { id: 'stty_crtr_tn', title: '법정기준총질소(mg/L)' }, + // { id: 'stty_crtr_tph', title: '법정기준총인(mg/L)' }, + // { id: 'stty_crtr_clbcl_cnt', title: '법정기준대장균수(군수/100mL)' }, + { id: "bsn_nm", title: "유역명" }, + // { id: 'obsrvnSptCd', title: '관측지점코드' }, +]; + +export const SwtrmtFcltCol2 = [ + { id: "rowNumber", title: "순번" }, + { id: "swtrmtFcltNm", title: "하수처리\n시설명" }, + { id: "fcltCpct", title: "시설\n용량\n(㎥/d)" }, + { id: "rgnExpln", title: "지역설명" }, + // { id: 'cmcnYmd', title: '준공일자' }, + { id: "oprtngStrtYmd", title: "가동개시일자" }, + // { id: 'clsgYmd', title: '폐쇄일자' }, + // { id: 'prcsMsrcCd', title: '처리공법코드' }, + // { id: 'laeCd', title: '지자체코드' }, + { id: "laeNm", title: "지자체명" }, + // { id: 'ctpvNm', title: '시도명' }, + // { id: 'sggNm', title: '시군구명' }, + // { id: 'sareaCd', title: '권역코드' }, + { id: "sareaNm", title: "권역명" }, + // { id: 'lrgeSareaNm', title: '대권역명' }, + { id: "rvrNm", title: "하천명" }, + // { id: 'addr', title: '주소' }, + // { id: 'dtlAddr', title: '상세주소' }, + // { id: 'rdnmAddr', title: '도로명주소' }, + // { id: 'dtlRdnmAddr', title: '상세도로명주소' }, + { id: "dschgWtstNm", title: "방류수계명" }, + // { id: 'pllnArea', title: '부지면적(m²)' }, + // { id: 'mngRprtvBzentyNm', title: '관리대행업체명' }, + { id: "swtrmtPpltnCnt", title: "하수\n처리\n인구수\n(인)" }, + // { id: 'prcsZoneAreaSum', title: '처리구역면적합계(m²)' }, + // { id: 'xcrd', title: 'X좌표' }, + // { id: 'ycrd', title: 'Y좌표' }, + // { id: 'oprtngSttsCd', title: '가동상태코드' }, + { id: "rprsMsrcCn", title: "대표\n공법\n내용" }, + // { id: 'sttyCrtrBod', title: '법정기준생물화학적산소요구량(mg/L)' }, + // { id: 'sttyCrtrToc', title: '법정기준TOC(mg/L)' }, + // { id: 'sttyCrtrSs', title: '법정기준SS(mg/L)' }, + // { id: 'sttyCrtrTn', title: '법정기준총질소(mg/L)' }, + // { id: 'sttyCrtrTph', title: '법정기준총인(mg/L)' }, + // { id: 'sttyCrtrClbclCnt', title: '법정기준대장균수(군수/100mL)' }, + // { id: 'bsnNm', title: '유역명' }, + // { id: 'obsrvnSptCd', title: '관측지점코드' }, +]; + +export const SwtrmtFcltExcelCol = { + swtrmtFcltNm: "하수처리시설명", + fcltCpct: "시설 용량(㎥/d)", + rgnExpln: "지역설명", + oprtngStrtYmd: "가동개시 일자", + laeNm: "지자체명", + sareaNm: "권역명", + rvrNm: "하천명", + dschgWtstNm: "방류수계명", + swtrmtPpltnCnt: "하수 처리 인구수(인)", + rprsMsrcCn: "대표 공법 내용", +}; diff --git a/apps/sn/src/entities/facility/model/treatment-zone-columns.ts b/apps/sn/src/entities/facility/model/treatment-zone-columns.ts new file mode 100644 index 000000000..6afeb45ad --- /dev/null +++ b/apps/sn/src/entities/facility/model/treatment-zone-columns.ts @@ -0,0 +1,31 @@ +/** 기존 시설 컬럼 순서·필드·제목 계약. 구조 분리 시 값은 유지한다. */ +// 하수처리구역 +export const SwtrmtPrcsZoneCol = [ + // { id: 'swtrmtZoneSn', title: '하수처리구역 일련번호' }, + { id: "swtrmt_zone_nm", title: "하수처리구역명" }, + // { id: 'swtrmt_fclt_cd', title: '하수처리시설코드' }, + // { id: 'ctpv_cd', title: '시도코드' }, + { id: "ctpv_nm", title: "시도명" }, + // { id: 'sgg_cd', title: '시군구코드' }, + { id: "sgg_nm", title: "시군구명" }, + { id: "swtrmt_fclt_nm", title: "하수처리시설명" }, +]; + +export const SwtrmtPrcsZoneCol2 = [ + { id: "rowNumber", title: "순번" }, + // { id: 'swtrmtZoneSn', title: '하수처리구역 일련번호' }, + { id: "swtrmtZoneNm", title: "하수처리구역명" }, + // { id: 'swtrmt_fclt_cd', title: '하수처리시설코드' }, + // { id: 'ctpv_cd', title: '시도코드' }, + { id: "ctpvNm", title: "시도" }, + // { id: 'sgg_cd', title: '시군구코드' }, + { id: "sggNm", title: "시군구" }, + { id: "swtrmtFcltNm", title: "하수처리시설명" }, +]; + +export const SwtrmtPrcsZoneExcelCol = { + swtrmtZoneNm: "하수처리구역명", + ctpvNm: "시도", + sggNm: "시군구", + swtrmtFcltNm: "하수처리시설명", +}; diff --git a/apps/sn/app/(map-layout)/map/components/fldngHstry/fldngHstryUtils.ts b/apps/sn/src/entities/flood-history/lib/flood-history-utils.ts similarity index 99% rename from apps/sn/app/(map-layout)/map/components/fldngHstry/fldngHstryUtils.ts rename to apps/sn/src/entities/flood-history/lib/flood-history-utils.ts index d28e0d43a..0e361c0d5 100644 --- a/apps/sn/app/(map-layout)/map/components/fldngHstry/fldngHstryUtils.ts +++ b/apps/sn/src/entities/flood-history/lib/flood-history-utils.ts @@ -17,7 +17,7 @@ import { type FldngHstryItem, type FldngHstryTableRow, type FldngHstryYearRange, -} from "./fldngHstryTypes"; +} from "@/entities/flood-history/model/flood-history-types"; export const normalizeFldngHstryWtlvDisplayId = (value: unknown) => { const normalized = String(value ?? "").trim(); diff --git a/apps/sn/app/(map-layout)/map/components/fldngHstry/fldngHstryTypes.ts b/apps/sn/src/entities/flood-history/model/flood-history-types.ts similarity index 100% rename from apps/sn/app/(map-layout)/map/components/fldngHstry/fldngHstryTypes.ts rename to apps/sn/src/entities/flood-history/model/flood-history-types.ts diff --git a/apps/sn/src/entities/menu-authority/api/menu-authority-service.ts b/apps/sn/src/entities/menu-authority/api/menu-authority-service.ts index 14988eee0..b2b571049 100644 --- a/apps/sn/src/entities/menu-authority/api/menu-authority-service.ts +++ b/apps/sn/src/entities/menu-authority/api/menu-authority-service.ts @@ -1,4 +1,4 @@ -import { client } from "@/shared/api"; +import { client } from "@/shared/api/client"; import { ENDPOINT } from "@/shared/config"; import type { MenuAuthorityList, diff --git a/apps/sn/src/entities/menu/api/menu-service.ts b/apps/sn/src/entities/menu/api/menu-service.ts index 02a2d1718..37f47b001 100644 --- a/apps/sn/src/entities/menu/api/menu-service.ts +++ b/apps/sn/src/entities/menu/api/menu-service.ts @@ -1,4 +1,4 @@ -import { client } from "@/shared/api"; +import { client } from "@/shared/api/client"; import { ENDPOINT } from "@/shared/config"; import type { MenuItem, MenuService } from "../model/menu-interface"; diff --git a/apps/sn/src/shared/lib/bbs-notice-menu.ts b/apps/sn/src/entities/menu/lib/bbs-notice-menu.ts similarity index 100% rename from apps/sn/src/shared/lib/bbs-notice-menu.ts rename to apps/sn/src/entities/menu/lib/bbs-notice-menu.ts diff --git a/apps/sn/src/shared/lib/statistics-menu-url.ts b/apps/sn/src/entities/menu/lib/statistics-menu-url.ts similarity index 100% rename from apps/sn/src/shared/lib/statistics-menu-url.ts rename to apps/sn/src/entities/menu/lib/statistics-menu-url.ts diff --git a/apps/sn/src/store/tab-store.ts b/apps/sn/src/entities/menu/model/tab-store.ts similarity index 98% rename from apps/sn/src/store/tab-store.ts rename to apps/sn/src/entities/menu/model/tab-store.ts index 7e942879a..6868ba1e9 100644 --- a/apps/sn/src/store/tab-store.ts +++ b/apps/sn/src/entities/menu/model/tab-store.ts @@ -4,8 +4,8 @@ import { createJSONStorage, persist } from "zustand/middleware"; import type { MenuItem } from "@/entities/menu"; import { MenuAuthorityApi } from "@/entities/menu-authority"; import type { PageMenuAuthority } from "@/entities/menu-authority/model/menu-authority-interface"; -import { isBbsNoticeMenu } from "@/shared/lib/bbs-notice-menu"; -import { resolveStatisticsTabId } from "@/shared/lib/statistics-menu-url"; +import { isBbsNoticeMenu } from "@/entities/menu/lib/bbs-notice-menu"; +import { resolveStatisticsTabId } from "@/entities/menu/lib/statistics-menu-url"; export interface Tab { id: string; // 고유 ID (urlPath) diff --git a/apps/sn/app/store/index.ts b/apps/sn/src/entities/reference-data/api/reference-data-api.ts similarity index 98% rename from apps/sn/app/store/index.ts rename to apps/sn/src/entities/reference-data/api/reference-data-api.ts index 7d761f91a..9c32966d8 100644 --- a/apps/sn/app/store/index.ts +++ b/apps/sn/src/entities/reference-data/api/reference-data-api.ts @@ -15,7 +15,7 @@ * - 사업 구분 코드 조회: GET /api/v1/code/cache/BIZSECD (cms-service) * - 사업 유형 코드 조회: GET /api/v1/code/cache/BIZTYCD (cms-service) */ -import { getCmsData } from "@/components/lib/api"; +import { getCmsData } from "@/shared/api/external-service-api"; export type BIZ_CD_TYPE = { diff --git a/apps/sn/app/store/useOnsemiroAddrStore.ts b/apps/sn/src/entities/reference-data/model/onsemiro-address-store.ts similarity index 98% rename from apps/sn/app/store/useOnsemiroAddrStore.ts rename to apps/sn/src/entities/reference-data/model/onsemiro-address-store.ts index 3702c1e8f..ab7547693 100644 --- a/apps/sn/app/store/useOnsemiroAddrStore.ts +++ b/apps/sn/src/entities/reference-data/model/onsemiro-address-store.ts @@ -2,7 +2,7 @@ import { create } from "zustand"; -import { getRawJson } from "@/components/lib/api"; +import { getRawJson } from "@/shared/api/sewerage-api"; import type { SelectDataType } from "@common/business_components"; diff --git a/apps/sn/app/store/useStore.ts b/apps/sn/src/entities/reference-data/model/reference-data-store.ts similarity index 95% rename from apps/sn/app/store/useStore.ts rename to apps/sn/src/entities/reference-data/model/reference-data-store.ts index 2e8932005..0f8cfaa38 100644 --- a/apps/sn/app/store/useStore.ts +++ b/apps/sn/src/entities/reference-data/model/reference-data-store.ts @@ -10,7 +10,7 @@ * - 데이터 변경 감지 및 불필요한 재렌더링 방지 */ import { create } from "zustand"; -import { fetchCtpv, fetchSgg, fetchBIZ_SE_CD, fetchBIZ_TY_CD } from "./index"; +import { fetchCtpv, fetchSgg, fetchBIZ_SE_CD, fetchBIZ_TY_CD } from "@/entities/reference-data/api/reference-data-api"; import { type SelectDataType } from "@common/business_components"; export type BIZ_CD_TYPE = { diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/chart/aplcnYms.ts b/apps/sn/src/features/analysis-charts/model/aplcn-yms.ts similarity index 99% rename from apps/sn/app/(map-layout)/map/components/popupComponent/chart/aplcnYms.ts rename to apps/sn/src/features/analysis-charts/model/aplcn-yms.ts index 0c33df1e1..e6ae917a3 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/chart/aplcnYms.ts +++ b/apps/sn/src/features/analysis-charts/model/aplcn-yms.ts @@ -1,7 +1,7 @@ /** 침입수·RDII 분석의 적용연월 선택 상태와 월 목록을 제공한다. */ import { useState, useEffect } from "react"; import moment from "moment"; -import { getData } from "@/components/lib/api"; +import { getData } from "@/shared/api/sewerage-api"; export type TInftwtAnls = { inftwtAnlsSn: number; diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/chart/infilChart.tsx b/apps/sn/src/features/analysis-charts/ui/infil-chart.tsx similarity index 98% rename from apps/sn/app/(map-layout)/map/components/popupComponent/chart/infilChart.tsx rename to apps/sn/src/features/analysis-charts/ui/infil-chart.tsx index 4cf5c5d45..7d147b5c1 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/chart/infilChart.tsx +++ b/apps/sn/src/features/analysis-charts/ui/infil-chart.tsx @@ -18,8 +18,8 @@ import { import { SelectBox } from "@common/business_components/ui"; import { Button, Input } from "@common/components"; -import type { TInftwtAnls, TInftwtAnlsRslt, TOilqData } from "./aplcnYms"; -import * as fn from "./aplcnYms"; +import type { TInftwtAnls, TInftwtAnlsRslt, TOilqData } from "@/features/analysis-charts/model/aplcn-yms"; +import * as fn from "@/features/analysis-charts/model/aplcn-yms"; type ModalOpenProp = { pplMngNo?: string; diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/chart/rdiiChart.tsx b/apps/sn/src/features/analysis-charts/ui/rdii-chart.tsx similarity index 98% rename from apps/sn/app/(map-layout)/map/components/popupComponent/chart/rdiiChart.tsx rename to apps/sn/src/features/analysis-charts/ui/rdii-chart.tsx index 44c384ace..47460bf25 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/chart/rdiiChart.tsx +++ b/apps/sn/src/features/analysis-charts/ui/rdii-chart.tsx @@ -18,8 +18,8 @@ import { import { SelectBox } from "@common/business_components/ui"; import { Button, Input } from "@common/components"; -import type { TRdiiAnls } from "./aplcnYms"; -import * as fn from "./aplcnYms"; +import type { TRdiiAnls } from "@/features/analysis-charts/model/aplcn-yms"; +import * as fn from "@/features/analysis-charts/model/aplcn-yms"; type ModalOpenProp = { pplMngNo?: string; diff --git a/apps/sn/src/features/cfmtnBgt/api/cfmtn-bgt-api.ts b/apps/sn/src/features/cfmtn-bgt/api/cfmtn-bgt-api.ts similarity index 94% rename from apps/sn/src/features/cfmtnBgt/api/cfmtn-bgt-api.ts rename to apps/sn/src/features/cfmtn-bgt/api/cfmtn-bgt-api.ts index 594d97ae0..12f082cfa 100644 --- a/apps/sn/src/features/cfmtnBgt/api/cfmtn-bgt-api.ts +++ b/apps/sn/src/features/cfmtn-bgt/api/cfmtn-bgt-api.ts @@ -1,4 +1,4 @@ -import { getData, postRawJson } from "@/components/lib/api"; +import { getData, postRawJson } from "@/shared/api/sewerage-api"; import type { CfmtnBgtCode, CfmtnBgtPage, diff --git a/apps/sn/src/features/cfmtnBgt/lib/cfmtn-bgt-excel.ts b/apps/sn/src/features/cfmtn-bgt/lib/cfmtn-bgt-excel.ts similarity index 100% rename from apps/sn/src/features/cfmtnBgt/lib/cfmtn-bgt-excel.ts rename to apps/sn/src/features/cfmtn-bgt/lib/cfmtn-bgt-excel.ts diff --git a/apps/sn/src/features/cfmtnBgt/model/cfmtn-bgt-types.ts b/apps/sn/src/features/cfmtn-bgt/model/cfmtn-bgt-types.ts similarity index 100% rename from apps/sn/src/features/cfmtnBgt/model/cfmtn-bgt-types.ts rename to apps/sn/src/features/cfmtn-bgt/model/cfmtn-bgt-types.ts diff --git a/apps/sn/src/features/cfmtnBgt/ui/CfmtnBgtPopupContent.tsx b/apps/sn/src/features/cfmtn-bgt/ui/cfmtn-bgt-popup-content.tsx similarity index 97% rename from apps/sn/src/features/cfmtnBgt/ui/CfmtnBgtPopupContent.tsx rename to apps/sn/src/features/cfmtn-bgt/ui/cfmtn-bgt-popup-content.tsx index 8c3e7d03a..662550a24 100644 --- a/apps/sn/src/features/cfmtnBgt/ui/CfmtnBgtPopupContent.tsx +++ b/apps/sn/src/features/cfmtn-bgt/ui/cfmtn-bgt-popup-content.tsx @@ -7,7 +7,7 @@ import { getSnRowNumber } from "@/shared/lib/row-number"; import { useEffect, useMemo, useState } from "react"; import { useConfirm } from "@frontend-opensource/use-react-hooks"; -import useOnsemiroAddrStore from "@/app/store/useOnsemiroAddrStore"; +import useOnsemiroAddrStore from "@/entities/reference-data/model/onsemiro-address-store"; import { StandardDataTable, StandardPopupLayout, @@ -29,9 +29,9 @@ import { type PaginationState, } from "@tanstack/react-table"; -import { postPopupMessageAndClose } from "../../windowPopup/lib/popupMessage"; -import type { WindowPopupContentProps } from "../../windowPopup/model/types"; -import { WINDOW_POPUP_MESSAGE_TYPE } from "../../windowPopup/model/types"; +import { postPopupMessageAndClose } from "../../window-popup/lib/popup-message"; +import type { WindowPopupContentProps } from "../../window-popup/model/types"; +import { WINDOW_POPUP_MESSAGE_TYPE } from "../../window-popup/model/types"; import { getCfmtnBgtCodes, getCfmtnBgtPage, @@ -44,8 +44,8 @@ import type { CfmtnBgtRow, } from "../model/cfmtn-bgt-types"; -import LoadingOverlay from "@/components/ui/LoadingBar"; -import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel"; +import LoadingOverlay from "@/components/ui/loading-bar"; +import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel"; import { fetchAllPages } from "@/shared/lib/fetch-all-pages"; type SelectOption = { text: string; val: string }; diff --git a/apps/sn/src/features/ctyFldngPrvtnBiz/api/cty-fldng-prvtn-biz-api.ts b/apps/sn/src/features/cty-fldng-prvtn-biz/api/cty-fldng-prvtn-biz-api.ts similarity index 96% rename from apps/sn/src/features/ctyFldngPrvtnBiz/api/cty-fldng-prvtn-biz-api.ts rename to apps/sn/src/features/cty-fldng-prvtn-biz/api/cty-fldng-prvtn-biz-api.ts index bdbd5d405..a789d537c 100644 --- a/apps/sn/src/features/ctyFldngPrvtnBiz/api/cty-fldng-prvtn-biz-api.ts +++ b/apps/sn/src/features/cty-fldng-prvtn-biz/api/cty-fldng-prvtn-biz-api.ts @@ -5,12 +5,7 @@ import type { CtyFldngPrvtnBizSaveRequest, } from "../model/cty-fldng-prvtn-biz-types"; -import { - deleteData, - getData, - postRawJson, - putData, -} from "@/components/lib/api"; +import { deleteData, getData, postRawJson, putData } from "@/shared/api/sewerage-api"; import { fetchAllPages } from "@/shared/lib/fetch-all-pages"; const API_PATH = "v1/ctyFldngPrvtnBiz"; diff --git a/apps/sn/src/features/ctyFldngPrvtnBiz/lib/cty-fldng-prvtn-biz-excel.ts b/apps/sn/src/features/cty-fldng-prvtn-biz/lib/cty-fldng-prvtn-biz-excel.ts similarity index 100% rename from apps/sn/src/features/ctyFldngPrvtnBiz/lib/cty-fldng-prvtn-biz-excel.ts rename to apps/sn/src/features/cty-fldng-prvtn-biz/lib/cty-fldng-prvtn-biz-excel.ts diff --git a/apps/sn/src/features/ctyFldngPrvtnBiz/model/cty-fldng-prvtn-biz-types.ts b/apps/sn/src/features/cty-fldng-prvtn-biz/model/cty-fldng-prvtn-biz-types.ts similarity index 100% rename from apps/sn/src/features/ctyFldngPrvtnBiz/model/cty-fldng-prvtn-biz-types.ts rename to apps/sn/src/features/cty-fldng-prvtn-biz/model/cty-fldng-prvtn-biz-types.ts diff --git a/apps/sn/src/features/ctyFldngPrvtnBiz/model/xlsx-populate-browser.d.ts b/apps/sn/src/features/cty-fldng-prvtn-biz/model/xlsx-populate-browser.d.ts similarity index 100% rename from apps/sn/src/features/ctyFldngPrvtnBiz/model/xlsx-populate-browser.d.ts rename to apps/sn/src/features/cty-fldng-prvtn-biz/model/xlsx-populate-browser.d.ts diff --git a/apps/sn/src/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizFormPage.tsx b/apps/sn/src/features/cty-fldng-prvtn-biz/ui/cty-fldng-prvtn-biz-form-page.tsx similarity index 97% rename from apps/sn/src/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizFormPage.tsx rename to apps/sn/src/features/cty-fldng-prvtn-biz/ui/cty-fldng-prvtn-biz-form-page.tsx index c49c8c673..5a8b03b10 100644 --- a/apps/sn/src/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizFormPage.tsx +++ b/apps/sn/src/features/cty-fldng-prvtn-biz/ui/cty-fldng-prvtn-biz-form-page.tsx @@ -30,10 +30,10 @@ import type { CtyFldngPrvtnBizSaveRequest, } from "../model/cty-fldng-prvtn-biz-types"; -import LoadingOverlay from "@/components/ui/LoadingBar"; +import LoadingOverlay from "@/components/ui/loading-bar"; import { useAlertStore } from "@/shared/lib"; -import { useOperBusinessCommonCodes } from "@/shared/lib/use-oper-business-common-codes"; -import useStore from "@/store/useStore"; +import { useOperBusinessCommonCodes } from "@/entities/business/lib/use-oper-business-common-codes"; +import useStore from "@/entities/reference-data/model/reference-data-store"; const DETAIL_META = [ { code: "EM", title: "중점관리지역 지정" }, @@ -393,6 +393,8 @@ export function CtyFldngPrvtnBizFormPage({ id }: { id?: string }) { suggestionOptions.length > 0 ? `${fieldId}-suggestions` : undefined; const span: 1 | 2 | 3 = key === "excptnMttrCn" ? 3 : key === "bizNm" || key === "pstn" ? 2 : 1; + // 수정 화면에서는 중복 판정 업무키인 사업순번과 단계를 변경할 수 없다. + const isBusinessKeyLocked = Boolean(id) && (key === "seqNo" || key === "stp"); return ( setForm((current) => ({ diff --git a/apps/sn/src/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizListPage.tsx b/apps/sn/src/features/cty-fldng-prvtn-biz/ui/cty-fldng-prvtn-biz-list-page.tsx similarity index 98% rename from apps/sn/src/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizListPage.tsx rename to apps/sn/src/features/cty-fldng-prvtn-biz/ui/cty-fldng-prvtn-biz-list-page.tsx index 9d4107ddd..a2f247017 100644 --- a/apps/sn/src/features/ctyFldngPrvtnBiz/ui/CtyFldngPrvtnBizListPage.tsx +++ b/apps/sn/src/features/cty-fldng-prvtn-biz/ui/cty-fldng-prvtn-biz-list-page.tsx @@ -41,9 +41,9 @@ import type { CtyFldngPrvtnBizPage, } from "../model/cty-fldng-prvtn-biz-types"; -import LoadingOverlay from "@/components/ui/LoadingBar"; -import { useOperBusinessCommonCodes } from "@/shared/lib/use-oper-business-common-codes"; -import useStore from "@/store/useStore"; +import LoadingOverlay from "@/components/ui/loading-bar"; +import { useOperBusinessCommonCodes } from "@/entities/business/lib/use-oper-business-common-codes"; +import useStore from "@/entities/reference-data/model/reference-data-store"; const PAGE_SIZE = 10; const ALL = { text: "전체", val: "0" }; @@ -164,13 +164,11 @@ const formatLocalDateInputValue = (date: Date) => { return `${year}-${month}-${day}`; }; -/** 지정일 기본 검색기간을 오늘까지의 최근 1년으로 생성한다. */ +/** 지정일 기본 검색기간을 최초 사업자료 기준일인 2013-01-01부터 오늘까지로 생성한다. */ const createDefaultDesignationDateRange = () => { const to = new Date(); - const from = new Date(to); - from.setFullYear(from.getFullYear() - 1); return { - dsgnYmdFrom: formatLocalDateInputValue(from), + dsgnYmdFrom: "2013-01-01", dsgnYmdTo: formatLocalDateInputValue(to), }; }; diff --git a/apps/sn/src/features/dredging/api/dredging-api.ts b/apps/sn/src/features/dredging/api/dredging-api.ts index 93a6c1a05..60f8c2fe4 100644 --- a/apps/sn/src/features/dredging/api/dredging-api.ts +++ b/apps/sn/src/features/dredging/api/dredging-api.ts @@ -1,11 +1,11 @@ /** 준설 조회·등록·수정·삭제와 최근접 관로 HTTP 계약을 담당한다. */ import { deleteData, - getBlobData, getRawJson, postRawJson, putData, -} from "@/components/lib/api"; +} from "@/shared/api/sewerage-api"; +import { getBlobData } from "@/shared/api/file-api"; import { requestGridExcel } from "@/shared/api/grid-export"; import { isDredgingBackendEnabled } from "./backend-mode"; import { toDredgingApiError } from "./dredging-error"; @@ -40,7 +40,9 @@ export type DredgingGridQuery = { sort?: string; }; -export const fetchDredgingGridPage = async (query: DredgingGridQuery): Promise<{ +export const fetchDredgingGridPage = async ( + query: DredgingGridQuery, +): Promise<{ content: Array & { recordIds: string[] }>; totalElements: number; number: number; @@ -55,12 +57,18 @@ export const fetchDredgingGridPage = async (query: DredgingGridQuery): Promise<{ }; /** 적용 조건 전체의 서버 XLSX를 받는다. page/size는 다운로드에 전달하지 않는다. */ -export const downloadDredgingGridExcel = (query: Omit): Promise => { +export const downloadDredgingGridExcel = ( + query: Omit, +): Promise => { const params = new URLSearchParams(); Object.entries(query).forEach(([key, value]) => { if (value !== undefined && value !== "") params.set(key, String(value)); }); - return requestGridExcel(`v1/dredging/grid/exportJobs?${params}`, {}, `준설관리_${query.viewType}_${query.fromDate}_${query.toDate}.xlsx`); + return requestGridExcel( + `v1/dredging/grid/exportJobs?${params}`, + {}, + `준설관리_${query.viewType}_${query.fromDate}_${query.toDate}.xlsx`, + ); }; type LocationRequest = GeoPoint & { @@ -99,7 +107,6 @@ export const fetchDredgingRecords = async (params: { } }; - /** 등록된 준설사진의 공통 첨부파일 참조를 조회한다. */ export const fetchDredgingPhotos = async ( dredgingJobSn: string | number, diff --git a/apps/sn/src/features/dredging/lib/use-dredging-common-codes.ts b/apps/sn/src/features/dredging/lib/use-dredging-common-codes.ts index 3f4083b8a..e37b69d1d 100644 --- a/apps/sn/src/features/dredging/lib/use-dredging-common-codes.ts +++ b/apps/sn/src/features/dredging/lib/use-dredging-common-codes.ts @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; -import { getCommonCodes } from "@/components/lib/common/apiHelpers"; +import { getCommonCodes } from "@/entities/code/api/common-code-api"; import type { DredgingLocationSource, diff --git a/apps/sn/src/features/dredging/lib/use-dredging-list-data.ts b/apps/sn/src/features/dredging/lib/use-dredging-list-data.ts new file mode 100644 index 000000000..b39777080 --- /dev/null +++ b/apps/sn/src/features/dredging/lib/use-dredging-list-data.ts @@ -0,0 +1,117 @@ +"use client"; +import { useCallback, useEffect, useState } from "react"; +import type { UiTableSort } from "@common/business_components"; +import { fetchDredgingGridPage } from "../api/dredging-api"; +import { isDredgingBackendEnabled } from "../api/backend-mode"; +import type { DredgingRecord, DredgingViewType } from "../model/types"; +import { + PAGE_SIZE, + DREDGING_LOAD_ERROR_MESSAGE, + initialRecords, + type TableRow, +} from "../model/dredging-list-view"; + +type Query = { + appliedFromDate: string; + appliedToDate: string; + appliedCtpvName: string; + appliedSggName: string; + viewType: DredgingViewType; + currentPage: number; + sort?: UiTableSort; + searchVersion: number; +}; +/** 서버 페이지·전체 건수·조회 상태를 소유한다. 로컬 demo 갱신은 명시적 명령으로만 제공한다. */ +export function useDredgingListData({ + appliedFromDate, + appliedToDate, + appliedCtpvName, + appliedSggName, + viewType, + currentPage, + sort, + searchVersion, +}: Query) { + const [records, setRecords] = useState( + isDredgingBackendEnabled ? [] : initialRecords, + ); + const [serverRows, setServerRows] = useState([]); + const [serverTotal, setServerTotal] = useState(0); + const [isLoading, setIsLoading] = useState(isDredgingBackendEnabled); + const [loadError, setLoadError] = useState(null); + useEffect(() => { + if (!isDredgingBackendEnabled) return; + let cancelled = false; + const load = async () => { + setIsLoading(true); + setLoadError(null); + try { + const response = await fetchDredgingGridPage({ + viewType, + fromDate: appliedFromDate, + toDate: appliedToDate, + ctpv: appliedCtpvName, + sgg: appliedSggName, + page: currentPage - 1, + size: PAGE_SIZE, + sort: sort ? `${sort.key},${sort.direction}` : undefined, + }); + if (!cancelled) { + setServerRows(response.content); + setServerTotal(response.totalElements); + setRecords( + viewType === "MUNICIPALITY" + ? (response.content as unknown as DredgingRecord[]) + : [], + ); + } + } catch { + if (!cancelled) { + setRecords([]); + setServerRows([]); + setServerTotal(0); + setLoadError(DREDGING_LOAD_ERROR_MESSAGE); + } + } finally { + if (!cancelled) setIsLoading(false); + } + }; + void load(); + return () => { + cancelled = true; + }; + // 조회 버튼과 등록 완료 이벤트에서 searchVersion을 증가시켜 명시적으로 재조회한다. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + searchVersion, + currentPage, + viewType, + sort, + appliedFromDate, + appliedToDate, + appliedCtpvName, + appliedSggName, + ]); + const prependDemoRecords = useCallback((rows: DredgingRecord[]) => { + if (!isDredgingBackendEnabled) + setRecords((current) => [...rows, ...current]); + }, []); + const removeDemoJob = useCallback( + (jobSn: DredgingRecord["dredgingJobSn"]) => { + if (!isDredgingBackendEnabled) + setRecords((current) => + current.filter((row) => row.dredgingJobSn !== jobSn), + ); + }, + [], + ); + return { + records, + serverRows, + serverTotal, + isLoading, + loadError, + prependDemoRecords, + removeDemoJob, + }; +} diff --git a/apps/sn/src/features/dredging/model/dredging-list-view.ts b/apps/sn/src/features/dredging/model/dredging-list-view.ts new file mode 100644 index 000000000..f75820b67 --- /dev/null +++ b/apps/sn/src/features/dredging/model/dredging-list-view.ts @@ -0,0 +1,273 @@ +/** 준설 목록·집계 표의 컬럼·표시 변환. 서버 조회/React 상태와 분리한다. */ +import type { DredgingRecord } from "./types"; +import { MS_SWAGE_PPL_CODES } from "@/entities/facility/model/facility-code-fallbacks"; + +export const PAGE_SIZE = 10; + +export const ALL_REGION_OPTION = { text: "전체", val: "0" }; + +export const pipeMaterialNameByCode = new Map( + MS_SWAGE_PPL_CODES.map((item) => [item.val.trim(), item.text]), +); + +export const resolvePipeMaterialName = ( + value: string, + names: ReadonlyMap = pipeMaterialNameByCode, +) => { + const normalizedValue = value.trim(); + if (!normalizedValue) return "확인불가"; + const normalizedCode = normalizedValue.toUpperCase(); + if (!/^MOP\d+$/.test(normalizedCode)) return normalizedValue; + return names.get(normalizedCode) ?? "확인불가"; +}; + +// 백엔드·프록시 상세 오류 대신 사용자가 다음 행동을 알 수 있는 문구만 표시한다. +export const DREDGING_LOAD_ERROR_MESSAGE = + "준설현황을 불러오지 못했습니다. 잠시 후 다시 시도해주세요."; + +export const currentYear = new Date().getFullYear(); + +export const formatLocalDate = (date: Date) => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +}; + +/** 준설일자 기본 검색기간을 오늘까지의 최근 1년으로 생성한다. */ +export const createDefaultDredgingDateRange = () => { + const to = new Date(); + const from = new Date(to); + from.setFullYear(from.getFullYear() - 1); + return { + fromDate: formatLocalDate(from), + toDate: formatLocalDate(to), + }; +}; + +export const initialRecords: DredgingRecord[] = [ + { + id: "mock-dredging-1", + dredgingJobSn: "mock-dredging-job-1", + pipeFid: "MOCK-PIPE-1", + mngInstCd: "4173000", + ctpv: "경기도", + sgg: "양평군", + treatmentZone: "양평처리구역", + fcltMngNo: "PPL-DEMO-001", + pipeDiameterMm: 450, + pipeLengthM: 82.4, + pipeMaterial: "콘크리트", + installationYear: 2004, + dredgingDate: `${currentYear}-05-14`, + photoCount: 3, + registeredDate: `${currentYear}-05-15`, + longitude: 127.4875, + latitude: 37.4916, + snappedLongitude: 127.48751, + snappedLatitude: 37.49159, + registrationMode: "PHOTO", + registrationSource: "EXIF", + matchDistanceMeters: 4.28, + isMock: true, + }, + { + id: "mock-dredging-2", + dredgingJobSn: "mock-dredging-job-2", + pipeFid: "MOCK-PIPE-2", + mngInstCd: "1153000", + ctpv: "서울특별시", + sgg: "구로구", + treatmentZone: "서남처리구역", + fcltMngNo: "PPL-DEMO-002", + pipeDiameterMm: 900, + pipeLengthM: 116.7, + pipeMaterial: "PVC", + installationYear: 1992, + dredgingDate: `${currentYear - 1}-11-08`, + photoCount: 0, + registeredDate: `${currentYear - 1}-11-09`, + longitude: 126.8874, + latitude: 37.4954, + snappedLongitude: 126.88741, + snappedLatitude: 37.49539, + registrationMode: "MANUAL", + registrationSource: "MANUAL", + matchDistanceMeters: 8.15, + isMock: true, + }, +]; + +/** 상세·수정 팝업이 동일한 관로·위치 정보를 받도록 쿼리 파라미터를 한곳에서 구성한다. */ +export const buildDredgingPopupParams = (record: DredgingRecord) => ({ + sourcePath: window.location.pathname, + id: record.id, + dredgingJobSn: record.dredgingJobSn, + pipeFid: record.pipeFid, + mngInstCd: record.mngInstCd, + ctpv: record.ctpv, + sgg: record.sgg, + treatmentZone: record.treatmentZone, + fcltMngNo: record.fcltMngNo, + pipeDiameterMm: record.pipeDiameterMm, + pipeLengthM: record.pipeLengthM, + pipeMaterial: record.pipeMaterial, + installationYear: record.installationYear, + dredgingDate: record.dredgingDate, + photoCount: record.photoCount, + registeredDate: record.registeredDate, + longitude: record.longitude, + latitude: record.latitude, + snappedLongitude: record.snappedLongitude, + snappedLatitude: record.snappedLatitude, + registrationMode: + record.registrationMode ?? + (record.photoCount === 0 && record.registrationSource === "MANUAL" + ? "MANUAL" + : "PHOTO"), + registrationSource: record.registrationSource, + matchDistanceMeters: record.matchDistanceMeters, + isMock: record.isMock, +}); + +export type TableRow = Record & { + recordIds: string[]; +}; + +export type TableHeadDefinition = { + id: string; + title: string; + align?: "left" | "center" | "right"; +}; + +export const municipalityHeadlist: TableHeadDefinition[] = [ + { id: "rowNumber", title: "순번" }, + { id: "ctpv", title: "시도" }, + { id: "sgg", title: "시군구" }, + { id: "treatmentZone", title: "처리구역" }, + { id: "fcltMngNo", title: "관로 번호" }, + { id: "pipeDiameterMm", title: "관경(mm)", align: "right" }, + { id: "pipeLengthM", title: "관로연장(m)", align: "right" }, + { id: "pipeMaterial", title: "관재질" }, + { id: "installationYear", title: "매설년도" }, + { id: "elapsedYears", title: "경과년도" }, + { id: "dredgingDate", title: "준설일자" }, + { id: "photoCount", title: "등록사진건수", align: "right" }, + { id: "registeredDate", title: "등록일자" }, +]; + +export const diameterHeadlist: TableHeadDefinition[] = [ + { id: "ctpv", title: "시도" }, + { id: "totalLengthKm", title: "관로연장(km)", align: "right" }, + { id: "diameter450", title: "450 이하", align: "right" }, + { id: "diameter600", title: "450~600", align: "right" }, + { id: "diameter700", title: "700", align: "right" }, + { id: "diameter900", title: "800~900", align: "right" }, + { id: "diameter1000", title: "1000", align: "right" }, + { id: "diameter1200", title: "1200", align: "right" }, + { id: "diameter1500", title: "1500 이상", align: "right" }, +]; + +export const elapsedYearHeadlist: TableHeadDefinition[] = [ + { id: "ctpv", title: "시도" }, + { id: "totalLengthKm", title: "관로연장(km)", align: "right" }, + { id: "under10", title: "10년 미만", align: "right" }, + { id: "year10To20", title: "10년~20년", align: "right" }, + { id: "year20To30", title: "20년~30년", align: "right" }, + { id: "over30", title: "30년 이상", align: "right" }, + { id: "unknown", title: "확인불가", align: "right" }, +]; + +export const roundKm = (meters: number) => Number((meters / 1000).toFixed(3)); + +export const parseLocalDate = (value: string) => new Date(`${value}T00:00:00`); + +export const formatTableValue = (value: unknown) => { + if (typeof value === "number") return value.toLocaleString("ko-KR"); + if (value === null || value === undefined) return ""; + return String(value); +}; + +export const groupByCtpv = (records: DredgingRecord[]) => { + const groups = new Map(); + for (const record of records) { + groups.set(record.ctpv, [...(groups.get(record.ctpv) ?? []), record]); + } + return groups; +}; + +export const buildDiameterRows = (records: DredgingRecord[]): TableRow[] => + Array.from(groupByCtpv(records), ([ctpv, items]) => { + const sumLength = (predicate: (record: DredgingRecord) => boolean) => + roundKm( + items + .filter(predicate) + .reduce((sum, record) => sum + record.pipeLengthM, 0), + ); + + return { + ctpv, + totalLengthKm: roundKm( + items.reduce((sum, record) => sum + record.pipeLengthM, 0), + ), + diameter450: sumLength((record) => record.pipeDiameterMm <= 450), + diameter600: sumLength( + (record) => record.pipeDiameterMm > 450 && record.pipeDiameterMm <= 600, + ), + diameter700: sumLength( + (record) => record.pipeDiameterMm > 600 && record.pipeDiameterMm <= 700, + ), + diameter900: sumLength( + (record) => record.pipeDiameterMm > 700 && record.pipeDiameterMm <= 900, + ), + diameter1000: sumLength( + (record) => + record.pipeDiameterMm > 900 && record.pipeDiameterMm <= 1000, + ), + diameter1200: sumLength( + (record) => + record.pipeDiameterMm > 1000 && record.pipeDiameterMm <= 1200, + ), + diameter1500: sumLength((record) => record.pipeDiameterMm >= 1500), + recordIds: items.map((record) => record.id), + }; + }); + +export const buildElapsedYearRows = (records: DredgingRecord[]): TableRow[] => + Array.from(groupByCtpv(records), ([ctpv, items]) => { + const sumLength = (predicate: (record: DredgingRecord) => boolean) => + roundKm( + items + .filter(predicate) + .reduce((sum, record) => sum + record.pipeLengthM, 0), + ); + const elapsedYears = (record: DredgingRecord) => + record.installationYear === null + ? null + : currentYear - record.installationYear; + + return { + ctpv, + totalLengthKm: roundKm( + items.reduce((sum, record) => sum + record.pipeLengthM, 0), + ), + under10: sumLength((record) => { + const elapsed = elapsedYears(record); + return elapsed !== null && elapsed < 10; + }), + year10To20: sumLength((record) => { + const elapsed = elapsedYears(record); + return elapsed !== null && elapsed >= 10 && elapsed < 20; + }), + year20To30: sumLength((record) => { + const elapsed = elapsedYears(record); + return elapsed !== null && elapsed >= 20 && elapsed < 30; + }), + over30: sumLength((record) => { + const elapsed = elapsedYears(record); + return elapsed !== null && elapsed >= 30; + }), + unknown: sumLength((record) => elapsedYears(record) === null), + recordIds: items.map((record) => record.id), + }; + }); diff --git a/apps/sn/app/(with-base-layout)/oper/dredging/components/DredgingDetailPopupPage.tsx b/apps/sn/src/features/dredging/ui/dredging-detail-popup-page.tsx similarity index 96% rename from apps/sn/app/(with-base-layout)/oper/dredging/components/DredgingDetailPopupPage.tsx rename to apps/sn/src/features/dredging/ui/dredging-detail-popup-page.tsx index 8f9a9b9c4..5223ffcb8 100644 --- a/apps/sn/app/(with-base-layout)/oper/dredging/components/DredgingDetailPopupPage.tsx +++ b/apps/sn/src/features/dredging/ui/dredging-detail-popup-page.tsx @@ -13,8 +13,8 @@ import { TableHead, TableRow, } from "@common/components"; -import { MS_SWAGE_PPL_CODES } from "@/app/(map-layout)/map/components/common/table"; -import { fileDownload } from "@/components/lib/utils/fileDownload"; +import { useFacilityCodeCatalog } from "@/entities/facility/lib/use-facility-code-catalog"; +import { fileDownload } from "@/components/lib/utils/file-download"; import { useAlertStore } from "@/shared/lib"; import { useEffect, useMemo, useRef, useState } from "react"; @@ -23,13 +23,13 @@ import { fetchDredgingPhotos, findNearestPipeMatches, updateDredgingRecord, - uploadDredgingPhotoDrafts, type AttachmentReference, -} from "../lib/client"; -import { formatDredgingCapturedDate } from "../lib/date"; -import { readPhotoMetadata } from "../lib/exif"; -import { useDredgingCommonCodes } from "../lib/useDredgingCommonCodes"; -import { createDredgingClientUuid } from "../lib/uuid"; +} from "@/features/dredging/api/dredging-api"; +import { uploadDredgingPhotoDrafts } from "@/features/dredging/api/photo-upload"; +import { formatDredgingCapturedDate } from "@/features/dredging/lib/date"; +import { readPhotoMetadata } from "@/features/dredging/lib/exif"; +import { useDredgingCommonCodes } from "@/features/dredging/lib/use-dredging-common-codes"; +import { createDredgingClientUuid } from "@/features/dredging/lib/uuid"; import { EMPTY_PHOTO_METADATA, type DredgingPhoto, @@ -38,11 +38,14 @@ import { type DredgingRegistrationMode, type GeoPoint, type PipeMatch, -} from "../lib/types"; +} from "@/features/dredging/model/types"; -const DredgingLocationMap = dynamic(() => import("./DredgingLocationMap"), { - ssr: false, -}); +const DredgingLocationMap = dynamic( + () => import("@/features/dredging/ui/dredging-location-map"), + { + ssr: false, + }, +); type Props = { record: DredgingRecord; @@ -59,11 +62,10 @@ type NewPhotoPreview = DredgingPhotoDraft & { previewUrl: string; }; -const pipeMaterialNameByCode = new Map( - MS_SWAGE_PPL_CODES.map((item) => [item.val.trim(), item.text]), -); - -const resolvePipeMaterialName = (value: string) => { +const resolveMaterialName = ( + value: string, + pipeMaterialNameByCode: Map, +) => { const normalizedValue = value.trim(); if (!normalizedValue) return "확인불가"; const normalizedCode = normalizedValue.toUpperCase(); @@ -86,6 +88,14 @@ const DredgingDetailPopupPage = ({ mode = "detail", onUpdated, }: Props) => { + const { MS_SWAGE_PPL_CODES } = useFacilityCodeCatalog(); + const materialNames = useMemo( + () => + new Map(MS_SWAGE_PPL_CODES.map((item) => [item.val.trim(), item.text])), + [MS_SWAGE_PPL_CODES], + ); + const resolvePipeMaterialName = (value: string) => + resolveMaterialName(value, materialNames); const alert = useAlertStore((state) => state.setMessage); const { confirm } = useConfirm(); // 수정 화면도 등록 화면과 같은 CMS 공통코드 표시명을 공유한다. diff --git a/apps/sn/src/features/dredging/ui/dredging-list-page.tsx b/apps/sn/src/features/dredging/ui/dredging-list-page.tsx new file mode 100644 index 000000000..7b9c795a6 --- /dev/null +++ b/apps/sn/src/features/dredging/ui/dredging-list-page.tsx @@ -0,0 +1,693 @@ +/** + * ➡️ 경로 : 유지관리 정보 - 준설관리 + * + * - SFR-010-012 화면설계서 50~55쪽을 기준으로 한 FE 선행 구현 + * - 사진 EXIF 촬영일/GPS, 지도 수동 위치, 최근접 관로 자동 매핑 흐름 제공 + * - 지자체별/관경별/경과년도별 조회와 XLSX 다운로드 제공 + */ +"use client"; +import { useFacilityCodeCatalog } from "@/entities/facility/lib/use-facility-code-catalog"; +import { useDredgingListData } from "../lib/use-dredging-list-data"; +import { + PAGE_SIZE, + ALL_REGION_OPTION, + resolvePipeMaterialName, + currentYear, + formatLocalDate, + createDefaultDredgingDateRange, + buildDredgingPopupParams, + type TableRow, + municipalityHeadlist, + diameterHeadlist, + elapsedYearHeadlist, + parseLocalDate, + formatTableValue, + buildDiameterRows, + buildElapsedYearRows, +} from "../model/dredging-list-view"; + +import LoadingOverlay from "@/components/ui/loading-bar"; +import { useAlertStore } from "@/shared/lib"; +import { + StandardDataTable, + StandardTableSortHeader, + sortStandardTableRows, + type UiTableSort, +} from "@common/business_components"; +import { + Button, + InputDatePicker, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@common/components"; +import { openWindowPopup } from "@/features/window-popup/lib/open-window-popup"; +import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types"; +import useStore from "@/entities/reference-data/model/reference-data-store"; +import { + getCoreRowModel, + useReactTable, + type ColumnDef, + type PaginationState, +} from "@tanstack/react-table"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useConfirm } from "@frontend-opensource/use-react-hooks"; + +import { + deleteDredgingRecord, + downloadDredgingGridExcel, +} from "@/features/dredging/api/dredging-api"; +import { isDredgingBackendEnabled } from "@/features/dredging/api/backend-mode"; +import { createDredgingClientUuid } from "@/features/dredging/lib/uuid"; +import type { + DredgingRecord, + DredgingSaveGroup, + DredgingViewType, +} from "@/features/dredging/model/types"; + +const Page = () => { + const { MS_SWAGE_PPL_CODES: materialOptions } = useFacilityCodeCatalog(); + const materialNames = useMemo( + () => new Map(materialOptions.map((option) => [option.val, option.text])), + [materialOptions], + ); + const alert = useAlertStore((state) => state.setMessage); + const { confirm } = useConfirm(); + const defaultDredgingDateRange = useMemo(createDefaultDredgingDateRange, []); + const { + ctpv: ctpvRegionOptions, + sgg: sggRegionOptions, + fetchCtpv, + fetchSgg, + } = useStore(); + const [fromDate, setFromDate] = useState(defaultDredgingDateRange.fromDate); + const [toDate, setToDate] = useState(defaultDredgingDateRange.toDate); + // 날짜 입력 중에는 기존 결과를 유지하고 조회·초기화 시점에만 적용 기간을 갱신한다. + const [appliedFromDate, setAppliedFromDate] = useState( + defaultDredgingDateRange.fromDate, + ); + const [appliedToDate, setAppliedToDate] = useState( + defaultDredgingDateRange.toDate, + ); + // 선택 UI는 행정구역 코드를, 준설 조회 API는 행정구역 명칭을 사용한다. + const [ctpvCd, setCtpvCd] = useState("0"); + const [sggCd, setSggCd] = useState("0"); + const [ctpvName, setCtpvName] = useState(""); + const [sggName, setSggName] = useState(""); + const [appliedCtpvName, setAppliedCtpvName] = useState(""); + const [appliedSggName, setAppliedSggName] = useState(""); + const [viewType, setViewType] = useState("MUNICIPALITY"); + const [isDownloading, setIsDownloading] = useState(false); + const [selectedRecordId, setSelectedRecordId] = useState(null); + // 삭제 중 중복 요청과 선택 변경을 막는다. + const [isMutating, setIsMutating] = useState(false); + const [currentPage, setCurrentPage] = useState(1); + // 조회구분별 전체 결과를 페이지 절단 전에 정렬하는 단일 상태다. + const [sort, setSort] = useState(); + const [searchVersion, setSearchVersion] = useState(0); + const { + records, + serverRows, + serverTotal, + isLoading: isQueryLoading, + loadError, + prependDemoRecords, + removeDemoJob, + } = useDredgingListData({ + appliedFromDate, + appliedToDate, + appliedCtpvName, + appliedSggName, + viewType, + currentPage, + sort, + searchVersion, + }); + const isLoading = isQueryLoading || isDownloading; + + /** 공통 레이아웃에서 아직 지역 기준정보를 받지 못한 경우 조회를 보완한다. */ + useEffect(() => { + if (ctpvRegionOptions.length === 0) void fetchCtpv(); + if (sggRegionOptions.length === 0) void fetchSgg(); + }, [ctpvRegionOptions.length, fetchCtpv, fetchSgg, sggRegionOptions.length]); + + /** 선택한 시도 코드와 앞 두 자리가 같은 시군구만 후보로 제공한다. */ + const availableSggOptions = useMemo(() => { + if (ctpvCd === "0") return []; + const ctpvPrefix = ctpvCd.slice(0, 2); + return sggRegionOptions.filter((option) => + String(option.val).startsWith(ctpvPrefix), + ); + }, [ctpvCd, sggRegionOptions]); + + // 조회 버튼을 누른 시점의 기간으로 실제 준설관리 API를 호출한다. + + const filteredRecords = useMemo( + () => + records.filter( + (record) => + record.dredgingDate >= appliedFromDate && + record.dredgingDate <= appliedToDate && + (!appliedCtpvName || record.ctpv === appliedCtpvName) && + (!appliedSggName || record.sgg === appliedSggName), + ), + [ + records, + appliedFromDate, + appliedToDate, + appliedCtpvName, + appliedSggName, + searchVersion, + ], + ); + + const tableRows = useMemo(() => { + if (isDredgingBackendEnabled) { + return serverRows.map((row) => + viewType === "MUNICIPALITY" + ? { + ...row, + pipeMaterial: resolvePipeMaterialName( + String(row.pipeMaterial ?? ""), + materialNames, + ), + installationYear: row.installationYear ?? "확인불가", + elapsedYears: row.elapsedYears ?? "확인불가", + } + : row, + ); + } + if (viewType === "DIAMETER") return buildDiameterRows(filteredRecords); + if (viewType === "ELAPSED_YEAR") + return buildElapsedYearRows(filteredRecords); + + return filteredRecords.map((record, index) => ({ + rowNumber: index + 1, + ...record, + pipeLengthM: Number(record.pipeLengthM.toFixed(2)), + pipeMaterial: resolvePipeMaterialName(record.pipeMaterial, materialNames), + installationYear: record.installationYear ?? "확인불가", + elapsedYears: + record.installationYear === null + ? "확인불가" + : currentYear - record.installationYear, + recordIds: [record.id], + })); + }, [filteredRecords, viewType, serverRows, materialNames]); + + // 운영 모드는 서버가 정렬·페이징하며 로컬 시연 모드만 메모리 처리를 유지한다. + const sortedTableRows = useMemo( + () => + isDredgingBackendEnabled + ? tableRows + : sortStandardTableRows(tableRows, sort), + [sort, tableRows], + ); + + const pageRows = useMemo( + () => + isDredgingBackendEnabled + ? sortedTableRows + : sortedTableRows.slice( + (currentPage - 1) * PAGE_SIZE, + currentPage * PAGE_SIZE, + ), + [currentPage, sortedTableRows], + ); + + const selectedRecord = + records.find((record) => record.id === selectedRecordId) ?? null; + const canManageSelectedRecord = + viewType === "MUNICIPALITY" && selectedRecord !== null && !isMutating; + + const headlist = + viewType === "MUNICIPALITY" + ? municipalityHeadlist + : viewType === "DIAMETER" + ? diameterHeadlist + : elapsedYearHeadlist; + + const title = "준설 목록"; + + const columns = useMemo[]>( + () => + headlist.map((head) => ({ + id: head.id, + accessorFn: (row) => row[head.id], + // 표시명 컬럼까지 서버 전체 정렬을 사용한다. + header: () => ( + { + setSort(nextSort); + setCurrentPage(1); + setSelectedRecordId(null); + }} + /> + ), + cell: ({ getValue }) => formatTableValue(getValue()), + meta: { + align: head.align ?? "center", + }, + })), + [headlist, sort, viewType], + ); + const pagination: PaginationState = { + pageIndex: currentPage - 1, + pageSize: PAGE_SIZE, + }; + const selectedTableRowId = pageRows + .find( + (row) => + selectedRecordId !== null && row.recordIds.includes(selectedRecordId), + ) + ?.recordIds.join("|"); + const table = useReactTable({ + data: pageRows, + columns, + state: { + pagination, + rowSelection: selectedTableRowId ? { [selectedTableRowId]: true } : {}, + }, + rowCount: isDredgingBackendEnabled ? serverTotal : sortedTableRows.length, + manualPagination: true, + getCoreRowModel: getCoreRowModel(), + getRowId: (row) => row.recordIds.join("|"), + onPaginationChange: (updater) => { + const next = + typeof updater === "function" ? updater(pagination) : updater; + setCurrentPage(next.pageIndex + 1); + setSelectedRecordId(null); + }, + }); + + const handleSearch = () => { + if (fromDate > toDate) { + alert("조회 시작일은 종료일보다 늦을 수 없습니다."); + return; + } + setCurrentPage(1); + setSelectedRecordId(null); + setAppliedFromDate(fromDate); + setAppliedToDate(toDate); + setAppliedCtpvName(ctpvName); + setAppliedSggName(sggName); + setSearchVersion((version) => version + 1); + }; + + const handleReset = () => { + setFromDate(defaultDredgingDateRange.fromDate); + setToDate(defaultDredgingDateRange.toDate); + setAppliedFromDate(defaultDredgingDateRange.fromDate); + setAppliedToDate(defaultDredgingDateRange.toDate); + setViewType("MUNICIPALITY"); + setCtpvCd("0"); + setSggCd("0"); + setCtpvName(""); + setSggName(""); + setAppliedCtpvName(""); + setAppliedSggName(""); + setSort(undefined); + setCurrentPage(1); + setSelectedRecordId(null); + setSearchVersion((version) => version + 1); + }; + + const handleDownload = async () => { + try { + if (isDredgingBackendEnabled) { + setIsDownloading(true); + await downloadDredgingGridExcel({ + viewType, + fromDate: appliedFromDate, + toDate: appliedToDate, + ctpv: appliedCtpvName, + sgg: appliedSggName, + sort: sort ? `${sort.key},${sort.direction}` : undefined, + }); + return; + } + const visibleHeads = headlist.filter((head) => head.id !== "recordIds"); + const excelRows = [ + visibleHeads.map((head) => head.title), + ...sortedTableRows.map((row) => + visibleHeads.map((head) => row[head.id] ?? ""), + ), + ]; + const { utils, writeFileXLSX } = await import("xlsx"); + const worksheet = utils.aoa_to_sheet(excelRows); + worksheet["!cols"] = visibleHeads.map((head) => ({ + wch: Math.max(head.title.length * 2, 12), + })); + const workbook = utils.book_new(); + utils.book_append_sheet(workbook, worksheet, title.slice(0, 31)); + writeFileXLSX( + workbook, + `준설관리_${viewType}_${fromDate}_${toDate}.xlsx`, + { compression: true }, + ); + } catch (error) { + console.error("준설현황 엑셀 다운로드 실패", error); + alert("엑셀 파일을 생성하지 못했습니다."); + } finally { + setIsDownloading(false); + } + }; + + const handleSaved = useCallback( + (savedGroups: DredgingSaveGroup[]) => { + if (isDredgingBackendEnabled) { + setCurrentPage(1); + setSelectedRecordId(null); + setSearchVersion((version) => version + 1); + return; + } + const registeredDate = formatLocalDate(new Date()); + const localJobSn = `local-job-${createDredgingClientUuid()}`; + const addedRecords = savedGroups.map((group, index) => ({ + id: `${localJobSn}-${index + 1}`, + dredgingJobSn: localJobSn, + pipeFid: group.pipe.fid, + mngInstCd: group.pipe.mngInstCd, + ctpv: ctpvName || "경기도", + sgg: sggName || "양평군", + treatmentZone: group.pipe.swtrmtZoneNm || "확인불가", + fcltMngNo: group.pipe.fcltMngNo, + pipeDiameterMm: group.pipe.pipeDiameterMm, + pipeLengthM: group.pipe.pipeLengthM, + pipeMaterial: group.pipe.pipeMaterial, + installationYear: group.pipe.installationYear, + dredgingDate: group.dredgingDate, + photoCount: group.photos.length, + registeredDate, + longitude: group.location.longitude, + latitude: group.location.latitude, + snappedLongitude: group.pipe.snappedLongitude, + snappedLatitude: group.pipe.snappedLatitude, + registrationMode: group.registrationMode, + registrationSource: group.locationSource, + matchDistanceMeters: group.pipe.distanceMeters, + isMock: group.pipe.isMock || !isDredgingBackendEnabled, + })); + prependDemoRecords(addedRecords); + setCurrentPage(1); + }, + [ctpvName, sggName, prependDemoRecords], + ); + + /** 상세 화면과 같은 최대화 팝업에서 준설일자·사진·위치를 함께 수정한다. */ + const openEditRecord = () => { + if (!canManageSelectedRecord || !selectedRecord) return; + + const popup = openWindowPopup( + "dredgingEdit", + buildDredgingPopupParams(selectedRecord), + { maximize: true }, + ); + if (!popup) { + alert( + "준설정보 수정 팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.", + ); + } + }; + + /** 선택 행이 속한 준설작업과 연결 관로·사진 참조를 물리 삭제하고 목록을 재조회한다. */ + const handleDeleteRecord = async () => { + if (!canManageSelectedRecord || !selectedRecord) return; + const confirmed = await confirm( + `선택한 준설작업 전체를 삭제하시겠습니까?\n준설일자: ${selectedRecord.dredgingDate}\n현재 페이지에 보이지 않는 연결 관로와 사진도 모두 삭제됩니다.`, + ); + if (!confirmed) return; + + setIsMutating(true); + try { + await deleteDredgingRecord(selectedRecord.dredgingJobSn); + if (isDredgingBackendEnabled) { + setSearchVersion((version) => version + 1); + } else { + removeDemoJob(selectedRecord.dredgingJobSn); + } + setSelectedRecordId(null); + alert("준설정보를 삭제했습니다."); + } catch (error) { + alert( + error instanceof Error + ? error.message + : "준설정보를 삭제하지 못했습니다.", + ); + } finally { + setIsMutating(false); + } + }; + + /** 공통 WindowPopup 체계로 준설결과 등록 화면을 연다. */ + const openDredgingRegister = () => { + const popup = openWindowPopup( + "dredgingRegister", + { sourcePath: window.location.pathname }, + { maximize: true }, + ); + if (!popup) { + alert( + "준설결과 등록 팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.", + ); + } + }; + + /** 등록·수정 팝업의 저장 완료 메시지를 받아 목록을 최신 상태로 갱신한다. */ + useEffect(() => { + const handlePopupMessage = (event: MessageEvent) => { + if (event.origin !== window.location.origin) return; + if (event.data?.type === WINDOW_POPUP_MESSAGE_TYPE.DREDGING_UPDATED) { + setSelectedRecordId(null); + setCurrentPage(1); + setSearchVersion((version) => version + 1); + return; + } + if (event.data?.type !== WINDOW_POPUP_MESSAGE_TYPE.DREDGING_REGISTERED) { + return; + } + + try { + const savedGroups = JSON.parse( + event.data?.payload?.groups ?? "[]", + ) as DredgingSaveGroup[]; + if (Array.isArray(savedGroups) && savedGroups.length > 0) { + handleSaved(savedGroups); + } + } catch (error) { + console.error("준설결과 등록 팝업 응답 처리 실패", error); + } + }; + + window.addEventListener("message", handlePopupMessage); + return () => window.removeEventListener("message", handlePopupMessage); + }, [handleSaved]); + + return ( + <> + +
+
{ + event.preventDefault(); + handleSearch(); + }} + > +
+
+ + date && setFromDate(formatLocalDate(date))} + className="w-[15.5rem]" + /> + ~ + date && setToDate(formatLocalDate(date))} + className="w-[15.5rem]" + /> +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + + +
+
+
+ + {loadError ? ( +
+ {loadError} + +
+ ) : null} + +
+ { + setSelectedRecordId(row.recordIds[0] ?? null); + }} + actions={ + <> + + + + + + } + /> +
+ + ); +}; + +export default Page; diff --git a/apps/sn/app/(with-base-layout)/oper/dredging/components/DredgingLocationMap.tsx b/apps/sn/src/features/dredging/ui/dredging-location-map.tsx similarity index 98% rename from apps/sn/app/(with-base-layout)/oper/dredging/components/DredgingLocationMap.tsx rename to apps/sn/src/features/dredging/ui/dredging-location-map.tsx index 886fe7d4e..3473d513d 100644 --- a/apps/sn/app/(with-base-layout)/oper/dredging/components/DredgingLocationMap.tsx +++ b/apps/sn/src/features/dredging/ui/dredging-location-map.tsx @@ -16,11 +16,11 @@ import Style from "ol/style/Style.js"; import type { StyleFunction, StyleLike } from "ol/style/Style.js"; import { useEffect, useMemo, useRef, useState } from "react"; -import { getRawJson } from "@/components/lib/api"; +import { getRawJson } from "@/shared/api/sewerage-api"; import Gis, { useGisContext } from "@common/business_components/ui/map/Gis"; import GisApp from "@common/business_components/ui/map/lib/gis.core"; -import type { GeoPoint, PipeMatch } from "../lib/types"; +import type { GeoPoint, PipeMatch } from "@/features/dredging/model/types"; type DredgingLocationMapProps = { value: GeoPoint | null; diff --git a/apps/sn/app/(with-base-layout)/oper/dredging/components/DredgingRegisterModal.tsx b/apps/sn/src/features/dredging/ui/dredging-register-modal.tsx similarity index 96% rename from apps/sn/app/(with-base-layout)/oper/dredging/components/DredgingRegisterModal.tsx rename to apps/sn/src/features/dredging/ui/dredging-register-modal.tsx index d753286e4..34146bdb9 100644 --- a/apps/sn/app/(with-base-layout)/oper/dredging/components/DredgingRegisterModal.tsx +++ b/apps/sn/src/features/dredging/ui/dredging-register-modal.tsx @@ -6,7 +6,7 @@ import dynamic from "next/dynamic"; import { useConfirm } from "@frontend-opensource/use-react-hooks"; -import { DredgingRegistrationBody } from "@/features/dredging/ui/DredgingRegistrationBody"; +import { DredgingRegistrationBody } from "@/features/dredging/ui/dredging-registration-body"; import { StandardPopupLayout } from "@common/business_components"; import { Button, @@ -21,27 +21,30 @@ import { useEffect, useRef, useState } from "react"; import { findNearestPipeMatches, - isDredgingBackendEnabled, saveDredgingRegistrations, type AttachmentReference, - uploadDredgingPhotos, -} from "../lib/client"; -import { readPhotoMetadata } from "../lib/exif"; -import { useDredgingCommonCodes } from "../lib/useDredgingCommonCodes"; -import { createDredgingClientUuid } from "../lib/uuid"; -import { createDredgingRegistrationSession } from "../lib/registration-request"; +} from "@/features/dredging/api/dredging-api"; +import { isDredgingBackendEnabled } from "@/features/dredging/api/backend-mode"; +import { uploadDredgingPhotos } from "@/features/dredging/api/photo-upload"; +import { readPhotoMetadata } from "@/features/dredging/lib/exif"; +import { useDredgingCommonCodes } from "@/features/dredging/lib/use-dredging-common-codes"; +import { createDredgingClientUuid } from "@/features/dredging/lib/uuid"; +import { createDredgingRegistrationSession } from "@/features/dredging/model/registration-request"; import { EMPTY_PHOTO_METADATA, type DredgingDraftGroup, type DredgingPhotoDraft, type DredgingSaveGroup, type GeoPoint, -} from "../lib/types"; +} from "@/features/dredging/model/types"; // 무거운 OpenLayers 지도 모듈은 준설 등록 본문과 별도 chunk로 지연 로드한다. -const DredgingLocationMap = dynamic(() => import("./DredgingLocationMap"), { - ssr: false, -}); +const DredgingLocationMap = dynamic( + () => import("@/features/dredging/ui/dredging-location-map"), + { + ssr: false, + }, +); type DredgingRegisterModalProps = { open: boolean; @@ -90,11 +93,8 @@ const DredgingRegisterModal = ({ const alert = useAlertStore((state) => state.setMessage); const { confirm } = useConfirm(); // 등록방식·위치출처·사진상태 코드값은 유지하고 CMS 코드명을 화면에 표시한다. - const { - registrationModeLabels, - locationSourceLabels, - photoStatusLabels, - } = useDredgingCommonCodes(); + const { registrationModeLabels, locationSourceLabels, photoStatusLabels } = + useDredgingCommonCodes(); const [groups, setGroups] = useState([]); const groupsRef = useRef([]); const [activeGroupId, setActiveGroupId] = useState(null); diff --git a/apps/sn/app/(with-base-layout)/oper/dredging/components/DredgingRegisterPopupPage.tsx b/apps/sn/src/features/dredging/ui/dredging-register-popup-page.tsx similarity index 66% rename from apps/sn/app/(with-base-layout)/oper/dredging/components/DredgingRegisterPopupPage.tsx rename to apps/sn/src/features/dredging/ui/dredging-register-popup-page.tsx index c409cc596..b8a5c09dc 100644 --- a/apps/sn/app/(with-base-layout)/oper/dredging/components/DredgingRegisterPopupPage.tsx +++ b/apps/sn/src/features/dredging/ui/dredging-register-popup-page.tsx @@ -1,9 +1,9 @@ "use client"; -import { postPopupMessage } from "@/features/windowPopup/lib/popupMessage"; -import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types"; +import { postPopupMessage } from "@/features/window-popup/lib/popup-message"; +import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types"; -import DredgingRegisterModal from "./DredgingRegisterModal"; +import DredgingRegisterModal from "@/features/dredging/ui/dredging-register-modal"; /** 기존 준설 등록 흐름을 공통 WindowPopup 화면 규격으로 표시한다. */ const DredgingRegisterPopupPage = () => ( diff --git a/apps/sn/src/features/dredging/ui/DredgingRegistrationBody.tsx b/apps/sn/src/features/dredging/ui/dredging-registration-body.tsx similarity index 100% rename from apps/sn/src/features/dredging/ui/DredgingRegistrationBody.tsx rename to apps/sn/src/features/dredging/ui/dredging-registration-body.tsx diff --git a/apps/sn/src/features/exmn/api/exmn-business-api.ts b/apps/sn/src/features/exmn/api/exmn-business-api.ts index 085cf4510..815e3ff07 100644 --- a/apps/sn/src/features/exmn/api/exmn-business-api.ts +++ b/apps/sn/src/features/exmn/api/exmn-business-api.ts @@ -21,14 +21,8 @@ import { PIPE_DETAIL_DEFECT_COLUMNS, } from "../model/exmn-detail-table"; -import { - getBlobData, - getData, - getRawJson, - postMutiPart, - postRawJson, - putData, -} from "@/components/lib/api"; +import { getBlobData, postMutiPart } from "@/shared/api/file-api"; +import { getData, getRawJson, postRawJson, putData } from "@/shared/api/sewerage-api"; /** 조사사업 API의 공통 기준 경로다. */ const EXMN_BUSINESS_API_PATH = "v1/exmnBusinesses"; diff --git a/apps/sn/src/features/exmn/api/exmn-detail-export.ts b/apps/sn/src/features/exmn/api/exmn-detail-export.ts new file mode 100644 index 000000000..080325ecf --- /dev/null +++ b/apps/sn/src/features/exmn/api/exmn-detail-export.ts @@ -0,0 +1,33 @@ +import { requestGridExcel } from "@/shared/api/grid-export"; +import { PPL_STATUS_LABELS } from "./exmn-business-api"; +import type { ExmnJobType } from "../model/exmn-business-types"; +import type { ExmnDetailDefectType } from "../model/exmn-detail-table"; + +type DetailExportRequest = { + exmnBizId: string; + jobType: ExmnJobType; + trtdstId: string; + gisManagementNo: string; + defectType: ExmnDetailDefectType; + mode: "PIPE" | "UNIT"; + sort?: string; + defectColumns: Array<{ code: string; label: string }>; + recordTypeLabels: Record; + pipeItemLabels: Record; + severityLabels: Record; +}; + +/** 현재 페이지 행을 받지 않고 적용조건 전체와 표시명만 서버 Excel 작업에 전달한다. */ +export function downloadExmnDetailExcel( + request: DetailExportRequest, + fileName: string, +) { + return requestGridExcel( + "v1/exmnFacilities/exportJobs", + { + ...request, + pipeItemLabels: { ...PPL_STATUS_LABELS, ...request.pipeItemLabels }, + }, + fileName, + ); +} diff --git a/apps/sn/src/features/exmn/api/trtdst-api.ts b/apps/sn/src/features/exmn/api/trtdst-api.ts index 45f27260c..fcd506669 100644 --- a/apps/sn/src/features/exmn/api/trtdst-api.ts +++ b/apps/sn/src/features/exmn/api/trtdst-api.ts @@ -1,4 +1,4 @@ -import { getData, putData } from "@/components/lib/api"; +import { getData, putData } from "@/shared/api/sewerage-api"; import type { TrtdstBatchRequest, TrtdstBatchResponse, diff --git a/apps/sn/src/features/exmn/index.ts b/apps/sn/src/features/exmn/index.ts index 18b76036f..9d08f8677 100644 --- a/apps/sn/src/features/exmn/index.ts +++ b/apps/sn/src/features/exmn/index.ts @@ -4,7 +4,7 @@ export * from "./lib/exmn-business-validation"; export * from "./lib/trtdst-validation"; export * from "./model/exmn-business-types"; export * from "./model/trtdst-types"; -export * from "./ui/ExmnRegistrationPage"; -export * from "./ui/ExmnStatusPage"; -export * from "./ui/ExmnStatusDetailPanel"; -export * from "./ui/TrtdstManagementPage"; +export * from "./ui/exmn-registration-page"; +export * from "./ui/exmn-status-page"; +export * from "./ui/exmn-status-detail-panel"; +export * from "./ui/trtdst-management-page"; diff --git a/apps/sn/src/features/exmn/lib/exmn-business-validation.ts b/apps/sn/src/features/exmn/lib/exmn-business-validation.ts index fee3a4a76..9e423bdc8 100644 --- a/apps/sn/src/features/exmn/lib/exmn-business-validation.ts +++ b/apps/sn/src/features/exmn/lib/exmn-business-validation.ts @@ -1,8 +1,24 @@ import type { ExmnBusinessSaveRequest, + ExmnBusinessDetail, ExmnFileType, } from "../model/exmn-business-types"; +/** 다운로드 이력이 아니라 실제 처리분구별 시트 연결 여부로 양식 준비 상태를 확인한다. */ +export const getExmnTemplatePrerequisiteMessage = ( + detail: Pick, +): string | null => { + if (!detail.districts?.length) { + return "조사관리에서 해당 사업 행을 선택하고 ‘수정’을 눌러 처리분구를 등록한 뒤 ‘조사양식 받기’를 실행해 주세요. 이후 결과등록을 다시 진행해 주세요."; + } + if (detail.districts.some(district => + !district.pplExmnSheetNm?.trim() || !district.mnhlExmnSheetNm?.trim(), + )) { + return "조사관리에서 해당 사업 행 선택 → ‘수정’ → ‘조사양식 받기’를 먼저 실행한 후 결과등록을 다시 진행해 주세요."; + } + return null; +}; + /** 브라우저 1차 검사에서 허용하는 조사파일 최대 크기(200MB)다. */ export const EXMN_MAX_FILE_SIZE = 200 * 1024 * 1024; diff --git a/apps/sn/src/features/exmn/lib/useExmnCommonCodes.ts b/apps/sn/src/features/exmn/lib/use-exmn-common-codes.ts similarity index 97% rename from apps/sn/src/features/exmn/lib/useExmnCommonCodes.ts rename to apps/sn/src/features/exmn/lib/use-exmn-common-codes.ts index 93d180776..6aefb1dde 100644 --- a/apps/sn/src/features/exmn/lib/useExmnCommonCodes.ts +++ b/apps/sn/src/features/exmn/lib/use-exmn-common-codes.ts @@ -2,10 +2,7 @@ import { useEffect, useMemo, useState } from "react"; -import { - getCommonCodes, - type CommonCodeItem, -} from "@/components/lib/common/apiHelpers"; +import { getCommonCodes, type CommonCodeItem } from "@/entities/code/api/common-code-api"; type CodeLabelMap = Record; type ExmnCodeGroup = diff --git a/apps/sn/src/features/exmn/lib/use-exmn-detail-data.ts b/apps/sn/src/features/exmn/lib/use-exmn-detail-data.ts new file mode 100644 index 000000000..23418b152 --- /dev/null +++ b/apps/sn/src/features/exmn/lib/use-exmn-detail-data.ts @@ -0,0 +1,174 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { UiTableSort } from "@common/business_components"; +import { + getExmnFacilityPage, + getExmnPplUnitPage, +} from "../api/exmn-business-api"; +import type { ExmnBusiness, ExmnJobType } from "../model/exmn-business-types"; +import type { + ExmnDetailDefectType, + ExmnPplUnitDetailRow, + ExmnSurveyDetailRow, +} from "../model/exmn-detail-table"; + +type DetailQuery = { + business: Pick; + jobType: ExmnJobType; + selectedTrtdstId: string; + appliedDetailNumberKeyword: string; + defectType: ExmnDetailDefectType; + visibleDefectColumns: ReadonlyArray<{ code: string }>; + page: number; + sort?: UiTableSort; + isUnitMode: boolean; + isDefectTypeView: boolean; + searchRequestSeq: number; +}; + +/** 상세 목록과 구간 목록의 서버 조회·전체 합계·요청 취소 경계를 소유한다. + * 부모는 적용조건만 전달하며 현재 페이지 배열로 전체 합계를 계산하지 않는다. + */ +export function useExmnDetailData({ + business, + jobType, + selectedTrtdstId, + appliedDetailNumberKeyword, + defectType, + visibleDefectColumns, + page, + sort, + isUnitMode, + isDefectTypeView, + searchRequestSeq, +}: DetailQuery) { + const [actualRows, setActualRows] = useState([]); + const [actualUnitRows, setActualUnitRows] = useState( + [], + ); + const [total, setTotal] = useState(0); + const [serverSummary, setServerSummary] = + useState(null); + const [isLoading, setIsLoading] = useState(false); + const [loadError, setLoadError] = useState(null); + useEffect(() => { + if (isUnitMode || !selectedTrtdstId) { + setActualRows([]); + setIsLoading(false); + setLoadError(null); + return; + } + let active = true; + setActualRows([]); + setIsLoading(true); + setLoadError(null); + void getExmnFacilityPage({ + exmnBizId: business.exmnBizId, + jobType, + trtdstId: selectedTrtdstId, + gisManagementNo: appliedDetailNumberKeyword, + defectType, + defectCodes: visibleDefectColumns.map((column) => column.code).join(","), + page: page - 1, + size: 10, + sort: sort ? `${sort.key},${sort.direction}` : undefined, + }) + .then((result) => { + if (active) { + setActualRows(result.content); + setTotal(result.totalElements); + setServerSummary(result.summary); + } + }) + .catch((error) => { + if (!active) return; + setActualRows([]); + setTotal(0); + setServerSummary(null); + setLoadError( + error instanceof Error + ? error.message + : "조사 상세 집계 조회 중 오류가 발생했습니다.", + ); + }) + .finally(() => { + if (active) setIsLoading(false); + }); + return () => { + active = false; + }; + }, [ + appliedDetailNumberKeyword, + business.exmnBizId, + defectType, + isUnitMode, + jobType, + searchRequestSeq, + selectedTrtdstId, + page, + sort, + ]); + + useEffect(() => { + if (!isUnitMode || isDefectTypeView || !selectedTrtdstId) { + setActualUnitRows([]); + return; + } + let active = true; + setActualUnitRows([]); + setIsLoading(true); + setLoadError(null); + void getExmnPplUnitPage({ + exmnBizId: business.exmnBizId, + trtdstId: selectedTrtdstId, + gisManagementNo: appliedDetailNumberKeyword, + page: page - 1, + size: 10, + }) + .then((result) => { + if (active) { + setActualUnitRows(result.content); + setTotal(result.totalElements); + } + }) + .catch((error) => { + if (!active) return; + setActualUnitRows([]); + setTotal(0); + setLoadError( + error instanceof Error + ? error.message + : "본단위구간 조회 중 오류가 발생했습니다.", + ); + }) + .finally(() => { + if (active) setIsLoading(false); + }); + return () => { + active = false; + }; + }, [ + appliedDetailNumberKeyword, + business.exmnBizId, + isDefectTypeView, + isUnitMode, + searchRequestSeq, + selectedTrtdstId, + page, + ]); + /** 처리분구 전환 직후 이전 행을 숨기는 기존 UI 동작을 유지한다. */ + const clearRows = () => { + setActualRows([]); + setActualUnitRows([]); + }; + return { + actualRows, + actualUnitRows, + total, + serverSummary, + isLoading, + loadError, + clearRows, + }; +} diff --git a/apps/sn/src/features/exmn/model/exmn-detail-view.ts b/apps/sn/src/features/exmn/model/exmn-detail-view.ts new file mode 100644 index 000000000..13e09ce96 --- /dev/null +++ b/apps/sn/src/features/exmn/model/exmn-detail-view.ts @@ -0,0 +1,75 @@ +/** 조사 상세 표의 컬럼·표시 변환. 서버 조회/React 상태와 분리한다. */ +import type { + ExmnDetailSeverity, + ExmnDetailDefectType, + ExmnPplDetailMode, + ExmnSurveyDetailRow, +} from "./exmn-detail-table"; + +export const DEFAULT_SEVERITY_LABELS: Record = { + L: "대", + M: "중", + S: "소", +}; + +/** 관로 상세보기에서 조회 결과 표를 전환하는 모드 표시 문구다. */ +export const PPL_DETAIL_MODE_LABELS: Record = { + PIPE: "관로", + UNIT: "본단위구간", +}; + +export const DEFAULT_DEFECT_TYPE_LABELS: Record = + { + ALL: "전체", + STRUCTURAL: "구조적결함", + OPERATIONAL: "운영적결함", + }; + +export const FIXED_COLUMNS = [ + { id: "reportNo", label: "보고서\n번호", width: 118 }, + { id: "pipeNo", label: "관로\n번호", width: 120 }, + { id: "upstreamManhole", label: "상류\n맨홀", width: 135 }, + { id: "downstreamManhole", label: "하류\n맨홀", width: 135 }, + { id: "drainageType", label: "배수\n방식", width: 82 }, + { id: "pipeType", label: "관종", width: 76 }, + { id: "specification", label: "규격", width: 82 }, + { id: "extensionM", label: "연장", width: 86 }, + { id: "drivingDistanceM", label: "주행\n거리", width: 92 }, + { id: "structuralGrade", label: "구조적\n내부결함", width: 105 }, + { id: "operationalGrade", label: "운영적\n내부결함", width: 105 }, +] as const; + +export const REPORT_COLUMN_LEFT = 0; + +export const PIPE_COLUMN_LEFT = FIXED_COLUMNS[0].width; + +export const STICKY_COLUMN_COUNT = 2; + +export const STICKY_DIVIDER_SHADOW = "2px 0 0 hsl(var(--border))"; + +export const SEVERITY_COLUMN_WIDTH = 52; + +export type ExmnDetailFixedColumnId = (typeof FIXED_COLUMNS)[number]["id"]; + +/** 숫자를 엑셀 화면과 같은 최대 소수점 1자리 형식으로 표시한다. */ +export const formatNumber = (value: number) => + value.toLocaleString("ko-KR", { maximumFractionDigits: 1 }); + +/** 이상항목이 없는 셀은 0 대신 엑셀형 빈 상태인 하이픈으로 표시한다. */ +export const formatCount = (value: number | null | undefined) => + Number(value ?? 0) === 0 ? "-" : Number(value).toLocaleString("ko-KR"); + +/** 공통 고정 열의 행 값을 화면용 문자열로 변환한다. */ +export const getFixedCellValue = ( + row: ExmnSurveyDetailRow, + columnId: ExmnDetailFixedColumnId, +) => { + if (columnId === "drainageType") { + return row.drainageTypeName?.trim() || row.drainageType; + } + if (columnId === "pipeType") { + return row.pipeTypeName?.trim() || row.pipeType; + } + const value = row[columnId]; + return typeof value === "number" ? formatNumber(value) : value; +}; diff --git a/apps/sn/src/features/exmn/ui/ExmnBusinessRegisterPopupPage.tsx b/apps/sn/src/features/exmn/ui/exmn-business-register-popup-page.tsx similarity index 98% rename from apps/sn/src/features/exmn/ui/ExmnBusinessRegisterPopupPage.tsx rename to apps/sn/src/features/exmn/ui/exmn-business-register-popup-page.tsx index 2f78ed781..00cadc431 100644 --- a/apps/sn/src/features/exmn/ui/ExmnBusinessRegisterPopupPage.tsx +++ b/apps/sn/src/features/exmn/ui/exmn-business-register-popup-page.tsx @@ -4,8 +4,8 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useConfirm } from "@frontend-opensource/use-react-hooks"; -import { postPopupMessage } from "@/features/windowPopup/lib/popupMessage"; -import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types"; +import { postPopupMessage } from "@/features/window-popup/lib/popup-message"; +import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types"; import { StandardFormField, StandardFormSection, @@ -37,9 +37,9 @@ import type { } from "../model/exmn-business-types"; import type { TrtdstSettingDto } from "../model/trtdst-types"; -import LoadingOverlay from "@/components/ui/LoadingBar"; -import { getStartYearArray } from "@/components/ui/YearRangeSelectBox"; -import useStore from "@/store/useStore"; +import LoadingOverlay from "@/components/ui/loading-bar"; +import { getStartYearArray } from "@/components/ui/year-range-select-box"; +import useStore from "@/entities/reference-data/model/reference-data-store"; type Notice = { type: "success" | "error" | "info"; text: string } | null; diff --git a/apps/sn/src/features/exmn/ui/exmn-detail-search-panel.tsx b/apps/sn/src/features/exmn/ui/exmn-detail-search-panel.tsx new file mode 100644 index 000000000..bbf2af033 --- /dev/null +++ b/apps/sn/src/features/exmn/ui/exmn-detail-search-panel.tsx @@ -0,0 +1,133 @@ +"use client"; +import { + Button, + Input, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@common/components"; +import PopupSearchPanel, { + PopupSearchActions, +} from "@/features/window-popup/ui/popup-search-panel"; +import { PPL_DETAIL_MODE_LABELS } from "../model/exmn-detail-view"; +import type { + ExmnSectionSummaryRow, + ExmnJobType, +} from "../model/exmn-business-types"; +import type { ExmnPplDetailMode } from "../model/exmn-detail-table"; + +type Props = { + districtOptions: ExmnSectionSummaryRow[]; + selectedTrtdstId: string; + trtdstId: string; + trtdstNm: string; + detailNumberKeyword: string; + detailMode: ExmnPplDetailMode; + jobType: ExmnJobType; + onDistrictChange: (value: string) => void; + onKeywordChange: (value: string) => void; + onModeChange: (mode: ExmnPplDetailMode) => void; + onSearch: () => void; + onReset: () => void; +}; + +/** 상세 검색 입력만 렌더링한다. 적용조건·조회·리셋 상태는 부모가 소유한다. */ +export function ExmnDetailSearchPanel({ + districtOptions, + selectedTrtdstId, + trtdstId, + trtdstNm, + detailNumberKeyword, + detailMode, + jobType, + onDistrictChange, + onKeywordChange, + onModeChange, + onSearch, + onReset, +}: Props) { + return ( + +
+ + +
+ +
+ + onKeywordChange(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") onSearch(); + }} + /> +
+ + {jobType === "PPL" && ( +
+ +
+ {(Object.keys(PPL_DETAIL_MODE_LABELS) as ExmnPplDetailMode[]).map( + (mode) => ( + + ), + )} +
+
+ )} + + + + + +
+ ); +} diff --git a/apps/sn/src/features/exmn/ui/ExmnPplUnitDetailTable.tsx b/apps/sn/src/features/exmn/ui/exmn-ppl-unit-detail-table.tsx similarity index 99% rename from apps/sn/src/features/exmn/ui/ExmnPplUnitDetailTable.tsx rename to apps/sn/src/features/exmn/ui/exmn-ppl-unit-detail-table.tsx index a82853091..c4cabb41c 100644 --- a/apps/sn/src/features/exmn/ui/ExmnPplUnitDetailTable.tsx +++ b/apps/sn/src/features/exmn/ui/exmn-ppl-unit-detail-table.tsx @@ -6,7 +6,7 @@ import { type ExmnDetailSeverity, type ExmnPplUnitDetailRow, } from "../model/exmn-detail-table"; -import { useExmnCommonCodes } from "../lib/useExmnCommonCodes"; +import { useExmnCommonCodes } from "../lib/use-exmn-common-codes"; const DEFAULT_SEVERITY_LABELS: Record = { L: "대", diff --git a/apps/sn/src/features/exmn/ui/ExmnRegistrationPage.tsx b/apps/sn/src/features/exmn/ui/exmn-registration-page.tsx similarity index 97% rename from apps/sn/src/features/exmn/ui/ExmnRegistrationPage.tsx rename to apps/sn/src/features/exmn/ui/exmn-registration-page.tsx index 7143ce61a..603362547 100644 --- a/apps/sn/src/features/exmn/ui/ExmnRegistrationPage.tsx +++ b/apps/sn/src/features/exmn/ui/exmn-registration-page.tsx @@ -1,12 +1,12 @@ "use client"; import { getSnRowNumber } from "@/shared/lib/row-number"; -import LoadingOverlay from "@/components/ui/LoadingBar"; +import LoadingOverlay from "@/components/ui/loading-bar"; import { fileDownload } from "@/components/lib/utils"; -import { getStartYearArray } from "@/components/ui/YearRangeSelectBox"; -import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup"; -import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types"; -import useStore from "@/store/useStore"; +import { getStartYearArray } from "@/components/ui/year-range-select-box"; +import { openWindowPopup } from "@/features/window-popup/lib/open-window-popup"; +import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types"; +import useStore from "@/entities/reference-data/model/reference-data-store"; import { Button, Input, @@ -38,8 +38,8 @@ import type { ExmnBusiness, ExmnBusinessSearchParams, } from "../model/exmn-business-types"; -import { ExmnStatusDetailPanel } from "./ExmnStatusDetailPanel"; -import { EXMN_TABLE_HEADER_CLASS } from "./ExmnStandardDataTable"; +import { ExmnStatusDetailPanel } from "./exmn-status-detail-panel"; +import { EXMN_TABLE_HEADER_CLASS } from "./exmn-standard-data-table"; const BUSINESS_LIST_PAGE_SIZE = 10; diff --git a/apps/sn/src/features/exmn/ui/ExmnResultRegisterPopupPage.tsx b/apps/sn/src/features/exmn/ui/exmn-result-register-popup-page.tsx similarity index 90% rename from apps/sn/src/features/exmn/ui/ExmnResultRegisterPopupPage.tsx rename to apps/sn/src/features/exmn/ui/exmn-result-register-popup-page.tsx index ebeb67947..06c4ddd76 100644 --- a/apps/sn/src/features/exmn/ui/ExmnResultRegisterPopupPage.tsx +++ b/apps/sn/src/features/exmn/ui/exmn-result-register-popup-page.tsx @@ -1,15 +1,15 @@ "use client"; import { handleUpload } from "@/components/lib/utils"; -import LoadingOverlay from "@/components/ui/LoadingBar"; -import { postPopupMessageAndClose } from "@/features/windowPopup/lib/popupMessage"; -import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types"; +import LoadingOverlay from "@/components/ui/loading-bar"; +import { postPopupMessageAndClose } from "@/features/window-popup/lib/popup-message"; +import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types"; import { StandardFormField, StandardFormSection, StandardPopupLayout, } from "@common/business_components"; -import { Button } from "@common/components"; +import { Button, Dialog, DialogContent, DialogFooter, DialogClose } from "@common/components"; import { useConfirm } from "@frontend-opensource/use-react-hooks"; import type { HttpResponse } from "tus-js-client"; import { useEffect, useState } from "react"; @@ -21,6 +21,7 @@ import { } from "../api/exmn-business-api"; import { EXMN_FILE_RULES, + getExmnTemplatePrerequisiteMessage, validateExmnFile, validateExmnFiles, } from "../lib/exmn-business-validation"; @@ -161,6 +162,8 @@ export function ExmnResultRegisterPopupPage({ businessSummary, }: ExmnResultRegisterPopupPageProps) { const { confirm } = useConfirm(); + // 양식 준비 안내만 넓은 모달로 표시해 다른 공통 알림창의 크기는 유지한다. + const [templateNotice, setTemplateNotice] = useState(""); const [detail, setDetail] = useState(null); const [files, setFiles] = useState({}); /** 파일별 서버 검토 결과를 보관해 다른 파일 검토 후에도 결과와 건수를 유지한다. */ @@ -224,6 +227,19 @@ export function ExmnResultRegisterPopupPage({ }; }, [exmnBizId]); + /** 검토 후 사업이 수정된 경우도 업로드 전에 발견하도록 서버의 최신 양식 연결 상태를 확인한다. */ + const ensureTemplateReady = async () => { + const latestDetail = await getExmnBusiness(exmnBizId); + setDetail(latestDetail); + const message = getExmnTemplatePrerequisiteMessage(latestDetail); + if (message) { + setIsLoading(false); + setTemplateNotice(message); + return false; + } + return true; + }; + /** 파일 변경 시 이전 검토결과를 폐기하고 확장자·크기 규칙을 즉시 확인한다. */ const handleFileChange = ( fileType: ExmnFileType, @@ -363,10 +379,14 @@ export function ExmnResultRegisterPopupPage({ fileValidationStatuses.PPL_EXMN_PDF !== "success" ) return; - if (!(await confirm("조사결과를 저장하시겠습니까?"))) return; setIsLoading(true); setNotice(null); try { + if (!(await ensureTemplateReady())) return; + // 로딩 화면이 저장 확인창을 가리지 않도록 사전 확인과 업로드 상태를 나눈다. + setIsLoading(false); + if (!(await confirm("조사결과를 저장하시겠습니까?"))) return; + setIsLoading(true); await uploadAndLinkAttachments(); await commitExmnImport( exmnBizId, @@ -379,13 +399,15 @@ export function ExmnResultRegisterPopupPage({ }, ); } catch (error) { - setNotice({ - type: "error", - text: - error instanceof Error - ? error.message - : "조사결과 최종 저장 중 오류가 발생했습니다.", - }); + const message = error instanceof Error + ? error.message : "조사결과 최종 저장 중 오류가 발생했습니다."; + // 사전 확인 이후 다른 창에서 사업이 수정돼 서버가 차단한 경우도 동일한 알림으로 안내한다. + if (message.includes("조사양식 받기")) { + setIsLoading(false); + setTemplateNotice(message); + } else { + setNotice({ type: "error", text: message }); + } } finally { setIsLoading(false); } @@ -429,6 +451,18 @@ export function ExmnResultRegisterPopupPage({ bodyClassName="space-y-3 overflow-x-hidden" > + setTemplateNotice("")}> + +
+

+ {templateNotice} +

+
+ + + +
+
{notice && notice.type !== "success" && (
= { - L: "대", - M: "중", - S: "소", -}; - -/** 관로 상세보기에서 조회 결과 표를 전환하는 모드 표시 문구다. */ -const PPL_DETAIL_MODE_LABELS: Record = { - PIPE: "관로", - UNIT: "본단위구간", -}; - -const DEFAULT_DEFECT_TYPE_LABELS: Record = { - ALL: "전체", - STRUCTURAL: "구조적결함", - OPERATIONAL: "운영적결함", -}; - -const FIXED_COLUMNS = [ - { id: "reportNo", label: "보고서\n번호", width: 118 }, - { id: "pipeNo", label: "관로\n번호", width: 120 }, - { id: "upstreamManhole", label: "상류\n맨홀", width: 135 }, - { id: "downstreamManhole", label: "하류\n맨홀", width: 135 }, - { id: "drainageType", label: "배수\n방식", width: 82 }, - { id: "pipeType", label: "관종", width: 76 }, - { id: "specification", label: "규격", width: 82 }, - { id: "extensionM", label: "연장", width: 86 }, - { id: "drivingDistanceM", label: "주행\n거리", width: 92 }, - { id: "structuralGrade", label: "구조적\n내부결함", width: 105 }, - { id: "operationalGrade", label: "운영적\n내부결함", width: 105 }, -] as const; - -const REPORT_COLUMN_LEFT = 0; -const PIPE_COLUMN_LEFT = FIXED_COLUMNS[0].width; -const STICKY_COLUMN_COUNT = 2; -const STICKY_DIVIDER_SHADOW = "2px 0 0 hsl(var(--border))"; -const SEVERITY_COLUMN_WIDTH = 52; -type ExmnDetailFixedColumnId = (typeof FIXED_COLUMNS)[number]["id"]; +import { ExmnPplUnitDetailTable } from "./exmn-ppl-unit-detail-table"; +import { ExmnSurveyStandardDataTable } from "./exmn-survey-standard-data-table"; interface ExmnSurveyDetailPopupPageProps { business: ExmnBusiness; @@ -99,31 +57,6 @@ interface ExmnSurveyDetailPopupPageProps { type Notice = { type: "info"; text: string } | null; -/** 숫자를 엑셀 화면과 같은 최대 소수점 1자리 형식으로 표시한다. */ -const formatNumber = (value: number) => - value.toLocaleString("ko-KR", { maximumFractionDigits: 1 }); - -/** 이상항목이 없는 셀은 0 대신 엑셀형 빈 상태인 하이픈으로 표시한다. */ -const formatCount = (value: number | null | undefined) => - Number(value ?? 0) === 0 ? "-" : Number(value).toLocaleString("ko-KR"); - - -/** 공통 고정 열의 행 값을 화면용 문자열로 변환한다. */ -const getFixedCellValue = ( - row: ExmnSurveyDetailRow, - columnId: ExmnDetailFixedColumnId, -) => { - if (columnId === "drainageType") { - return row.drainageTypeName?.trim() || row.drainageType; - } - if (columnId === "pipeType") { - return row.pipeTypeName?.trim() || row.pipeType; - } - const value = row[columnId]; - return typeof value === "number" ? formatNumber(value) : value; -}; - - /** 엑셀 `01. 덕풍`·`01. 덕풍 (2)` 구조를 관로·맨홀 공통 WindowPopup으로 표시한다. */ export function ExmnSurveyDetailPopupPage({ business, @@ -159,19 +92,9 @@ export function ExmnSurveyDetailPopupPage({ const [sort, setSort] = useState(); /** 다운로드 미연결 등 현재 팝업 안내 메시지를 표시한다. */ const [notice, setNotice] = useState(null); - /** 실제 DB 상세조회 결과와 요청 상태다. */ - const [actualRows, setActualRows] = useState([]); // 표 행은 현재 페이지만 보관하고 합계는 서버의 전체 조건 합계를 별도로 사용한다. const [page, setPage] = useState(1); - const [total, setTotal] = useState(0); - const [serverSummary, setServerSummary] = useState(null); - const summaryRows = useMemo(() => serverSummary ? [serverSummary] : [], [serverSummary]); - const [actualUnitRows, setActualUnitRows] = useState( - [], - ); - const [isLoading, setIsLoading] = useState(false); const [isDownloading, setIsDownloading] = useState(false); - const [loadError, setLoadError] = useState(null); const severityLabels: Record = { ...DEFAULT_SEVERITY_LABELS, ...cmsSeverityLabels, @@ -231,6 +154,32 @@ export function ExmnSurveyDetailPopupPage({ ), [defectType, definition], ); + const { + actualRows, + actualUnitRows, + total, + serverSummary, + isLoading, + loadError, + clearRows, + } = useExmnDetailData({ + business, + jobType, + selectedTrtdstId, + appliedDetailNumberKeyword, + defectType, + visibleDefectColumns, + page, + sort, + isUnitMode, + isDefectTypeView, + searchRequestSeq, + }); + const summaryRows = useMemo( + () => (serverSummary ? [serverSummary] : []), + [serverSummary], + ); + /** 분류별 상세에서는 선택 결함이 한 건 이상 존재하는 시설 행만 표시한다. */ const scopedRows = useMemo(() => { if (defectType === "ALL") return actualRows; @@ -266,7 +215,15 @@ export function ExmnSurveyDetailPopupPage({ // 현재 페이지를 다시 정렬하지 않는다. 정렬 조건을 서버에 보내 전체 결과 순서를 적용한다. const rows = filteredRows; - useEffect(() => { setPage(1); }, [selectedTrtdstId, appliedDetailNumberKeyword, defectType, detailMode, jobType]); + useEffect(() => { + setPage(1); + }, [ + selectedTrtdstId, + appliedDetailNumberKeyword, + defectType, + detailMode, + jobType, + ]); /** 관로·맨홀 조사유형 또는 결함 범위가 바뀌면 이전 검색 조건과 안내를 초기화한다. */ useEffect(() => { @@ -302,112 +259,8 @@ export function ExmnSurveyDetailPopupPage({ }, [business.exmnBizId, jobType, trtdstId]); /** 전체 또는 결함 분류별 모드에서 선택 처리분구의 활성 시설과 JSONB 결함 집계를 조회한다. */ - useEffect(() => { - if (isUnitMode || !selectedTrtdstId) { - setActualRows([]); - setIsLoading(false); - setLoadError(null); - return; - } - let active = true; - setActualRows([]); - setIsLoading(true); - setLoadError(null); - void getExmnFacilityPage({ - exmnBizId: business.exmnBizId, - jobType, - trtdstId: selectedTrtdstId, - gisManagementNo: appliedDetailNumberKeyword, - defectType, - defectCodes: visibleDefectColumns.map((column) => column.code).join(","), - page: page - 1, - size: 10, - sort: sort ? `${sort.key},${sort.direction}` : undefined, - }) - .then((result) => { - if (active) { - setActualRows(result.content); - setTotal(result.totalElements); - setServerSummary(result.summary); - } - }) - .catch((error) => { - if (!active) return; - setActualRows([]); - setTotal(0); - setServerSummary(null); - setLoadError( - error instanceof Error - ? error.message - : "조사 상세 집계 조회 중 오류가 발생했습니다.", - ); - }) - .finally(() => { - if (active) setIsLoading(false); - }); - return () => { - active = false; - }; - }, [ - appliedDetailNumberKeyword, - business.exmnBizId, - defectType, - isUnitMode, - jobType, - searchRequestSeq, - selectedTrtdstId, - page, - sort, - ]); /** 본단위구간 모드에서 선택 처리분구의 PPL_UNIT_V1을 실제 상세행으로 조회한다. */ - useEffect(() => { - if (!isUnitMode || isDefectTypeView || !selectedTrtdstId) { - setActualUnitRows([]); - return; - } - let active = true; - setActualUnitRows([]); - setIsLoading(true); - setLoadError(null); - void getExmnPplUnitPage({ - exmnBizId: business.exmnBizId, - trtdstId: selectedTrtdstId, - gisManagementNo: appliedDetailNumberKeyword, - page: page - 1, - size: 10, - }) - .then((result) => { - if (active) { - setActualUnitRows(result.content); - setTotal(result.totalElements); - } - }) - .catch((error) => { - if (!active) return; - setActualUnitRows([]); - setTotal(0); - setLoadError( - error instanceof Error - ? error.message - : "본단위구간 조회 중 오류가 발생했습니다.", - ); - }) - .finally(() => { - if (active) setIsLoading(false); - }); - return () => { - active = false; - }; - }, [ - appliedDetailNumberKeyword, - business.exmnBizId, - isDefectTypeView, - isUnitMode, - searchRequestSeq, - selectedTrtdstId, - page, - ]); /** 화면 조회 버튼으로 입력 번호를 확정해 상세 조회를 다시 실행한다. */ const handleSearch = () => { @@ -514,25 +367,54 @@ export function ExmnSurveyDetailPopupPage({ // 현재 페이지의 행은 전송하지 않고 적용조건·CMS 표시명으로 서버 전체 Excel을 생성한다. if (isDownloading) return; if (total === 0) { - setNotice({ type: "info", text: "다운로드할 조사 상세현황 데이터가 없습니다." }); + setNotice({ + type: "info", + text: "다운로드할 조사 상세현황 데이터가 없습니다.", + }); return; } setIsDownloading(true); - setNotice({ type: "info", text: "검색조건 전체의 Excel 자료를 준비하고 있습니다." }); + setNotice({ + type: "info", + text: "검색조건 전체의 Excel 자료를 준비하고 있습니다.", + }); try { - const filePrefix = `${selectedTrtdstNm}_${definition.defectGroupTitle}조사_${isUnitMode ? "본단위등급표" : defectTypeLabel}` - .replace(/[\\/:*?"<>|]/g, "_"); - await requestGridExcel("v1/exmnFacilities/exportJobs", { - exmnBizId: business.exmnBizId, jobType, trtdstId: selectedTrtdstId, - gisManagementNo: appliedDetailNumberKeyword, defectType, - mode: isUnitMode ? "UNIT" : "PIPE", - sort: sort ? `${sort.key},${sort.direction}` : undefined, - defectColumns: visibleDefectColumns.map((column) => ({ code: column.code, label: column.label })), - recordTypeLabels, pipeItemLabels: { ...PPL_STATUS_LABELS, ...pipeItemLabels }, severityLabels, - }, `${filePrefix}.xlsx`); - setNotice({ type: "info", text: "검색조건 전체의 Excel 파일 다운로드를 시작했습니다." }); + const filePrefix = + `${selectedTrtdstNm}_${definition.defectGroupTitle}조사_${isUnitMode ? "본단위등급표" : defectTypeLabel}`.replace( + /[\\/:*?"<>|]/g, + "_", + ); + await downloadExmnDetailExcel( + { + exmnBizId: business.exmnBizId, + jobType, + trtdstId: selectedTrtdstId, + gisManagementNo: appliedDetailNumberKeyword, + defectType, + mode: isUnitMode ? "UNIT" : "PIPE", + sort: sort ? `${sort.key},${sort.direction}` : undefined, + defectColumns: visibleDefectColumns.map((column) => ({ + code: column.code, + label: column.label, + })), + recordTypeLabels, + pipeItemLabels, + severityLabels, + }, + `${filePrefix}.xlsx`, + ); + setNotice({ + type: "info", + text: "검색조건 전체의 Excel 파일 다운로드를 시작했습니다.", + }); } catch (error) { - setNotice({ type: "info", text: error instanceof Error ? error.message : "전체 Excel 자료를 불러오지 못했습니다." }); + setNotice({ + type: "info", + text: + error instanceof Error + ? error.message + : "전체 Excel 자료를 불러오지 못했습니다.", + }); } finally { setIsDownloading(false); } @@ -547,10 +429,7 @@ export function ExmnSurveyDetailPopupPage({ [fixedColumns, visibleDefectColumns.length], ); /** 본단위구간 요약은 조회된 한 관로의 단위 수와 상태·결함 기록 수를 사용한다. */ - const unitSectionCount = useMemo( - () => total, - [total], - ); + const unitSectionCount = useMemo(() => total, [total]); /** 관로 집계와 본단위구간 행을 같은 StandardDataTable 데이터 유무 계약으로 전달한다. */ const standardTableRows: Array = isUnitMode ? unitRows : rows; @@ -596,110 +475,32 @@ export function ExmnSurveyDetailPopupPage({
{!isDefectTypeView && ( - -
- - -
- -
- - setDetailNumberKeyword(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") handleSearch(); - }} - /> -
- - {jobType === "PPL" && ( -
- -
- {( - Object.keys(PPL_DETAIL_MODE_LABELS) as ExmnPplDetailMode[] - ).map((mode) => ( - - ))} -
-
- )} - - - - - -
+ { + setSelectedTrtdstId(value); + clearRows(); + setDetailNumberKeyword(""); + setAppliedDetailNumberKeyword(""); + setNotice(null); + }} + onKeywordChange={setDetailNumberKeyword} + onModeChange={handleDetailModeChange} + onSearch={handleSearch} + onReset={() => { + setSelectedTrtdstId(trtdstId); + setDetailMode("PIPE"); + setDetailNumberKeyword(""); + setAppliedDetailNumberKeyword(""); + setNotice(null); + }} + /> )} {loadError && ( @@ -835,7 +636,10 @@ export function ExmnSurveyDetailPopupPage({ } sortKey={column.id} sort={sort} - onSortChange={(nextSort) => { setSort(nextSort); setPage(1); }} + onSortChange={(nextSort) => { + setSort(nextSort); + setPage(1); + }} /> ) : ( diff --git a/apps/sn/app/(map-layout)/map/components/exmn/ExmnSurveyResultSection.tsx b/apps/sn/src/features/exmn/ui/exmn-survey-result-section.tsx similarity index 98% rename from apps/sn/app/(map-layout)/map/components/exmn/ExmnSurveyResultSection.tsx rename to apps/sn/src/features/exmn/ui/exmn-survey-result-section.tsx index 67bbf2ae5..3dded247a 100644 --- a/apps/sn/app/(map-layout)/map/components/exmn/ExmnSurveyResultSection.tsx +++ b/apps/sn/src/features/exmn/ui/exmn-survey-result-section.tsx @@ -13,7 +13,7 @@ import { } from "@common/components"; import { getExmnPipeReportAvailability } from "@/features/exmn"; -import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup"; +import { openWindowPopup } from "@/features/window-popup/lib/open-window-popup"; import { useAlertStore } from "@/shared/lib"; type ExmnSurveyResultSectionProps = { diff --git a/apps/sn/src/features/exmn/ui/ExmnSurveyStandardDataTable.tsx b/apps/sn/src/features/exmn/ui/exmn-survey-standard-data-table.tsx similarity index 100% rename from apps/sn/src/features/exmn/ui/ExmnSurveyStandardDataTable.tsx rename to apps/sn/src/features/exmn/ui/exmn-survey-standard-data-table.tsx diff --git a/apps/sn/src/features/exmn/ui/TrtdstManagementPage.tsx b/apps/sn/src/features/exmn/ui/trtdst-management-page.tsx similarity index 99% rename from apps/sn/src/features/exmn/ui/TrtdstManagementPage.tsx rename to apps/sn/src/features/exmn/ui/trtdst-management-page.tsx index 8bd6f8ea4..c0e74ca50 100644 --- a/apps/sn/src/features/exmn/ui/TrtdstManagementPage.tsx +++ b/apps/sn/src/features/exmn/ui/trtdst-management-page.tsx @@ -1,8 +1,8 @@ "use client"; import { getSnRowNumber } from "@/shared/lib/row-number"; -import LoadingOverlay from "@/components/ui/LoadingBar"; -import useStore from "@/store/useStore"; +import LoadingOverlay from "@/components/ui/loading-bar"; +import useStore from "@/entities/reference-data/model/reference-data-store"; import { STANDARD_ROW_NUMBER_HEADER, StandardDataTable, diff --git a/apps/sn/src/features/facility-detail/api/facility-input-api.ts b/apps/sn/src/features/facility-detail/api/facility-input-api.ts new file mode 100644 index 000000000..d85a04b06 --- /dev/null +++ b/apps/sn/src/features/facility-detail/api/facility-input-api.ts @@ -0,0 +1,52 @@ +import { getData, postData } from "@/shared/api/sewerage-api"; + +const INPUT_RESOURCES = new Map([ + ["하수관로", "v1/fcltPpl"], + ["우수토실", "v1/fcltStofcbr"], + ["하수펌프장", "v1/fcltPump"], + ["하수맨홀", "v1/fcltMhl"], +]); + +/** 시설 종류와 자체입력 API의 기존 매핑. 미지원 종류의 조회는 수행하지 않는다. */ +export const supportsFacilityInput = (layerName: string) => + INPUT_RESOURCES.has(layerName); +/** 미지원 시설을 잘못된 URL로 전송하지 않도록 API 경계에서 차단한다. */ +function requireInputResource(layerName: string): string { + const resource = INPUT_RESOURCES.get(layerName); + if (!resource) + throw new Error(`자체입력을 지원하지 않는 시설입니다: ${layerName}`); + return resource; +} +/** 자체입력에 사용하는 관리기관 목록을 조회한다. */ +export const fetchManagingInstitutions = () => getData("v1/fcltPpl/mngInsts"); +/** 선택 시설의 자체입력 정보만 조회한다. */ +export const fetchFacilityInput = ( + layerName: string, + facilityNo: string, + institutionCode: string, +) => + getData( + `${requireInputResource(layerName)}/${facilityNo}/${institutionCode}`, + ); +/** 기존 시설별 등록 API와 요청 본문을 유지한다. */ +export const saveFacilityInput = ( + layerName: string, + body: Record, +) => postData(requireInputResource(layerName), body); + +/** 단건 관로의 기존 유하방향 보정 근거 조회 계약. cqlFiter는 기존 서버 파라미터명이다. */ +export async function fetchPipeFlowBasis( + facilityNo: string, + institutionCode: string, +) { + const escapeCqlLiteral = (value: string) => value.replace(/'/g, "''"); + const params = new URLSearchParams({ + lyrTypeNm: "tb_ms_swage_ppl", + includeFlowDirectionBasis: "true", + cqlFiter: `fclt_mng_no='${escapeCqlLiteral(facilityNo)}' and mng_inst_cd='${escapeCqlLiteral(institutionCode)}'`, + page: "0", + size: "1", + }); + const response = await getData(`v1/fclts?${params.toString()}`); + return Array.isArray(response?.data) ? response.data[0] : null; +} diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/swtrmtPrcsZoneAnalysisApi.ts b/apps/sn/src/features/facility-detail/api/treatment-zone-api.ts similarity index 78% rename from apps/sn/app/(map-layout)/map/components/popupComponent/swtrmtPrcsZoneAnalysisApi.ts rename to apps/sn/src/features/facility-detail/api/treatment-zone-api.ts index 0221f1918..6748f0109 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/swtrmtPrcsZoneAnalysisApi.ts +++ b/apps/sn/src/features/facility-detail/api/treatment-zone-api.ts @@ -1,5 +1,6 @@ /** 처리구역 관로분석정보와 보수이력을 조회·다운로드하는 API를 제공한다. */ -import { getBlobDataWithHeaders, getData } from "@/components/lib/api"; +import { getBlobDataWithHeaders } from "@/shared/api/file-api"; +import { getData } from "@/shared/api/sewerage-api"; export type SwtrmtPrcsZoneAnalysisMetric = { avgInstlYear?: number | string | null; @@ -70,7 +71,10 @@ export type RepairHistoryResponse = { const ANALYSIS_ENDPOINT_PREFIX = "v1/swagePrcsZones"; /** 지도 전체 강조에 필요한 필드만 제공하며 표의 행 수와 독립적이다. */ -export type RepairHistoryMapFeature = Pick; +export type RepairHistoryMapFeature = Pick< + RepairHistoryRow, + "fid" | "geometryJson" +>; const firstValue = (...values: unknown[]) => { const value = values.find((item) => { @@ -82,7 +86,10 @@ const firstValue = (...values: unknown[]) => { return value === undefined ? "" : String(value).trim(); }; -export const getSwtrmtPrcsZoneId = (rawFeatureData: any, featureData: ZoneAnalysisSource) => { +export const getSwtrmtPrcsZoneId = ( + rawFeatureData: any, + featureData: ZoneAnalysisSource, +) => { const rawValues = rawFeatureData?.values_ ?? rawFeatureData ?? {}; return firstValue( @@ -100,19 +107,42 @@ export const getSwtrmtPrcsZoneId = (rawFeatureData: any, featureData: ZoneAnalys rawValues?.swtrmt_fclt_cd, rawValues?.swtrmtFcltCd, featureData?.swtrmt_fclt_cd, - featureData?.swtrmtFcltCd + featureData?.swtrmtFcltCd, ); }; -export const getSwtrmtPrcsZoneAnalysisParams = (rawFeatureData: any, featureData: ZoneAnalysisSource) => { +export const getSwtrmtPrcsZoneAnalysisParams = ( + rawFeatureData: any, + featureData: ZoneAnalysisSource, +) => { const rawValues = rawFeatureData?.values_ ?? rawFeatureData ?? {}; const params = new URLSearchParams(); const entries = { - ctpvCd: firstValue(rawValues?.ctpv_cd, rawValues?.ctpvCd, featureData?.ctpv_cd, featureData?.ctpvCd), - sggCd: firstValue(rawValues?.sgg_cd, rawValues?.sggCd, featureData?.sgg_cd, featureData?.sggCd), - swtrmtFcltCd: firstValue(rawValues?.swtrmt_fclt_cd, rawValues?.swtrmtFcltCd, featureData?.swtrmt_fclt_cd, featureData?.swtrmtFcltCd), - swtrmtZoneNm: firstValue(rawValues?.swtrmt_zone_nm, rawValues?.swtrmtZoneNm, featureData?.swtrmt_zone_nm, featureData?.swtrmtZoneNm), + ctpvCd: firstValue( + rawValues?.ctpv_cd, + rawValues?.ctpvCd, + featureData?.ctpv_cd, + featureData?.ctpvCd, + ), + sggCd: firstValue( + rawValues?.sgg_cd, + rawValues?.sggCd, + featureData?.sgg_cd, + featureData?.sggCd, + ), + swtrmtFcltCd: firstValue( + rawValues?.swtrmt_fclt_cd, + rawValues?.swtrmtFcltCd, + featureData?.swtrmt_fclt_cd, + featureData?.swtrmtFcltCd, + ), + swtrmtZoneNm: firstValue( + rawValues?.swtrmt_zone_nm, + rawValues?.swtrmtZoneNm, + featureData?.swtrmt_zone_nm, + featureData?.swtrmtZoneNm, + ), }; Object.entries(entries).forEach(([key, value]) => { @@ -124,7 +154,7 @@ export const getSwtrmtPrcsZoneAnalysisParams = (rawFeatureData: any, featureData export const fetchSwtrmtPrcsZoneAnalysis = async ( rawFeatureData: any, - featureData: ZoneAnalysisSource + featureData: ZoneAnalysisSource, ): Promise => { const zoneId = getSwtrmtPrcsZoneId(rawFeatureData, featureData); if (!zoneId) return null; @@ -136,7 +166,10 @@ export const fetchSwtrmtPrcsZoneAnalysis = async ( return (await getData(path)) ?? {}; }; -const appendRepairHistoryFilters = (params: URLSearchParams, filters: RepairHistoryFilters) => { +const appendRepairHistoryFilters = ( + params: URLSearchParams, + filters: RepairHistoryFilters, +) => { Object.entries(filters).forEach(([key, value]) => { const normalized = String(value ?? "").trim(); if (normalized) params.append(key, normalized); @@ -167,9 +200,12 @@ export const fetchRepairHistoryPage = async ( size: number, ): Promise => { if (!zoneId.trim()) throw new Error("처리구역 식별값이 없습니다."); - return getData(getRepairHistoryPath(zoneId, filters, "", { - page: String(page), size: String(size), - })) as Promise; + return getData( + getRepairHistoryPath(zoneId, filters, "", { + page: String(page), + size: String(size), + }), + ) as Promise; }; /** 검색조건에 맞는 전체 도형을 한 번 조회하며 목록 페이지 이동 시에는 호출하지 않는다. */ @@ -186,5 +222,7 @@ export const downloadRepairHistoriesByZoneId = async ( zoneName: string, ) => { if (!zoneId.trim()) throw new Error("처리구역 식별값이 없습니다."); - return getBlobDataWithHeaders(getRepairHistoryPath(zoneId, filters, "/download", { zoneName })); + return getBlobDataWithHeaders( + getRepairHistoryPath(zoneId, filters, "/download", { zoneName }), + ); }; diff --git a/apps/sn/src/features/facility-detail/lib/facility-panel-registry.ts b/apps/sn/src/features/facility-detail/lib/facility-panel-registry.ts new file mode 100644 index 000000000..61f60e792 --- /dev/null +++ b/apps/sn/src/features/facility-detail/lib/facility-panel-registry.ts @@ -0,0 +1,84 @@ +/** 선택한 지도 레이어 종류에 맞는 시설 상세 컴포넌트를 반환한다. */ +import MsSwagePpl from "@/features/facility-detail/ui/ms-swage-ppl"; // 하수관로 +import sewagePump from "@/features/facility-detail/ui/sewage-pump"; // 하수펌프 +import smh from "@/features/facility-detail/ui/smh"; +import Ssc from "@/features/facility-detail/ui/ssc"; +import StormWaterPump from "@/features/facility-detail/ui/storm-water-pump"; +import SwtrmtFclt from "@/features/facility-detail/ui/swtrmt-fclt"; +import SwtrmtPrcsZoneAnalysis from "@/features/facility-detail/ui/swtrmt-prcs-zone-analysis"; + +type LayerComponentProps = { + featureData: any; + isMainSsc?: boolean; + isDrtwSewagePump?: boolean; + rainMeasureData?: any; + onSaveClick?: () => void; + onFormDataChange?: (data: any) => void; // 추가 + chmbrXcnts: string; + chmbrYdnts: string; + gisObject?: Record; // GIS 객체 추가 + rawFeatureData?: any; + surveyResultContent?: React.ReactNode; +}; +type LayerContentMap = { + [key: string]: { + component?: React.ComponentType; + props?: any; + }; +}; +// 레이어 이름과 컴포넌트를 매핑 +// 레이어 선택시 변경되는 팝업 컨텐츠 동적으로 변경하도록 작업 +const layerContentMap: LayerContentMap = { + 하수처리시설: { component: SwtrmtFclt }, // 디지털 자료관 연계정보가 없으니 일단 주석 처리 + 하수처리구역: { component: SwtrmtPrcsZoneAnalysis }, + 우수토실: { component: Ssc }, + 중점우수토실: { component: Ssc }, // 중점우수토실 여부 설정 + 하수관로: { component: MsSwagePpl }, + 하수맨홀: { component: smh }, + 하수펌프장: { component: sewagePump }, + // "빗물펌프": { component: StormWaterPump }, + // "오수펌프": { component: sewagePump }, +}; + +// 동적 컴포넌트 반환 함수 +export const getLayerContentComponent = ( + layerNm: string, +): React.FC | null => { + const component = layerContentMap[layerNm]; + if (!component || !component.component) { + // component가 없으면 null 반환 + return () => null; + } + return component.component as React.FC; +}; +// 사용자 입력 필드 (보수 및 갱신, 청소 및 준설, 점검 및 진단) +export const handleInputChange = ( + section: string, + index: number, + field: string, + value: string, + dataMap: { + [key: string]: { + data: { + year: string; + type: string; + bizInfoNm: string; + bizType: string; + }[]; + setData: React.Dispatch< + React.SetStateAction< + { year: string; type: string; bizInfoNm: string; bizType: string }[] + > + >; + }; + }, +) => { + const target = dataMap[section]; + if (!target) return; + + const updated = [...target.data]; + if (updated[index]) { + updated[index][field as keyof (typeof updated)[number]] = value; + } + target.setData(updated); +}; diff --git a/apps/sn/src/features/facility-detail/model/resolve-facility-selection.ts b/apps/sn/src/features/facility-detail/model/resolve-facility-selection.ts new file mode 100644 index 000000000..7fc69d066 --- /dev/null +++ b/apps/sn/src/features/facility-detail/model/resolve-facility-selection.ts @@ -0,0 +1,248 @@ +import { + FldngWtlvMapCol, + FldngWtlvMapCol2, + FldngDpthMapCol, +} from "@/entities/facility/model/flood-columns"; +import { + SwtrmtFcltCol, + SwtrmtFcltCol2, +} from "@/entities/facility/model/treatment-facility-columns"; +import { + SwtrmtPrcsZoneCol, + SwtrmtPrcsZoneCol2, +} from "@/entities/facility/model/treatment-zone-columns"; +import { + untrtSwgRainCol1, + untrtSwgRainCol2, + stofcbrCol, + stofcbrCol2, +} from "@/entities/facility/model/overflow-columns"; +import { + MsSwagePplCol, + MsSwagePplCol2, +} from "@/entities/facility/model/pipe-columns"; +import { + ExclnSewagePumpCol, + ExclnSewagePumpCol2, + DrtwSewagePumpCol, + DrtwSewagePumpCol2, +} from "@/entities/facility/model/pump-columns"; +import { + MsSwageMhlCol, + MsSwageMhlCol2, +} from "@/entities/facility/model/manhole-columns"; +import { DRTW_SEWAGE_PUMP_USE_CODES } from "@/entities/facility/model/facility-code-fallbacks"; +import { normalizeFldngHstryWtlvDisplayId } from "@/entities/flood-history/lib/flood-history-utils"; + +type Column = { id: string; title: string }; +export type FacilitySelectionPatch = { + isClicked?: boolean; + headlist?: Column[]; + headlist2?: Column[]; + layerSpecificNm?: string; + isDrtwSewagePump?: boolean; +}; + +/** 시설 유형별 열/제목/표시 모드를 계산한다. React setter 없이 변경할 필드만 반환한다. + * 지도 객체 선택과 목록 선택의 기존 분기(펌프 구분 포함)는 그대로 유지한다. + */ +export function resolveFacilitySelection( + featureData: any, + gisMap: any, + featureId: any, +) { + const patch: FacilitySelectionPatch = {}; + const setIsClicked = (value: boolean) => { + patch.isClicked = value; + }; + const setHeadlist = (value: Column[]) => { + patch.headlist = value; + }; + const setHeadlist2 = (value: Column[]) => { + patch.headlist2 = value; + }; + const setLayerSpecificNm = (value: string) => { + patch.layerSpecificNm = value; + }; + const setIsDrtwSewagePump = (value: boolean) => { + patch.isDrtwSewagePump = value; + }; + + let result = null; + const layers = gisMap.getLayers().getArray(); // gisMap에서 모든 레이어 가져오기 + let featureLayer: any = null; // 피처가 속한 레이어를 찾기 위한 변수 + layers.some((layer: any) => { + const source = layer.getSource(); + // if (source && featureData && featureData.fid) { + // featureLayer = layer; // 피처가 속한 레이어 찾기 + // } + if ( + source && + source.hasFeature && + featureData && + typeof featureData.getId === "function" && + source.hasFeature(featureData) + ) { + featureLayer = layer; // 피처가 속한 레이어 찾기 + return true; // 반복 종료 + } + return false; + }); + + if (featureData["ol_uid"]) { + // Gis 레이어 클릭 이벤트 + setIsClicked(true); + const lyrTyNm = featureData.id_.split(".")[0]; + + switch (lyrTyNm) { + case "tb_ms_swage_ppl": { + result = "하수관로"; + setHeadlist(MsSwagePplCol); + setLayerSpecificNm(featureData.values_.fclt_mng_no); + break; + } + case "tb_ms_swage_mhl": { + result = "하수맨홀"; + setHeadlist(MsSwageMhlCol); + setLayerSpecificNm(featureData.values_.fclt_mng_no); + break; + } + case "tb_ms_swage_pmstn": { + const drtwPumpCode = DRTW_SEWAGE_PUMP_USE_CODES.find( + (code) => code.text === "오수펌프장", + ); + if ( + drtwPumpCode && + featureData.values_.usg_cd.trim() === drtwPumpCode.val + ) { + setIsDrtwSewagePump(true); + setHeadlist(DrtwSewagePumpCol); + } else { + setIsDrtwSewagePump(false); + setHeadlist(ExclnSewagePumpCol); + } + setLayerSpecificNm(featureData.values_.fclt_mng_no); + result = "하수펌프장"; + break; + } + case "tb_il_swage_prcs_fclt": { + result = "하수처리시설"; + setHeadlist(SwtrmtFcltCol); + setLayerSpecificNm(featureData.values_.swtrmt_fclt_cd); + break; + } + case "tb_ms_swage_prcs_zone": { + result = "하수처리구역"; + setHeadlist(SwtrmtPrcsZoneCol); + setLayerSpecificNm(featureData.values_.swtrmt_fclt_cd); + break; + } + case "tb_ms_fldng_wtlv_map": { + result = "침수흔적위선"; + setHeadlist(FldngWtlvMapCol); + setLayerSpecificNm( + normalizeFldngHstryWtlvDisplayId( + featureData.values_.fid ?? featureId, + ), + ); + break; + } + case "tb_ms_fldng_dpth_map": { + result = "침수흔적심선"; + setHeadlist(FldngDpthMapCol); + setLayerSpecificNm( + String(featureId ?? "").replace(/^tb_ms_fldng_dpth_map\./, ""), + ); + break; + } + case "tb_ms_stofcbr": { + result = "우수토실"; + setHeadlist(stofcbrCol); + setLayerSpecificNm(featureData.values_.fclt_mng_no); + break; + } + case "om_dt_untrt_swg_rain": { + result = "중점우수토실"; + setHeadlist(untrtSwgRainCol1); + setHeadlist2(untrtSwgRainCol2); + setLayerSpecificNm(featureData.values_.manage_no); + break; + } + default: { + result = featureLayer ? featureLayer.get("lyrNm") : null; + break; + } + } + } else { + // SearchModal 클릭 이벤트 + setIsClicked(false); + const lyrTyNm = featureData.lyrTypeNm; + + switch (lyrTyNm) { + case "tb_ms_swage_ppl": { + result = "하수관로"; + setHeadlist(MsSwagePplCol2); + setLayerSpecificNm(featureData.fcltMngNo ?? featureData.swtrmtFcltCd); + break; + } + case "tb_ms_swage_mhl": { + result = "하수맨홀"; + setHeadlist(MsSwageMhlCol2); + setLayerSpecificNm(featureData.fcltMngNo); + break; + } + case "tb_ms_swage_pmstn": { + const drtwPumpCode = DRTW_SEWAGE_PUMP_USE_CODES.find( + (code) => code.text === "오수펌프장", + ); + if (drtwPumpCode && featureData.usgCd.trim() === drtwPumpCode.val) { + setIsDrtwSewagePump(false); + setHeadlist(ExclnSewagePumpCol2); + } else { + setIsDrtwSewagePump(true); + setHeadlist(DrtwSewagePumpCol2); + } + setLayerSpecificNm(featureData.fcltMngNo); + result = "하수펌프장"; + break; + } + case "tb_il_swage_prcs_fclt": { + result = "하수처리시설"; + setHeadlist(SwtrmtFcltCol2); + setLayerSpecificNm(featureData.swtrmtFcltCd); + break; + } + case "tb_ms_swage_prcs_zone": { + result = "하수처리구역"; + setHeadlist(SwtrmtPrcsZoneCol2); + setLayerSpecificNm(featureData.swtrmtFcltCd); + break; + } + case "tb_ms_fldng_wtlv_map": { + result = "침수흔적위선"; + setHeadlist(FldngWtlvMapCol2); + setLayerSpecificNm(normalizeFldngHstryWtlvDisplayId(featureData.fid)); + break; + } + case "tb_ms_fldng_dpth_map": { + result = "침수흔적심선"; + setHeadlist(FldngDpthMapCol); + setLayerSpecificNm( + String(featureData.fid ?? featureId ?? "").replace( + /^tb_ms_fldng_dpth_map\./, + "", + ), + ); + break; + } + case "tb_ms_stofcbr": { + result = "우수토실"; + setHeadlist(stofcbrCol2); + setLayerSpecificNm(featureData.fcltMngNo); + break; + } + } + } + + return { layerName: result, patch }; +} diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx b/apps/sn/src/features/facility-detail/ui/feature-click-event-popup.tsx similarity index 73% rename from apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx rename to apps/sn/src/features/facility-detail/ui/feature-click-event-popup.tsx index 281c47113..e2fa45500 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx +++ b/apps/sn/src/features/facility-detail/ui/feature-click-event-popup.tsx @@ -23,59 +23,42 @@ * - 하수맨홀 자체입력정보 저장: POST /api/v1/fcltMhl (sewerage-service) */ "use client"; -import { getData, postData } from "@/components/lib/api"; -import { VerticalTable } from "@/components/ui/verticalTable"; +import { resolveFacilitySelection } from "../model/resolve-facility-selection"; +import { + supportsFacilityInput, + fetchManagingInstitutions, + fetchFacilityInput, + saveFacilityInput, + fetchPipeFlowBasis, +} from "../api/facility-input-api"; +import { VerticalTable } from "@/components/ui/vertical-table"; import { useGisContext } from "@common/business_components/ui/map/Gis"; -import GisApp from "@common/business_components/ui/map/lib/gis.core"; import { Button } from "@common/components/ui"; -import { useEffect, useRef, useState, useMemo } from "react"; +import { useEffect, useRef, useState, useMemo, useCallback } from "react"; import Style from "ol/style/Style.js"; import Fill from "ol/style/Fill.js"; import Stroke from "ol/style/Stroke.js"; import CircleStyle from "ol/style/Circle.js"; -import { getLayerContentComponent } from "../common"; -import { - FldngWtlvMapCol, - FldngWtlvMapCol2, - FldngDpthMapCol, - 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 { formatDisplayData } from "@/components/lib/utils/formatDisplayValue"; -import { codeMapper } from "../common/codeMapper"; -import LoadingOverlay from "@/components/ui/LoadingBar"; -import { searchUpperPpls } from "./smh"; +import { getLayerContentComponent } from "@/features/facility-detail/lib/facility-panel-registry"; +import { useFacilityCodeCatalog } from "@/entities/facility/lib/use-facility-code-catalog"; +import { formatDisplayData } from "@/components/lib/utils/format-display-value"; +import { codeMapper as mapFacilityCodes } from "@/entities/facility/lib/code-mapper"; +import LoadingOverlay from "@/components/ui/loading-bar"; +import { searchUpperPpls } from "@/features/facility-detail/ui/smh"; import { getElapsedYearsDisplayText, isInvalidInstallYmd, -} from "../agedPipe/agedPipeUtils"; -import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup"; +} from "@/entities/facility/lib/aged-pipe-utils"; +import { openWindowPopup } from "@/features/window-popup/lib/open-window-popup"; import { useAlertStore } from "@/shared/lib"; import { useConfirm } from "@frontend-opensource/use-react-hooks"; -import useStore from "@/store/useStore"; -import ExmnSurveyResultSection from "../exmn/ExmnSurveyResultSection"; +import useStore from "@/entities/reference-data/model/reference-data-store"; +import ExmnSurveyResultSection from "@/features/exmn/ui/exmn-survey-result-section"; import { formatFldngHstryDateTime, normalizeFldngHstryWtlvApiFid, - normalizeFldngHstryWtlvDisplayId, -} from "../fldngHstry/fldngHstryUtils"; -import { useFldngHstryFeatureClick } from "../fldngHstry/FldngHstryFeatureClick"; +} from "@/entities/flood-history/lib/flood-history-utils"; +import { useFldngHstryFeatureClick } from "@/app/(map-layout)/map/components/fldng-hstry/fldng-hstry-feature-click"; const linkedFacilityHighlightStyle = new Style({ image: new CircleStyle({ @@ -112,6 +95,13 @@ const FeatureClickEventPopup = ({ featureData: any; surveyMode?: boolean; }) => { + const catalog = useFacilityCodeCatalog(); + const { MS_SWAGE_MHL_CODES } = catalog; + const codeMapper = useCallback( + (input: Parameters[0]) => + mapFacilityCodes({ ...input, catalog }), + [catalog], + ); const alert = useAlertStore((state) => state.setMessage); const { confirm } = useConfirm(); const { gisMap } = useGisContext() as unknown as { @@ -186,7 +176,7 @@ const FeatureClickEventPopup = ({ const fetchMngInstOptions = async () => { try { - const result = await getData("v1/fcltPpl/mngInsts"); + const result = await fetchManagingInstitutions(); if (!isMounted) return; const sortedOptions = Array.isArray(result) ? [...result].sort((a, b) => @@ -367,192 +357,50 @@ const FeatureClickEventPopup = ({ // 피쳐데이터의 현재 레이어 이름 가져오는 함수 const getSelectedLayers = () => { - let result = null; - const layers = gisMap.getLayers().getArray(); // gisMap에서 모든 레이어 가져오기 - let featureLayer: any = null; // 피처가 속한 레이어를 찾기 위한 변수 - layers.some((layer: any) => { - const source = layer.getSource(); - // if (source && featureData && featureData.fid) { - // featureLayer = layer; // 피처가 속한 레이어 찾기 - // } - if ( - source && - source.hasFeature && - featureData && - typeof featureData.getId === "function" && - source.hasFeature(featureData) - ) { - featureLayer = layer; // 피처가 속한 레이어 찾기 - return true; // 반복 종료 - } - return false; - }); - - if (featureData["ol_uid"]) { - // Gis 레이어 클릭 이벤트 - setIsClicked(true); - const lyrTyNm = featureData.id_.split(".")[0]; - - switch (lyrTyNm) { - case "tb_ms_swage_ppl": { - result = "하수관로"; - setHeadlist(MsSwagePplCol); - setLayerSpecificNm(featureData.values_.fclt_mng_no); - break; - } - case "tb_ms_swage_mhl": { - result = "하수맨홀"; - setHeadlist(MsSwageMhlCol); - setLayerSpecificNm(featureData.values_.fclt_mng_no); - break; - } - case "tb_ms_swage_pmstn": { - const drtwPumpCode = DRTW_SEWAGE_PUMP_USE_CODES.find( - (code) => code.text === "오수펌프장", - ); - if ( - drtwPumpCode && - featureData.values_.usg_cd.trim() === drtwPumpCode.val - ) { - setIsDrtwSewagePump(true); - setHeadlist(DrtwSewagePumpCol); - } else { - setIsDrtwSewagePump(false); - setHeadlist(ExclnSewagePumpCol); - } - setLayerSpecificNm(featureData.values_.fclt_mng_no); - result = "하수펌프장"; - break; - } - case "tb_il_swage_prcs_fclt": { - result = "하수처리시설"; - setHeadlist(SwtrmtFcltCol); - setLayerSpecificNm(featureData.values_.swtrmt_fclt_cd); - break; - } - case "tb_ms_swage_prcs_zone": { - result = "하수처리구역"; - setHeadlist(SwtrmtPrcsZoneCol); - setLayerSpecificNm(featureData.values_.swtrmt_fclt_cd); - break; - } - case "tb_ms_fldng_wtlv_map": { - result = "침수흔적위선"; - setHeadlist(FldngWtlvMapCol); - setLayerSpecificNm( - normalizeFldngHstryWtlvDisplayId( - featureData.values_.fid ?? featureId, - ), - ); - break; - } - case "tb_ms_fldng_dpth_map": { - result = "침수흔적심선"; - setHeadlist(FldngDpthMapCol); - setLayerSpecificNm( - String(featureId ?? "").replace(/^tb_ms_fldng_dpth_map\./, ""), - ); - break; - } - case "tb_ms_stofcbr": { - result = "우수토실"; - setHeadlist(stofcbrCol); - setLayerSpecificNm(featureData.values_.fclt_mng_no); - break; - } - case "om_dt_untrt_swg_rain": { - result = "중점우수토실"; - setHeadlist(untrtSwgRainCol1); - setHeadlist2(untrtSwgRainCol2); - setLayerSpecificNm(featureData.values_.manage_no); - break; - } - default: { - result = featureLayer ? featureLayer.get("lyrNm") : null; - break; - } - } - } else { - // SearchModal 클릭 이벤트 - setIsClicked(false); - const lyrTyNm = featureData.lyrTypeNm; - - switch (lyrTyNm) { - case "tb_ms_swage_ppl": { - result = "하수관로"; - setHeadlist(MsSwagePplCol2); - setLayerSpecificNm(featureData.fcltMngNo ?? featureData.swtrmtFcltCd); - break; - } - case "tb_ms_swage_mhl": { - result = "하수맨홀"; - setHeadlist(MsSwageMhlCol2); - setLayerSpecificNm(featureData.fcltMngNo); - break; - } - case "tb_ms_swage_pmstn": { - const drtwPumpCode = DRTW_SEWAGE_PUMP_USE_CODES.find( - (code) => code.text === "오수펌프장", - ); - if (drtwPumpCode && featureData.usgCd.trim() === drtwPumpCode.val) { - setIsDrtwSewagePump(false); - setHeadlist(ExclnSewagePumpCol2); - } else { - setIsDrtwSewagePump(true); - setHeadlist(DrtwSewagePumpCol2); - } - setLayerSpecificNm(featureData.fcltMngNo); - result = "하수펌프장"; - break; - } - case "tb_il_swage_prcs_fclt": { - result = "하수처리시설"; - setHeadlist(SwtrmtFcltCol2); - setLayerSpecificNm(featureData.swtrmtFcltCd); - break; - } - case "tb_ms_swage_prcs_zone": { - result = "하수처리구역"; - setHeadlist(SwtrmtPrcsZoneCol2); - setLayerSpecificNm(featureData.swtrmtFcltCd); - break; - } - case "tb_ms_fldng_wtlv_map": { - result = "침수흔적위선"; - setHeadlist(FldngWtlvMapCol2); - setLayerSpecificNm(normalizeFldngHstryWtlvDisplayId(featureData.fid)); - break; - } - case "tb_ms_fldng_dpth_map": { - result = "침수흔적심선"; - setHeadlist(FldngDpthMapCol); - setLayerSpecificNm( - String(featureData.fid ?? featureId ?? "").replace( - /^tb_ms_fldng_dpth_map\./, - "", - ), - ); - break; - } - case "tb_ms_stofcbr": { - result = "우수토실"; - setHeadlist(stofcbrCol2); - setLayerSpecificNm(featureData.fcltMngNo); - break; - } - } - } - - return result; + const { layerName, patch } = resolveFacilitySelection( + featureData, + gisMap, + featureId, + ); + if ("isClicked" in patch) setIsClicked(patch.isClicked as boolean); + if ("headlist" in patch) setHeadlist(patch.headlist!); + if ("headlist2" in patch) setHeadlist2(patch.headlist2!); + if ("layerSpecificNm" in patch) + setLayerSpecificNm(patch.layerSpecificNm as string); + if ("isDrtwSewagePump" in patch) + setIsDrtwSewagePump(patch.isDrtwSewagePump as boolean); + return layerName; }; useEffect(() => { + let active = true; + const commitLayerNm: typeof setLayerNm = (value) => { + if (active) setLayerNm(value); + }; + const commitFetchData: typeof setFetchData = (value) => { + if (active) setFetchData(value); + }; + const commitTableData: typeof setTableData = (value) => { + if (active) setTableData(value); + }; + const commitButtonChk: typeof setButtonChk = (value) => { + if (active) setButtonChk(value); + }; + const commitIsMainSsc: typeof setIsMainSsc = (value) => { + if (active) setIsMainSsc(value); + }; + const commitChmbrXcnts: typeof setChmbrXcnts = (value) => { + if (active) setChmbrXcnts(value); + }; + const commitChmbrYdnts: typeof setChmbrYdnts = (value) => { + if (active) setChmbrYdnts(value); + }; const fetchData = async () => { if (!featureId) return; try { const selectedLayerName = getSelectedLayers(); // 레이어 이름 가져오기 - setLayerNm(selectedLayerName); // 상태 업데이트 + commitLayerNm(selectedLayerName); // 상태 업데이트 let data; if (featureData["ol_uid"]) @@ -630,12 +478,12 @@ const FeatureClickEventPopup = ({ } let mergedDetailData = formattedData; - setFetchData(formattedData); - setTableData(formattedData); + commitFetchData(formattedData); + commitTableData(formattedData); switch (selectedLayerName) { case "하수관로": - setButtonChk(true); + commitButtonChk(true); // ✅ codeMapper 사용하여 API 결과도 변환 { @@ -645,15 +493,15 @@ const FeatureClickEventPopup = ({ data: { ...formattedData }, }), ); - setFetchData(initialPipeData); - setTableData(initialPipeData); + commitFetchData(initialPipeData); + commitTableData(initialPipeData); } break; case "하수맨홀": - setButtonChk(true); + commitButtonChk(true); // ✅ codeMapper 사용하여 API 결과도 변환 - setTableData( + commitTableData( codeMapper({ layerName: selectedLayerName, data: { ...formattedData }, @@ -661,9 +509,9 @@ const FeatureClickEventPopup = ({ ); break; case "하수펌프장": - setButtonChk(true); + commitButtonChk(true); - setTableData( + commitTableData( codeMapper({ layerName: selectedLayerName, data: { ...formattedData }, @@ -671,37 +519,37 @@ const FeatureClickEventPopup = ({ ); break; case "하수처리시설": - setButtonChk(false); + commitButtonChk(false); break; case "하수처리구역": - setButtonChk(false); + commitButtonChk(false); break; case "침수흔적위선": - setButtonChk(false); + commitButtonChk(false); break; case "우수토실": - setButtonChk(true); - setIsMainSsc(false); + commitButtonChk(true); + commitIsMainSsc(false); break; case "중점우수토실": - setButtonChk(false); - setIsMainSsc(true); + commitButtonChk(false); + commitIsMainSsc(true); // 🎯 processedData도 포맷팅하여 설정 const processedFormattedData = formatDisplayData(processedData); - setFetchData([processedFormattedData]); - setTableData(processedFormattedData); + commitFetchData([processedFormattedData]); + commitTableData(processedFormattedData); // setIsLoading(false); // 기본 렌더링 완료 - setTableData( + commitTableData( codeMapper({ layerName: selectedLayerName, data: { ...processedFormattedData }, }), ); - setChmbrXcnts(data.chmbr_xcnts); - setChmbrYdnts(data.chmbr_ydnts); + commitChmbrXcnts(data.chmbr_xcnts); + commitChmbrYdnts(data.chmbr_ydnts); break; default: @@ -709,7 +557,6 @@ const FeatureClickEventPopup = ({ } // 자체입력정보 API 엔드포인트 선택 - let apiEndpoint = ""; const fcltMngNo = String( featureData?.values_?.fclt_mng_no || featureData?.fcltMngNo || "", ).trim(); @@ -719,19 +566,15 @@ const FeatureClickEventPopup = ({ const canRequestDetail = isValidDetailIdentifier(fcltMngNo) && isValidDetailIdentifier(mngInstCd); - if (selectedLayerName === "하수관로") - apiEndpoint = `v1/fcltPpl/${fcltMngNo}/${mngInstCd}`; - else if (selectedLayerName === "우수토실") - apiEndpoint = `v1/fcltStofcbr/${fcltMngNo}/${mngInstCd}`; - else if (selectedLayerName === "하수펌프장") - apiEndpoint = `v1/fcltPump/${fcltMngNo}/${mngInstCd}`; - else if (selectedLayerName === "하수맨홀") - apiEndpoint = `v1/fcltMhl/${fcltMngNo}/${mngInstCd}`; // API에서 상세 데이터 가져오기 - if (apiEndpoint && canRequestDetail) { + if (supportsFacilityInput(selectedLayerName) && canRequestDetail) { try { - const result = await getData(apiEndpoint); + const result = await fetchFacilityInput( + selectedLayerName, + fcltMngNo, + mngInstCd, + ); if (result) { mergedDetailData = mergeDetailData(mergedDetailData, result); @@ -742,8 +585,8 @@ const FeatureClickEventPopup = ({ }), ); - setFetchData(processedApiData); - setTableData(processedApiData); + commitFetchData(processedApiData); + commitTableData(processedApiData); } } catch (apiError) { void apiError; @@ -753,31 +596,19 @@ const FeatureClickEventPopup = ({ if (selectedLayerName === "하수관로" && canRequestDetail) { try { - const escapeCqlLiteral = (value: string) => - value.replace(/'/g, "''"); const requestCacheKey = `${fcltMngNo}::${mngInstCd}`; - const params = new URLSearchParams({ - lyrTypeNm: "tb_ms_swage_ppl", - includeFlowDirectionBasis: "true", - cqlFiter: - `fclt_mng_no='${escapeCqlLiteral(fcltMngNo)}' and ` + - `mng_inst_cd='${escapeCqlLiteral(mngInstCd)}'`, - page: "0", - size: "1", - }); let flowDirectionRequest = flowDirectionBasisRequestCacheRef.current.get(requestCacheKey); if (!flowDirectionRequest) { - flowDirectionRequest = getData(`v1/fclts?${params.toString()}`) - .then((response) => - Array.isArray(response?.data) ? response.data[0] : null, - ) - .catch((error) => { - flowDirectionBasisRequestCacheRef.current.delete( - requestCacheKey, - ); - throw error; - }); + flowDirectionRequest = fetchPipeFlowBasis( + fcltMngNo, + mngInstCd, + ).catch((error) => { + flowDirectionBasisRequestCacheRef.current.delete( + requestCacheKey, + ); + throw error; + }); flowDirectionBasisRequestCacheRef.current.set( requestCacheKey, flowDirectionRequest, @@ -795,8 +626,8 @@ const FeatureClickEventPopup = ({ data: mergedDetailData, }), ); - setFetchData(processedPipeData); - setTableData(processedPipeData); + commitFetchData(processedPipeData); + commitTableData(processedPipeData); } } catch (flowDirectionError) { console.warn( @@ -819,16 +650,19 @@ const FeatureClickEventPopup = ({ ...formattedData, ...fldngHstryDetail.rainAnalysis, }; - setFetchData(mergedFldngHstryData); - setTableData(mergedFldngHstryData); + commitFetchData(mergedFldngHstryData); + commitTableData(mergedFldngHstryData); } } catch (error) { console.error("데이터 로딩 중 오류:", error); } }; - fetchData(); - }, [featureId, processedData, mngInstNameMap]); + void fetchData(); + return () => { + active = false; + }; + }, [featureId, processedData, mngInstNameMap, codeMapper]); useEffect(() => { const layerTypeNm = featureData?.values_ @@ -883,14 +717,8 @@ const FeatureClickEventPopup = ({ }); // 레이어명에 따라 API 엔드포인트 분기처리 - let apiEndpoint = ""; let result = null; - if (layerNm === "하수관로") apiEndpoint = "v1/fcltPpl"; - else if (layerNm === "우수토실") apiEndpoint = "v1/fcltStofcbr"; - else if (layerNm === "하수펌프장") apiEndpoint = "v1/fcltPump"; - else if (layerNm === "하수맨홀") apiEndpoint = "v1/fcltMhl"; - bodyData["fcltMngNo"] = featureData.values_.fclt_mng_no.toString() || null; bodyData["mngInstCd"] = @@ -908,7 +736,7 @@ const FeatureClickEventPopup = ({ } try { - result = await postData(apiEndpoint, bodyData); + result = await saveFacilityInput(layerNm, bodyData); if (result) { alert("저장되었습니다"); diff --git a/apps/sn/app/(map-layout)/map/components/InputSection.tsx b/apps/sn/src/features/facility-detail/ui/input-section.tsx similarity index 100% rename from apps/sn/app/(map-layout)/map/components/InputSection.tsx rename to apps/sn/src/features/facility-detail/ui/input-section.tsx diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/MsSwagePpl.tsx b/apps/sn/src/features/facility-detail/ui/ms-swage-ppl.tsx similarity index 70% rename from apps/sn/app/(map-layout)/map/components/popupComponent/MsSwagePpl.tsx rename to apps/sn/src/features/facility-detail/ui/ms-swage-ppl.tsx index aadf5751b..8a4333467 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/MsSwagePpl.tsx +++ b/apps/sn/src/features/facility-detail/ui/ms-swage-ppl.tsx @@ -1,7 +1,7 @@ /** * 화면명: 하수관로 정보 * ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창 (하수관로) - * + * * 📄 MsSwagePpl * 하수관로 정보 컴포넌트 * @@ -10,30 +10,50 @@ * - VerticalTable을 통한 입력 폼 렌더링 * - InputSection을 통한 운영 데이터 관리 * - 저장 버튼을 통한 데이터 저장 기능 - * + * * 🔗 사용 API * - 시설별 운영 정보 조회: GET /api/v1/bizInfo/bizByFclt (sewerage-service) */ -'use client' -import { VerticalTable } from '@/components/ui/verticalTable'; -import React, { useState, useEffect } from 'react' -import InputSection from '../InputSection'; -import { handleInputChange } from '../common'; -import { getData } from "@/components/lib/api"; -import useStore from '@/store/useStore'; +"use client"; +import { VerticalTable } from "@/components/ui/vertical-table"; +import React, { useState, useEffect } from "react"; +import InputSection from "@/features/facility-detail/ui/input-section"; +import { handleInputChange } from "@/features/facility-detail/lib/facility-panel-registry"; +import { getData } from "@/shared/api/sewerage-api"; +import useStore from "@/entities/reference-data/model/reference-data-store"; // 하수관로 const inputColumn = [ - { id: "instlMbdyCd", title: "설치주제", type: "select", options: ["BTL", "재정사업"] }, + { + id: "instlMbdyCd", + title: "설치주제", + type: "select", + options: ["BTL", "재정사업"], + }, { id: "swtrmtZoneNm", title: "처리구역", type: "input" }, { id: "mltvZoneNm", title: "배수구역", type: "input" }, { id: "mltvDtdvNm", title: "배수분구", type: "input" }, - { id: "lineCd", title: "지선/간선", type: "select", options: ["지선", "간선"] }, - { id: "floCd", title: "흐름방식", type: "select", options: ["자연유하", "압송"] }, + { + id: "lineCd", + title: "지선/간선", + type: "select", + options: ["지선", "간선"], + }, + { + id: "floCd", + title: "흐름방식", + type: "select", + options: ["자연유하", "압송"], + }, { id: "layCd", title: "매설위치", type: "select", options: ["도로", "하상"] }, ]; -type operationDataType = { year: string; type: string, bizInfoNm: string, bizType: string }; +type operationDataType = { + year: string; + type: string; + bizInfoNm: string; + bizType: string; +}; const MsSwagePpl = ({ featureData, @@ -55,8 +75,12 @@ const MsSwagePpl = ({ }; const { BIZ_SE_CD, BIZ_TY_CD } = useStore(); - const requestMngInstCd = String(featureData?.mng_inst_cd ?? featureData?.mngInstCd ?? "").trim(); - const requestFcltMngNo = String(featureData?.fclt_mng_no ?? featureData?.fcltMngNo ?? "").trim(); + const requestMngInstCd = String( + featureData?.mng_inst_cd ?? featureData?.mngInstCd ?? "", + ).trim(); + const requestFcltMngNo = String( + featureData?.fclt_mng_no ?? featureData?.fcltMngNo ?? "", + ).trim(); // 조사관리 전용 탭은 기본·입력정보와 조사결과만 표시하고 일반 시설 유지관리 이력은 숨긴다. const showOperationSections = !surveyResultContent; @@ -76,19 +100,26 @@ const MsSwagePpl = ({ return; } + let active = true; const fetchAll = async () => { - const operationData = await getData(`v1/bizInfo/bizByFclt?mngInstCd=${requestMngInstCd}&fcltMngNo=${requestFcltMngNo}&page=0&size=1`); + const operationData = await getData( + `v1/bizInfo/bizByFclt?mngInstCd=${requestMngInstCd}&fcltMngNo=${requestFcltMngNo}&page=0&size=1`, + ); let inspectionArr: operationDataType[] = []; let cleaningArr: operationDataType[] = []; let repairArr: operationDataType[] = []; if (Array.isArray(operationData)) { - operationData.forEach(item => { + operationData.forEach((item) => { if (!item.flfmt_yr) return; // biz_se_cd에 해당하는 type명 찾기 - const seCodeObj = BIZ_SE_CD.find(code => code.lwpoCd === item.biz_se_cd); - const tyCodeObj = BIZ_TY_CD.find(code => code.lwpoCd === item.biz_ty_cd); + const seCodeObj = BIZ_SE_CD.find( + (code) => code.lwpoCd === item.biz_se_cd, + ); + const tyCodeObj = BIZ_TY_CD.find( + (code) => code.lwpoCd === item.biz_ty_cd, + ); const seTypeName = seCodeObj ? seCodeObj.lwpoCdNm : ""; const tyTypeName = tyCodeObj ? tyCodeObj.lwpoCdNm : ""; switch (item.biz_se_cd) { @@ -122,17 +153,28 @@ const MsSwagePpl = ({ }); } + if (!active) return; setInspectionData(inspectionArr); setCleaningData(cleaningArr); setRepairData(repairArr); }; fetchAll().catch((error) => { + if (!active) return; console.error("운영 이력 조회 실패:", error); setInspectionData([]); setCleaningData([]); setRepairData([]); }); - }, [BIZ_SE_CD, BIZ_TY_CD, requestFcltMngNo, requestMngInstCd, showOperationSections]); + return () => { + active = false; + }; + }, [ + BIZ_SE_CD, + BIZ_TY_CD, + requestFcltMngNo, + requestMngInstCd, + showOperationSections, + ]); // 폼 데이터가 변경될 때 호출될 핸들러 const handleFormDataChange = (newFormData: any) => { @@ -147,11 +189,10 @@ const MsSwagePpl = ({ } }; - return ( <> handleInputChange(section, index, field, value, dataMap)} + onInputChange={(section, index, field, value) => + handleInputChange(section, index, field, value, dataMap) + } /> handleInputChange(section, index, field, value, dataMap)} + onInputChange={(section, index, field, value) => + handleInputChange(section, index, field, value, dataMap) + } /> handleInputChange(section, index, field, value, dataMap)} + onInputChange={(section, index, field, value) => + handleInputChange(section, index, field, value, dataMap) + } /> )} - ) -} + ); +}; -export default MsSwagePpl +export default MsSwagePpl; diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/sewagePump.tsx b/apps/sn/src/features/facility-detail/ui/sewage-pump.tsx similarity index 77% rename from apps/sn/app/(map-layout)/map/components/popupComponent/sewagePump.tsx rename to apps/sn/src/features/facility-detail/ui/sewage-pump.tsx index 8712a7b31..6a5906111 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/sewagePump.tsx +++ b/apps/sn/src/features/facility-detail/ui/sewage-pump.tsx @@ -1,7 +1,7 @@ /** * 화면명: 하수펌프장 정보 * ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창 (하수펌프장) - * + * * 📄 sewagePump * 하수펌프장 정보 컴포넌트 * @@ -9,22 +9,36 @@ * - VerticalTable을 통한 입력 폼 렌더링 * - 저장 버튼을 통한 데이터 저장 기능 */ -import { VerticalTable } from '@/components/ui/verticalTable'; -import React, { useState, useEffect } from 'react' +import { VerticalTable } from "@/components/ui/vertical-table"; +import React, { useState, useEffect } from "react"; // 빗물펌프장 자체 입력 정보 const exclnInputColumn = [ { id: "swtrmtZoneNm", title: "배수구역", type: "input" }, { id: "mltvDtdvNm", title: "배수분구", type: "input" }, - { id: "designFrqYr", title: "설계빈도(년)", type: "input", placeholder: "예: 10", isNumber: true, length: 4 }, -] + { + id: "designFrqYr", + title: "설계빈도(년)", + type: "input", + placeholder: "예: 10", + isNumber: true, + length: 4, + }, +]; // 오수펌프장 자체 입력 정보 const drtwInputColumn = [ { id: "swtrmtZoneNm", title: "처리구역", type: "input" }, { id: "mltvDtdvNm", title: "처리분구", type: "input" }, -] +]; - -const sewagePump = ({ featureData, isDrtwSewagePump, onSaveClick }: { featureData: any, isDrtwSewagePump?: boolean, onSaveClick?: () => void }) => { +const sewagePump = ({ + featureData, + isDrtwSewagePump, + onSaveClick, +}: { + featureData: any; + isDrtwSewagePump?: boolean; + onSaveClick?: () => void; +}) => { const [formData, setFormData] = useState({}); // featureData가 변경될 때 formData 초기화 @@ -49,7 +63,7 @@ const sewagePump = ({ featureData, isDrtwSewagePump, onSaveClick }: { featureDat return ( - ) -} + ); +}; -export default sewagePump +export default sewagePump; diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/smh.tsx b/apps/sn/src/features/facility-detail/ui/smh.tsx similarity index 81% rename from apps/sn/app/(map-layout)/map/components/popupComponent/smh.tsx rename to apps/sn/src/features/facility-detail/ui/smh.tsx index f69902b12..ff7fe9e47 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/smh.tsx +++ b/apps/sn/src/features/facility-detail/ui/smh.tsx @@ -1,7 +1,7 @@ /** * 화면명: 하수맨홀 정보 * ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창 (하수맨홀) - * + * * 📄 Smh * 하수맨홀 정보 컴포넌트 * @@ -9,14 +9,14 @@ * - VerticalTable을 통한 입력 폼 렌더링 * - 상류관로 검색 및 지도 포커스 기능 * - 저장 버튼을 통한 데이터 저장 기능 - * + * * 🔗 사용 API * - 상류관로 조회: GET /api/v1/fcltMhl/upperPpls/{fcltMngNo} (sewerage-service) */ -import { VerticalTable } from '@/components/ui/verticalTable'; -import { type ReactNode, useEffect, useState } from 'react'; +import { VerticalTable } from "@/components/ui/vertical-table"; +import { type ReactNode, useEffect, useState } from "react"; import GisApp from "@common/business_components/ui/map/lib/gis.core"; -import { getData } from '@/components/lib/api'; +import { getData } from "@/shared/api/sewerage-api"; // 상류관로 보기 함수 (FeatureClickEventPopup에서 사용) export async function searchUpperPpls( @@ -118,25 +118,25 @@ const Smh = ({ return ( <> - - {surveyResultContent} + + {surveyResultContent} - ) -} + ); +}; export default Smh; diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/Ssc.tsx b/apps/sn/src/features/facility-detail/ui/ssc.tsx similarity index 90% rename from apps/sn/app/(map-layout)/map/components/popupComponent/Ssc.tsx rename to apps/sn/src/features/facility-detail/ui/ssc.tsx index 5a0fcadde..f3c79b7da 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/Ssc.tsx +++ b/apps/sn/src/features/facility-detail/ui/ssc.tsx @@ -17,18 +17,18 @@ "use client"; import { StandardPopupLayout } from "@common/business_components/ui"; import { Button } from "@common/components/ui"; -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useRef } from "react"; import { untrtSwgRainCol3, untrtSwgRainCol4, untrtSwgRainCol5, -} from "../common/table"; -import { VerticalTable } from "@/components/ui/verticalTable"; -import { getRawJson } from "@/components/lib/api"; -import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue"; -import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup"; +} from "@/entities/facility/model/overflow-columns"; +import { VerticalTable } from "@/components/ui/vertical-table"; +import { getRawJson } from "@/shared/api/sewerage-api"; +import { formatDisplayData } from "@/components/lib/utils/format-display-value"; +import { openWindowPopup } from "@/features/window-popup/lib/open-window-popup"; import { useAlertStore } from "@/shared/lib"; -import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable"; +import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table"; const inputColumn = [ { @@ -70,12 +70,23 @@ const Ssc = ({ const [selectedRowIndex, setSelectedRowIndex] = useState(-1); const [curPage, setCurPage] = useState(1); const [curDetailPage, setCurDetailPage] = useState(1); + // 시설·페이지 재선택 시 오래된 측정 응답을 무시한다. + const rainRequestSequence = useRef(0); + const monitoringRequestSequence = useRef(0); + useEffect( + () => () => { + rainRequestSequence.current += 1; + monitoringRequestSequence.current += 1; + }, + [chmbrXcnts, chmbrYdnts, featureData], + ); // 강우, 측정 정보 API 호출 함수 const fetchRainMeasureData = async ( chmbrXcnts: string, chmbrYdnts: string, ) => { + const sequence = ++rainRequestSequence.current; if (!chmbrXcnts || !chmbrYdnts) { console.warn("추가 데이터 호출 실패: 필수 매개변수 누락", { chmbrXcnts, @@ -95,6 +106,7 @@ const Ssc = ({ const result = await getRawJson( `/sewerage-service/api/v1/onsemiro/getSwgRainsByGeomety?${queryParams.toString()}`, ); + if (sequence !== rainRequestSequence.current) return []; // helper: "YYYY-MM-DD HH:MM:SS" -> "MM-DD HH:MM" const formatNoYearNoSec = (val: any) => { @@ -156,6 +168,7 @@ const Ssc = ({ setRainMeasureData(processedDataArray); setTotalCount(result?.totalElements || 0); } catch (error) { + if (sequence !== rainRequestSequence.current) return []; console.error("추가 데이터 로딩 실패:", error); return []; } @@ -163,6 +176,7 @@ const Ssc = ({ // 수량/수질측정 정보 API 호출 함수 const fetchMntrngsData = async (adstDstrCd: string, manageNo: string) => { + const sequence = ++monitoringRequestSequence.current; if (!adstDstrCd || !manageNo) { console.warn("추가 데이터 호출 실패: 필수 매개변수 누락", { adstDstrCd, @@ -180,6 +194,7 @@ const Ssc = ({ const result = await getRawJson( `/sewerage-service/api/v1/onsemiro/swgRain/${adstDstrCd}/${manageNo}/mntrngs?${queryParams.toString()}`, ); + if (sequence !== monitoringRequestSequence.current) return []; // 각 아이템 전처리 const processedDataArray = result.content.map((data: any) => { @@ -193,6 +208,7 @@ const Ssc = ({ setMntrngsData(processedDataArray); setTotalDetailCount(result?.totalElements || 0); } catch (error) { + if (sequence !== monitoringRequestSequence.current) return []; console.error("추가 데이터 로딩 실패:", error); return []; } @@ -261,11 +277,11 @@ const Ssc = ({ index: number, ) => { if (rainMeasureData[index]) { + setSelectedRowIndex(index); await fetchMntrngsData( rainMeasureData[index].adstDstrCd, rainMeasureData[index].manageNo, ); - setSelectedRowIndex(index); } }; @@ -340,7 +356,7 @@ const Ssc = ({ {isMainSsc && renderMode !== "popup" && (
-

+

중점우수토실 운영정보

diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/stormWaterPump.tsx b/apps/sn/src/features/facility-detail/ui/storm-water-pump.tsx similarity index 59% rename from apps/sn/app/(map-layout)/map/components/popupComponent/stormWaterPump.tsx rename to apps/sn/src/features/facility-detail/ui/storm-water-pump.tsx index 7b365b59e..2d1ddc044 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/stormWaterPump.tsx +++ b/apps/sn/src/features/facility-detail/ui/storm-water-pump.tsx @@ -1,26 +1,37 @@ /** * 화면명: 빗물펌프 정보 * ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창 (빗물펌프) - * + * * 📄 StormWaterPump * 빗물펌프 정보 컴포넌트 * * - 빗물펌프의 자체입력정보 표시 및 편집 기능 * - VerticalTable을 통한 입력 폼 렌더링 */ -import { VerticalTable } from '@/components/ui/verticalTable'; -import React from 'react' +import { VerticalTable } from "@/components/ui/vertical-table"; +import React from "react"; const inputColumn = [ { id: "swtrmtZoneNm", title: "처리구역", type: "input" }, { id: "mltvDtdvNm", title: "배수분구", type: "input" }, -] +]; // 빗물펌프 -const StormWaterPump = ({ featureData }: { featureData: Record }) => { +const StormWaterPump = ({ + featureData, +}: { + featureData: Record; +}) => { const validKey = inputColumn.map((item) => item.id); - const filteredData = featureData && Object.entries(featureData).filter(([key]) => validKey.includes(key)).reduce((obj: Record, [key, value]) => { obj[key] = value; return obj; }, {}); + const filteredData = + featureData && + Object.entries(featureData) + .filter(([key]) => validKey.includes(key)) + .reduce((obj: Record, [key, value]) => { + obj[key] = value; + return obj; + }, {}); return ( }) = cellClick={undefined} isEditable={true} onPageChange={function (page: number): void { - throw new Error('Function not implemented.'); + throw new Error("Function not implemented."); }} /> - ) -} + ); +}; -export default StormWaterPump +export default StormWaterPump; diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/SwtrmtFclt.tsx b/apps/sn/src/features/facility-detail/ui/swtrmt-fclt.tsx similarity index 62% rename from apps/sn/app/(map-layout)/map/components/popupComponent/SwtrmtFclt.tsx rename to apps/sn/src/features/facility-detail/ui/swtrmt-fclt.tsx index 3ef836edf..ca54b54bc 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/SwtrmtFclt.tsx +++ b/apps/sn/src/features/facility-detail/ui/swtrmt-fclt.tsx @@ -1,7 +1,7 @@ /** * 화면명: 하수처리시설 정보 * ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창 (하수처리시설) - * + * * 📄 SwtrmtFclt * 하수처리시설 정보 컴포넌트 * @@ -9,17 +9,15 @@ * - VerticalTable을 통한 테이블 렌더링 * - 조회 버튼을 통한 모달 표시 및 ReportTab 연동 */ -'use client' -import React from 'react' -import { VerticalTable } from '@/components/ui/verticalTable'; -import { Button } from '@common/components/ui'; -import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup'; -import { useAlertStore } from '@/shared/lib'; +"use client"; +import React from "react"; +import { VerticalTable } from "@/components/ui/vertical-table"; +import { Button } from "@common/components/ui"; +import { openWindowPopup } from "@/features/window-popup/lib/open-window-popup"; +import { useAlertStore } from "@/shared/lib"; // 하수처리시설 -const headList = [ - { id: "title", title: "하수도정비 기본계획보고서" }, -]; +const headList = [{ id: "title", title: "하수도정비 기본계획보고서" }]; const SwtrmtFclt = ({ gisObject }: { gisObject: any }) => { const alert = useAlertStore((state) => state.setMessage); @@ -34,7 +32,7 @@ const SwtrmtFclt = ({ gisObject }: { gisObject: any }) => { }); if (!popupWindow) { - alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.'); + alert("팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요."); } }; @@ -42,9 +40,13 @@ const SwtrmtFclt = ({ gisObject }: { gisObject: any }) => {
조회 + title: ( + + ), }} - headName='디지털 자료관 연계정보' + headName="디지털 자료관 연계정보" tableData={[]} headlist={headList} pageSize={20} @@ -55,8 +57,8 @@ const SwtrmtFclt = ({ gisObject }: { gisObject: any }) => { throw new Error("Function not implemented."); }} /> -
- ) -} +
+ ); +}; -export default SwtrmtFclt +export default SwtrmtFclt; diff --git a/apps/sn/app/(map-layout)/map/components/popupComponent/SwtrmtPrcsZoneAnalysis.tsx b/apps/sn/src/features/facility-detail/ui/swtrmt-prcs-zone-analysis.tsx similarity index 96% rename from apps/sn/app/(map-layout)/map/components/popupComponent/SwtrmtPrcsZoneAnalysis.tsx rename to apps/sn/src/features/facility-detail/ui/swtrmt-prcs-zone-analysis.tsx index 5887d73c5..dfa3668a4 100644 --- a/apps/sn/app/(map-layout)/map/components/popupComponent/SwtrmtPrcsZoneAnalysis.tsx +++ b/apps/sn/src/features/facility-detail/ui/swtrmt-prcs-zone-analysis.tsx @@ -26,18 +26,18 @@ import VectorSource from "ol/source/Vector.js"; import Stroke from "ol/style/Stroke.js"; import Style from "ol/style/Style.js"; -import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup"; -import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types"; +import { openWindowPopup } from "@/features/window-popup/lib/open-window-popup"; +import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types"; import { useAlertStore } from "@/shared/lib"; import { fetchSwtrmtPrcsZoneAnalysis, getSwtrmtPrcsZoneId, -} from "./swtrmtPrcsZoneAnalysisApi"; +} from "@/features/facility-detail/api/treatment-zone-api"; import type { RepairHistoryMapFeature, SwtrmtPrcsZoneAnalysisMetric, SwtrmtPrcsZoneAnalysisResponse, -} from "./swtrmtPrcsZoneAnalysisApi"; +} from "@/features/facility-detail/api/treatment-zone-api"; const analysisRows: { key: keyof NonNullable; @@ -287,10 +287,13 @@ const SwtrmtPrcsZoneAnalysis = ({ if ( data?.type === WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_SHOW_RESULTS ) { - const features = Array.isArray(data.payload?.rows) ? data.payload.rows : []; + const features = Array.isArray(data.payload?.rows) + ? data.payload.rows + : []; if (features.length === 0) pendingRepairFidRef.current = ""; replaceRepairLayer(features); - if (pendingRepairFidRef.current) focusRepairFeature(pendingRepairFidRef.current); + if (pendingRepairFidRef.current) + focusRepairFeature(pendingRepairFidRef.current); return; } if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_SELECT_PIPE) { diff --git a/apps/sn/src/features/fldngPredcPrst/lib/fldng-predc-prst-excel.ts b/apps/sn/src/features/fldng-predc-prst/lib/fldng-predc-prst-excel.ts similarity index 100% rename from apps/sn/src/features/fldngPredcPrst/lib/fldng-predc-prst-excel.ts rename to apps/sn/src/features/fldng-predc-prst/lib/fldng-predc-prst-excel.ts diff --git a/apps/sn/src/features/fldng-predc-prst/lib/flood-risk-grade.ts b/apps/sn/src/features/fldng-predc-prst/lib/flood-risk-grade.ts new file mode 100644 index 000000000..a0f7e2419 --- /dev/null +++ b/apps/sn/src/features/fldng-predc-prst/lib/flood-risk-grade.ts @@ -0,0 +1,16 @@ +/** 침수위험도 등급을 목록과 지도 오버레이에서 사용하는 고정 의미색으로 변환한다. */ +export const getFloodRiskGradeClassName = (value: string) => { + const grade = String(value ?? "") + .trim() + .toUpperCase(); + if (grade.includes("위험") || grade === "DANGER") { + return "bg-[#dc2626] text-white"; + } + if (grade.includes("경계") || grade === "WARNING") { + return "bg-[#f97316] text-white"; + } + if (grade.includes("주의") || grade === "CAUTION") { + return "bg-[#facc15] text-[#713f12]"; + } + return "sn-popup-badge-normal"; +}; diff --git a/apps/sn/src/features/fldng-predc-prst/lib/use-flood-prediction-map.ts b/apps/sn/src/features/fldng-predc-prst/lib/use-flood-prediction-map.ts new file mode 100644 index 000000000..9204b1bb8 --- /dev/null +++ b/apps/sn/src/features/fldng-predc-prst/lib/use-flood-prediction-map.ts @@ -0,0 +1,406 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +import Overlay from "ol/Overlay.js"; + +import GisApp from "@common/business_components/ui/map/lib/gis.core"; + +import { getRawJson } from "@/shared/api/sewerage-api"; +import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types"; + +import { getFloodRiskGradeClassName } from "./flood-risk-grade"; + +const FLOOD_PREDICTION_GRID_LAYER_ID = "SN_FLDNG_PREDICTION_GRID_LABEL"; +const FLOOD_PREDICTION_LAYER_RETRY_LIMIT = 20; +const FLOOD_PREDICTION_LAYER_RETRY_DELAY = 200; + +/** 침수예측현황 행 선택 후 지도에 적용할 위치·행 식별 정보다. */ +type FloodPredictionSelection = { + extent: [number, number, number, number]; + sggCd: string; + frequency: string; + analysisDateTime: string; +}; + +/** 자동 레이어 선택 실패 시 안내를 돌려보낼 침수예측 팝업 정보다. */ +type FloodPredictionPopupContext = { + source: Window | null; + origin: string; +}; + +type FloodPredictionFeature = { + get?: (key: string) => unknown; + getId?: () => string | number | undefined; +}; + +type FloodPredictionSource = { + getFeatures?: () => FloodPredictionFeature[]; + once?: (eventName: string, listener: () => void) => void; +}; + +type FloodPredictionLayer = { + get?: (key: string) => unknown; + getVisible?: () => boolean; + getSource?: () => FloodPredictionSource | null; +}; + +type FloodPredictionMap = { + addOverlay: (overlay: Overlay) => void; + removeOverlay: (overlay: Overlay) => void; + getLayers: () => { getArray: () => FloodPredictionLayer[] }; + getView: () => { + fit: ( + extent: [number, number, number, number], + options: { + duration: number; + padding: [number, number, number, number]; + maxZoom: number; + }, + ) => void; + }; +}; + +type FloodPredictionOverlayRow = Record & { + analysisDateTime?: unknown; + ctpvCd?: unknown; + ctpvNm?: unknown; + exceededFrequencyYear?: unknown; + floodRiskGrade?: unknown; + observedAccumulatedRainfall?: unknown; + predictedAccumulatedRainfall?: unknown; + predictionDateTime?: unknown; + rainBeginningDateTime?: unknown; + sggCd?: unknown; + sggNm?: unknown; +}; + +type FloodSelectionModule = { + resetSelectedFeature?: () => void | Promise; + selectFocusedBoundaryFeature?: ( + feature: FloodPredictionFeature, + append: boolean, + ) => void; +}; + +type UseFloodPredictionMapOptions = { + mapRef: { current: FloodPredictionMap | null }; + floodLayerType: string; + onRequestFloodLayer: () => void; +}; + +/** 침수예측 오버레이용 12자리 분석시각을 화면 표시 형식으로 변환한다. */ +const formatFloodPredictionDateTime = (value: unknown) => { + const text = String(value ?? "").trim(); + return /^\d{12}$/.test(text) + ? `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)} ${text.slice(8, 10)}:${text.slice(10, 12)}` + : text || "-"; +}; + +/** 팝업 메시지 payload를 검증하여 지도 선택 정보로 변환한다. */ +const parseFloodPredictionSelection = ( + payload: unknown, +): FloodPredictionSelection | null => { + const message = + payload && typeof payload === "object" + ? (payload as Record) + : {}; + const extent = Array.isArray(message.extent) + ? message.extent.map(Number) + : []; + const sggCd = String(message.sggCd ?? "").trim(); + const frequency = String(message.sbmrgFrqExpln ?? "").trim(); + const analysisDateTime = String(message.analysisDateTime ?? "").trim(); + + if ( + extent.length !== 4 || + extent.some((value: number) => !Number.isFinite(value)) || + !/^\d{5}$/.test(sggCd) || + !/^(030|050|080|100)$/.test(frequency) + ) { + return null; + } + + return { + extent: extent as [number, number, number, number], + sggCd, + frequency, + analysisDateTime, + }; +}; + +/** 선택 행의 분석값을 표시할 지도 오버레이 DOM을 생성한다. */ +const createFloodPredictionOverlayElement = ( + row: FloodPredictionOverlayRow, + analysisDateTime: string, + onClose: () => void, +) => { + const observed = Number(row.observedAccumulatedRainfall ?? 0).toFixed(1); + const predicted = Number(row.predictedAccumulatedRainfall ?? 0).toFixed(1); + const container = document.createElement("div"); + container.style.cssText = + "position:relative; background:rgba(255,255,255,.55); color:#111827; border:1px solid rgba(17,24,39,.7); box-shadow:0 2px 8px rgba(0,0,0,.2); padding:8px 10px; font:400 11px/1.35 sans-serif; width:230px;"; + + const close = document.createElement("button"); + close.type = "button"; + close.textContent = "×"; + close.title = "침수예측 오버레이 닫기"; + close.style.cssText = + "position:absolute; right:3px; top:1px; border:0; background:transparent; color:#111827; font-size:16px; line-height:16px; cursor:pointer;"; + close.addEventListener("click", onClose); + + const table = document.createElement("table"); + table.style.cssText = + "border-collapse:collapse; table-layout:fixed; width:100%;"; + const colgroup = document.createElement("colgroup"); + const keyColumn = document.createElement("col"); + const valueColumn = document.createElement("col"); + keyColumn.style.width = "55%"; + valueColumn.style.width = "45%"; + colgroup.append(keyColumn, valueColumn); + table.append(colgroup); + + const title = table.createTHead().insertRow(); + const titleCell = title.insertCell(); + titleCell.colSpan = 2; + titleCell.textContent = `${row.ctpvNm ?? row.ctpvCd ?? "-"} ${row.sggNm ?? row.sggCd ?? "-"} 침수예측`; + titleCell.style.cssText = + "font-weight:500; text-align:center; padding:2px 16px 6px 2px; border-bottom:1px solid #9ca3af;"; + + const body = table.createTBody(); + const overlayRows: Array<[string, string]> = [ + [ + "분석시각", + formatFloodPredictionDateTime(row.analysisDateTime ?? analysisDateTime), + ], + [ + "강우시작시각", + formatFloodPredictionDateTime(row.rainBeginningDateTime), + ], + ["관측 누적강수량(mm)", observed], + ["예측시각", formatFloodPredictionDateTime(row.predictionDateTime)], + ["예측 누적강수량(mm)", predicted], + ["초과 빈도", `${row.exceededFrequencyYear ?? "-"}년`], + ["침수위험도", String(row.floodRiskGrade ?? "-")], + ]; + overlayRows.forEach(([label, value]) => { + const tr = body.insertRow(); + const key = tr.insertCell(); + key.textContent = label; + // 목록용 전역 말줄임 규칙이 지도 오버레이 셀에는 적용되지 않게 한다. + key.setAttribute("data-sn-cell-wrap", "true"); + key.style.cssText = + "font-weight:400; padding:2px 8px 2px 2px; white-space:nowrap; border-bottom:1px solid #e5e7eb;"; + + const val = tr.insertCell(); + val.setAttribute("data-sn-cell-wrap", "true"); + val.style.cssText = + "font-weight:400; overflow:visible; text-overflow:clip; text-align:right; padding:2px 2px; white-space:nowrap; border-bottom:1px solid #e5e7eb;"; + if (label === "침수위험도") { + // 침수예측현황 목록과 같은 배지 스타일로 등급을 표시한다. + const badge = document.createElement("span"); + badge.textContent = value; + badge.className = `sn-popup-badge min-w-[44px] ${getFloodRiskGradeClassName(String(value ?? ""))}`; + val.append(badge); + } else { + val.textContent = value; + } + }); + + container.append(close, table); + return container; +}; + +/** 침수예측현황 팝업과 도시침수지도의 선택·이동·오버레이 흐름을 관리한다. */ +export const useFloodPredictionMap = ({ + mapRef, + floodLayerType, + onRequestFloodLayer, +}: UseFloodPredictionMapOptions) => { + // 자동 레이어 선택이 완료된 뒤 다시 적용할 침수예측 행을 보관한다. + const [pendingSelection, setPendingSelection] = + useState(null); + // 팝업 Window 객체는 렌더링 대상이 아니므로 자동 선택 실패 안내용 ref로 보관한다. + const pendingPopupRef = useRef(null); + // 행을 다시 선택할 때 기존 오버레이를 제거할 수 있도록 OpenLayers 객체를 ref로 보관한다. + const overlayRef = useRef(null); + + /** 기존 침수예측 중앙 오버레이를 제거한다. */ + const removeFloodPredictionOverlay = useCallback(() => { + const core = GisApp.Module.core as unknown as + | { deleteLayer?: (layerId: string) => void } + | undefined; + core?.deleteLayer?.(FLOOD_PREDICTION_GRID_LAYER_ID); + const map = mapRef.current; + if (map && overlayRef.current) { + map.removeOverlay(overlayRef.current); + } + overlayRef.current = null; + }, [mapRef]); + + /** 선택행의 시군구·분석시각으로 도시침수지도 중앙 오버레이를 표시한다. */ + const loadFloodPredictionOverlay = useCallback( + async (selection: FloodPredictionSelection) => { + const { sggCd, analysisDateTime, extent } = selection; + const map = mapRef.current; + if (!map || !/^\d{12}$/.test(analysisDateTime)) return; + removeFloodPredictionOverlay(); + + try { + const response = await getRawJson( + `/sewerage-service/api/v1/fldng/predc/prst/gridMap?sggCd=${encodeURIComponent(sggCd)}&analysisDateTime=${encodeURIComponent(analysisDateTime)}`, + ); + const payload = response?.data ?? response; + const row = + payload && typeof payload === "object" && !Array.isArray(payload) + ? (payload as FloodPredictionOverlayRow) + : null; + if (!row) return; + + const center = [ + (extent[0] + extent[2]) / 2, + (extent[1] + extent[3]) / 2, + ]; + const overlay = new Overlay({ + element: createFloodPredictionOverlayElement( + row, + analysisDateTime, + removeFloodPredictionOverlay, + ), + positioning: "center-center", + stopEvent: true, + }); + overlay.set("id", FLOOD_PREDICTION_GRID_LAYER_ID); + overlay.setPosition(center); + map.addOverlay(overlay); + overlayRef.current = overlay; + } catch (error) { + console.error("침수예측지도 오버레이 표시 실패:", error); + } + }, + [mapRef, removeFloodPredictionOverlay], + ); + + /** 활성화된 도시침수지도에서 선택 행의 시군구·빈도 피처를 선택하고 지도와 오버레이를 이동한다. */ + const applyFloodPredictionSelection = useCallback( + async (selection: FloodPredictionSelection) => { + const map = mapRef.current; + const select = GisApp.Module.select as unknown as + | FloodSelectionModule + | undefined; + const selectFocusedBoundaryFeature = + select?.selectFocusedBoundaryFeature; + if (!map || !selectFocusedBoundaryFeature) return false; + + const floodLayers = map + .getLayers() + .getArray() + .filter( + (layer) => + String(layer.get?.("lyrTyNm") ?? "").trim() === floodLayerType && + layer.getVisible?.() !== false, + ); + if (floodLayers.length === 0) return false; + + await select.resetSelectedFeature?.(); + const selectedFeatureIds = new Set(); + /** 현재 로드된 동일 시군구·빈도 피처를 중복 없이 선택한다. */ + const selectLoadedGroupFeatures = (source: FloodPredictionSource) => { + const features = source?.getFeatures?.() ?? []; + features.forEach((feature) => { + const featureSggCd = String(feature.get?.("sgg_cd") ?? "").trim(); + const featureFrequency = String( + feature.get?.("sbmrg_frq_expln") ?? "", + ).trim(); + if ( + featureSggCd !== selection.sggCd || + featureFrequency !== selection.frequency + ) { + return; + } + + const featureId = String( + feature.getId?.() ?? feature.get?.("fid") ?? "", + ); + if (featureId && selectedFeatureIds.has(featureId)) return; + if (featureId) selectedFeatureIds.add(featureId); + selectFocusedBoundaryFeature(feature, true); + }); + }; + + floodLayers.forEach((layer) => { + const source = layer.getSource?.(); + if (!source?.getFeatures) return; + selectLoadedGroupFeatures(source); + source.once?.("featuresloadend", () => + selectLoadedGroupFeatures(source), + ); + }); + map.getView().fit(selection.extent, { + duration: 300, + padding: [50, 50, 50, 50], + maxZoom: 16, + }); + void loadFloodPredictionOverlay(selection); + return true; + }, + [floodLayerType, loadFloodPredictionOverlay, mapRef], + ); + + /** 침수예측현황 행 선택 메시지를 검증하고, 필요하면 도시침수지도 레이어 활성화를 요청한다. */ + const handleFloodPredictionPopupSelection = useCallback( + async (payload: unknown, popupContext: FloodPredictionPopupContext) => { + const selection = parseFloodPredictionSelection(payload); + if (!selection) return; + if (await applyFloodPredictionSelection(selection)) return; + + pendingPopupRef.current = popupContext; + setPendingSelection(selection); + onRequestFloodLayer(); + }, + [applyFloodPredictionSelection, onRequestFloodLayer], + ); + + useEffect(() => { + if (!pendingSelection) return; + + let retryTimer: number | null = null; + let retryCount = 0; + + /** 자동 체크 요청으로 도시침수지도 레이어가 생성될 때까지 선택 행 적용을 제한적으로 재시도한다. */ + const applyPendingSelection = async () => { + if (await applyFloodPredictionSelection(pendingSelection)) { + setPendingSelection((current) => + current === pendingSelection ? null : current, + ); + pendingPopupRef.current = null; + return; + } + + retryCount += 1; + if (retryCount >= FLOOD_PREDICTION_LAYER_RETRY_LIMIT) { + const popup = pendingPopupRef.current; + popup?.source?.postMessage( + { type: WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_FLOOD_LAYER_REQUIRED }, + popup.origin, + ); + pendingPopupRef.current = null; + setPendingSelection((current) => + current === pendingSelection ? null : current, + ); + return; + } + + retryTimer = window.setTimeout(() => { + void applyPendingSelection(); + }, FLOOD_PREDICTION_LAYER_RETRY_DELAY); + }; + + void applyPendingSelection(); + return () => { + if (retryTimer !== null) window.clearTimeout(retryTimer); + }; + }, [applyFloodPredictionSelection, pendingSelection]); + + return { handleFloodPredictionPopupSelection }; +}; diff --git a/apps/sn/src/features/fldngPredcPrst/model/analysisTimeRange.ts b/apps/sn/src/features/fldng-predc-prst/model/analysis-time-range.ts similarity index 100% rename from apps/sn/src/features/fldngPredcPrst/model/analysisTimeRange.ts rename to apps/sn/src/features/fldng-predc-prst/model/analysis-time-range.ts diff --git a/apps/sn/src/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent.tsx b/apps/sn/src/features/fldng-predc-prst/ui/fldng-predc-prst-popup-content.tsx similarity index 92% rename from apps/sn/src/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent.tsx rename to apps/sn/src/features/fldng-predc-prst/ui/fldng-predc-prst-popup-content.tsx index 67249e17f..409b0696d 100644 --- a/apps/sn/src/features/fldngPredcPrst/ui/FldngPredcPrstPopupContent.tsx +++ b/apps/sn/src/features/fldng-predc-prst/ui/fldng-predc-prst-popup-content.tsx @@ -8,18 +8,19 @@ import { } from "@common/business_components"; import { Button } from "@common/components"; -import { getData, getRawJson } from "@/components/lib/api"; +import { getData, getRawJson } from "@/shared/api/sewerage-api"; import { appendApiSearchParams } from "@/shared/util/util"; -import LoadingOverlay from "@/components/ui/LoadingBar"; -import { windowPopupDefinitions } from "@/features/windowPopup/model/popupDefinitions"; -import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types"; -import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable"; -import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel"; +import LoadingOverlay from "@/components/ui/loading-bar"; +import { windowPopupDefinitions } from "@/features/window-popup/model/popup-definitions"; +import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types"; +import MapPopupDataTable from "@/features/window-popup/ui/map-popup-data-table"; +import PopupSearchPanel from "@/features/window-popup/ui/popup-search-panel"; import { useAlertStore } from "@/shared/lib"; -import useStore from "@/store/useStore"; -import { createPredictionAnalysisTimeRange } from "../model/analysisTimeRange"; +import useStore from "@/entities/reference-data/model/reference-data-store"; +import { createPredictionAnalysisTimeRange } from "../model/analysis-time-range"; import { fetchAllPages } from "@/shared/lib/fetch-all-pages"; import { downloadFloodPredictionStatusExcel } from "../lib/fldng-predc-prst-excel"; +import { getFloodRiskGradeClassName } from "../lib/flood-risk-grade"; type ApiRow = { sggCd: string; @@ -59,9 +60,7 @@ type Filters = { const PAGE_SIZE = 10; const EXCEL_DOWNLOAD_PAGE_SIZE = 500; const MAP_FREQUENCIES = new Set([30, 50, 80, 100]); -const FREQUENCY_OPTIONS = [ - 5, 10, 20, 30, 50, 80, 100 -] as const; +const FREQUENCY_OPTIONS = [5, 10, 20, 30, 50, 80, 100] as const; /** 도시침수지도 DB의 3자리 빈도 코드로 변환한다. 30년 이하는 030, 100년 초과는 100으로 제한한다. */ const toFloodMapFrequencyCode = (value: number) => { @@ -111,21 +110,16 @@ const HEADS: UiHeadProps[] = [ { id: "floodRiskGrade", title: "침수위험도", widthPercent: 10 }, ]; -/** 최초·초기화 조회조건은 금일 분석결과를 기준으로 생성한다. */ -const buildInitialFilters = (): Filters => { - const range = createPredictionAnalysisTimeRange(); - const toDateTimeLocal = (value: string) => - `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}T${value.slice(8, 10)}:${value.slice(10, 12)}`; - return { - ctpvCd: "0", - sggCd: "0", - rainBgngFrom: "", - rainBgngTo: "", - analysisFrom: toDateTimeLocal(range.analysisFrom), - analysisTo: toDateTimeLocal(range.analysisTo), - frequencyYear: "0", - }; -}; +/** 최초·초기화 시 시각 범위를 제한하지 않고 최신 침수예측 데이터부터 조회한다. */ +const buildInitialFilters = (): Filters => ({ + ctpvCd: "0", + sggCd: "0", + rainBgngFrom: "", + rainBgngTo: "", + analysisFrom: "", + analysisTo: "", + frequencyYear: "0", +}); const apiDate = (value: string) => value.replace(/[^0-9]/g, "").slice(0, 12); @@ -151,6 +145,7 @@ const buildQuery = (filters: Filters, page?: number, size = PAGE_SIZE) => { if (page !== undefined) { params.set("page", String(page - 1)); params.set("size", String(size)); + params.set("sort", "analysisDateTime,desc"); } appendApiSearchParams(params, { ctpvCd: filters.ctpvCd === "0" ? "0" : filters.ctpvCd.slice(0, 2), @@ -194,23 +189,6 @@ const validateFilters = (filters: Filters) => { const buildRowId = (row: ApiRow) => `${row.ctpvCd}:${row.sggCd}:${row.rainBeginningDateTime}:${row.analysisDateTime}:${row.predictionHour}`; -/** 침수위험도 등급을 목록 배지의 고정 의미색으로 변환한다. */ -export const getFloodRiskGradeClassName = (value: string) => { - const grade = String(value ?? "") - .trim() - .toUpperCase(); - if (grade.includes("위험") || grade === "DANGER") { - return "bg-[#dc2626] text-white"; - } - if (grade.includes("경계") || grade === "WARNING") { - return "bg-[#f97316] text-white"; - } - if (grade.includes("주의") || grade === "CAUTION") { - return "bg-[#facc15] text-[#713f12]"; - } - return "sn-popup-badge-normal"; -}; - export default function FldngPredcPrstPopupContent() { const alert = useAlertStore((state) => state.setMessage); const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore(); diff --git a/apps/sn/src/features/gisEdit/index.ts b/apps/sn/src/features/gis-edit/index.ts similarity index 100% rename from apps/sn/src/features/gisEdit/index.ts rename to apps/sn/src/features/gis-edit/index.ts diff --git a/apps/sn/src/features/gisEdit/lib/edit-model.ts b/apps/sn/src/features/gis-edit/lib/edit-model.ts similarity index 100% rename from apps/sn/src/features/gisEdit/lib/edit-model.ts rename to apps/sn/src/features/gis-edit/lib/edit-model.ts diff --git a/apps/sn/src/features/gisEdit/lib/feature-snapshot.ts b/apps/sn/src/features/gis-edit/lib/feature-snapshot.ts similarity index 100% rename from apps/sn/src/features/gisEdit/lib/feature-snapshot.ts rename to apps/sn/src/features/gis-edit/lib/feature-snapshot.ts diff --git a/apps/sn/src/features/gisEdit/lib/geometry-mutations.ts b/apps/sn/src/features/gis-edit/lib/geometry-mutations.ts similarity index 100% rename from apps/sn/src/features/gisEdit/lib/geometry-mutations.ts rename to apps/sn/src/features/gis-edit/lib/geometry-mutations.ts diff --git a/apps/sn/src/features/gisEdit/lib/preview-styles.ts b/apps/sn/src/features/gis-edit/lib/preview-styles.ts similarity index 100% rename from apps/sn/src/features/gisEdit/lib/preview-styles.ts rename to apps/sn/src/features/gis-edit/lib/preview-styles.ts diff --git a/apps/sn/src/features/gisEdit/lib/vertex-handles.ts b/apps/sn/src/features/gis-edit/lib/vertex-handles.ts similarity index 100% rename from apps/sn/src/features/gisEdit/lib/vertex-handles.ts rename to apps/sn/src/features/gis-edit/lib/vertex-handles.ts diff --git a/apps/sn/src/features/gisEdit/model/types.ts b/apps/sn/src/features/gis-edit/model/types.ts similarity index 100% rename from apps/sn/src/features/gisEdit/model/types.ts rename to apps/sn/src/features/gis-edit/model/types.ts diff --git a/apps/sn/src/features/gisEdit/model/vertex-handles.ts b/apps/sn/src/features/gis-edit/model/vertex-handles.ts similarity index 100% rename from apps/sn/src/features/gisEdit/model/vertex-handles.ts rename to apps/sn/src/features/gis-edit/model/vertex-handles.ts diff --git a/apps/sn/src/features/gisManagement/api/gis-download.ts b/apps/sn/src/features/gis-management/api/gis-download.ts similarity index 100% rename from apps/sn/src/features/gisManagement/api/gis-download.ts rename to apps/sn/src/features/gis-management/api/gis-download.ts diff --git a/apps/sn/src/features/gisManagement/index.ts b/apps/sn/src/features/gis-management/index.ts similarity index 100% rename from apps/sn/src/features/gisManagement/index.ts rename to apps/sn/src/features/gis-management/index.ts diff --git a/apps/sn/src/features/gisManagement/lib/popup-refresh.ts b/apps/sn/src/features/gis-management/lib/popup-refresh.ts similarity index 88% rename from apps/sn/src/features/gisManagement/lib/popup-refresh.ts rename to apps/sn/src/features/gis-management/lib/popup-refresh.ts index 24640a63c..36a075707 100644 --- a/apps/sn/src/features/gisManagement/lib/popup-refresh.ts +++ b/apps/sn/src/features/gis-management/lib/popup-refresh.ts @@ -1,4 +1,4 @@ -import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types"; +import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types"; import type { GisUploadResponse } from "../model/upload-types"; /** 성공한 GIS 업로드의 기존 refresh 메시지를 opener에 전달한다. */ export const postRefreshMessageToOpener = (response: GisUploadResponse) => { diff --git a/apps/sn/src/features/gisManagement/lib/upload-display.ts b/apps/sn/src/features/gis-management/lib/upload-display.ts similarity index 100% rename from apps/sn/src/features/gisManagement/lib/upload-display.ts rename to apps/sn/src/features/gis-management/lib/upload-display.ts diff --git a/apps/sn/src/features/gisManagement/model/upload-retry-guard.ts b/apps/sn/src/features/gis-management/model/upload-retry-guard.ts similarity index 100% rename from apps/sn/src/features/gisManagement/model/upload-retry-guard.ts rename to apps/sn/src/features/gis-management/model/upload-retry-guard.ts diff --git a/apps/sn/src/features/gisManagement/model/upload-types.ts b/apps/sn/src/features/gis-management/model/upload-types.ts similarity index 100% rename from apps/sn/src/features/gisManagement/model/upload-types.ts rename to apps/sn/src/features/gis-management/model/upload-types.ts diff --git a/apps/sn/src/features/mapLayers/index.ts b/apps/sn/src/features/map-layers/index.ts similarity index 100% rename from apps/sn/src/features/mapLayers/index.ts rename to apps/sn/src/features/map-layers/index.ts diff --git a/apps/sn/src/features/mapLayers/lib/flow-direction-listener.ts b/apps/sn/src/features/map-layers/lib/flow-direction-listener.ts similarity index 100% rename from apps/sn/src/features/mapLayers/lib/flow-direction-listener.ts rename to apps/sn/src/features/map-layers/lib/flow-direction-listener.ts diff --git a/apps/sn/src/features/map-layers/lib/flow-direction-selection.ts b/apps/sn/src/features/map-layers/lib/flow-direction-selection.ts new file mode 100644 index 000000000..3d718d83e --- /dev/null +++ b/apps/sn/src/features/map-layers/lib/flow-direction-selection.ts @@ -0,0 +1,78 @@ +import { isAgedPipeLayerNode } from "@/entities/facility/lib/aged-pipe-utils"; +import { + FLOW_DIRECTION_DEFAULT_COLOR, + type FlowDirectionUsageStyle, + withAlpha, +} from "./flow-direction-styles"; +import { + getLayerTypeName, + isFlowDirectionLayerInfo, + getFiniteNumber, +} from "./layer-info"; +import { FLOW_DIRECTION_PIPE_LAYER_TYPE } from "../model/flow-direction-runtime"; + +/** 지도 체크 노드에서 유하방향 관종/스타일을 선택하는 순수 변환. */ +// 레이어 설정의 직접 코드와 filterSql 안의 usg_cd 조건을 모두 관종 코드 후보로 해석한다. +export const extractUsageCodesFromLayerInfo = (layerInfo: any) => { + const usageCodes = new Set(); + const directCode = layerInfo?.usgCd ?? layerInfo?.usg_cd; + if (directCode) usageCodes.add(String(directCode).trim()); + + const filterSql = String(layerInfo?.flterSql ?? layerInfo?.filterSql ?? ""); + const equalsMatches = filterSql.matchAll(/usg_cd\s*=\s*'([^']+)'/gi); + for (const match of equalsMatches) { + const code = match[1]?.trim(); + if (code) usageCodes.add(code); + } + + const inMatches = filterSql.matchAll(/usg_cd\s+in\s*\(([^)]+)\)/gi); + for (const match of inMatches) { + const codeList = match[1]; + if (!codeList) continue; + + codeList + .split(",") + .map((item) => item.replace(/'/g, "").trim()) + .filter(Boolean) + .forEach((code) => usageCodes.add(code)); + } + + return Array.from(usageCodes); +}; + +// 현재 줌에서 실제 표출 가능한 관로 레이어인지 확인해 숨겨진 관종의 유하방향 요청을 막는다. +export const isLayerVisibleAtZoom = (layerInfo: any, zoom: number) => { + const minZoom = getFiniteNumber(layerInfo?.minViewSize ?? layerInfo?.minZoom); + return minZoom === null || zoom >= minZoom; +}; + +// 현재 체크되어 화면에 보이는 관로 레이어의 관종 코드와 선 색상을 유하방향 조회/스타일 기준으로 변환한다. +export const extractCheckedPipeUsageStyles = ( + checkedNodesData: any[], + zoom: number, +) => { + const usageStyleMap: Record = {}; + + checkedNodesData.forEach((node) => { + const data = node?.data ?? node; + if (!data || isFlowDirectionLayerInfo(data) || isAgedPipeLayerNode(data)) + return; + if (getLayerTypeName(data) !== FLOW_DIRECTION_PIPE_LAYER_TYPE) return; + if (!isLayerVisibleAtZoom(data, zoom)) return; + + const usageCodes = extractUsageCodesFromLayerInfo(data); + if (usageCodes.length === 0) return; + + const layerColor = String( + data.lnColor ?? data.lineColor ?? FLOW_DIRECTION_DEFAULT_COLOR, + ).trim(); + usageCodes.forEach((code) => { + usageStyleMap[code] = { + color: layerColor, + lineColor: withAlpha(layerColor, 0.28), + }; + }); + }); + + return usageStyleMap; +}; diff --git a/apps/sn/src/features/mapLayers/lib/flow-direction-styles.ts b/apps/sn/src/features/map-layers/lib/flow-direction-styles.ts similarity index 100% rename from apps/sn/src/features/mapLayers/lib/flow-direction-styles.ts rename to apps/sn/src/features/map-layers/lib/flow-direction-styles.ts diff --git a/apps/sn/src/features/map-layers/lib/geojson-response.ts b/apps/sn/src/features/map-layers/lib/geojson-response.ts new file mode 100644 index 000000000..c79883d2b --- /dev/null +++ b/apps/sn/src/features/map-layers/lib/geojson-response.ts @@ -0,0 +1,7 @@ +/** 지도 API의 래퍼/raw GeoJSON 응답을 같은 feature collection으로 읽는다. */ +// BE 응답이 FeatureCollection 그대로 오거나 공통 응답 wrapper의 data 안에 들어오는 두 계약을 모두 처리한다. +export const getFeatureCollection = (payload: any) => { + if (payload?.type === "FeatureCollection") return payload; + if (payload?.data?.type === "FeatureCollection") return payload.data; + return { type: "FeatureCollection", features: [] }; +}; diff --git a/apps/sn/src/features/mapLayers/lib/layer-info.ts b/apps/sn/src/features/map-layers/lib/layer-info.ts similarity index 100% rename from apps/sn/src/features/mapLayers/lib/layer-info.ts rename to apps/sn/src/features/map-layers/lib/layer-info.ts diff --git a/apps/sn/src/features/map-layers/lib/use-flow-direction-layer.ts b/apps/sn/src/features/map-layers/lib/use-flow-direction-layer.ts new file mode 100644 index 000000000..b24bda801 --- /dev/null +++ b/apps/sn/src/features/map-layers/lib/use-flow-direction-layer.ts @@ -0,0 +1,518 @@ +"use client"; + +import { + useCallback, + useEffect, + useRef, + useState, + type MutableRefObject, +} from "react"; +import GeoJSON from "ol/format/GeoJSON.js"; +import VectorLayer from "ol/layer/Vector.js"; +import VectorSource from "ol/source/Vector.js"; +import { unByKey } from "ol/Observable.js"; +import GisApp from "@common/business_components/ui/map/lib/gis.core"; +import { getRawJson } from "@/shared/api/sewerage-api"; +import { + FLOW_DIRECTION_LAYER_TYPE, + FLOW_DIRECTION_LAYER_NAME, + getFlowDirectionMinZoom, + getFlowDirectionZIndex, + expandFlowDirectionExtent, + formatFlowDirectionBbox, + getFlowDirectionFeatureCacheKey, + getFlowDirectionModeByPathname, +} from "./layer-info"; +import { subscribeFlowDirectionMoveEnd } from "./flow-direction-listener"; +import { + createFlowDirectionStyle, + type FlowDirectionUsageStyle, +} from "./flow-direction-styles"; +import { FLOW_DIRECTION_SRS } from "../model/flow-direction-runtime"; +import { getFeatureCollection } from "./geojson-response"; +import { extractCheckedPipeUsageStyles } from "./flow-direction-selection"; +import { isFlowDirectionLayerInfo } from "./layer-info"; + +type FlowDirectionOptions = { + gisMaps: MutableRefObject; + checkedNodesDataRef: MutableRefObject; + mapReady: boolean; + flowDirectionMode: ReturnType; +}; + +/** 유하방향의 요청·캐시·레이어·이벤트 수명을 소유한다. 외부에는 선택 명령과 표시 상태만 제공한다. */ +export function useFlowDirectionLayer({ + gisMaps, + checkedNodesDataRef, + mapReady, + flowDirectionMode, +}: FlowDirectionOptions) { + // 유하방향 레이어 체크 여부를 React 렌더링 흐름에 반영하기 위한 state이다. + const [isFlowDirectionChecked, setIsFlowDirectionChecked] = useState(false); + + // 체크된 유하방향 트리 노드의 레이어 설정을 보관해 API 조회와 vector layer 스타일에 사용한다. + const [flowDirectionLayerInfo, setFlowDirectionLayerInfo] = useState< + any | null + >(null); + + // 관종 레이어 체크 변경도 유하방향 재조회 조건이므로 별도 버전 state로 effect를 다시 실행한다. + const [flowDirectionSelectionVersion, setFlowDirectionSelectionVersion] = + useState(0); + + // moveend 이벤트 핸들러는 최신 state closure를 보장하지 않으므로 현재 체크 여부를 ref로 함께 보관한다. + const flowDirectionCheckedRef = useRef(false); + + // 지도 이동 후 재조회할 때 최신 유하방향 레이어 설정을 참조하기 위한 ref이다. + const flowDirectionLayerInfoRef = useRef(null); + + // 유하방향 전용 vector layer/source를 유지해 지도 이동 시 기존 피처를 지우지 않고 새 BBOX 응답만 병합한다. + const flowDirectionLayerRef = useRef(null); + + const flowDirectionSourceRef = useRef(null); + + // 같은 관로가 겹치는 BBOX에서 반복 조회될 수 있으므로 고유키와 삽입 순서를 캐시로 관리한다. + const flowDirectionFeatureKeySetRef = useRef>(new Set()); + + const flowDirectionFeatureKeyQueueRef = useRef([]); + + // 관종 체크 변경 시 다른 관종 유하방향을 유지하기 위해 현재 source에 남겨둘 관종 코드를 별도로 추적한다. + const flowDirectionVisibleUsageCodesRef = useRef>(new Set()); + + // 관종 체크 변경으로 새로 켜진 usgCd만 다음 조회 대상으로 넘겨 기존 관종 재요청을 막는다. + const flowDirectionSelectionRequestUsageCodesRef = useRef( + null, + ); + + // 체크 해제나 재조회 중 늦게 도착한 이전 API 응답이 레이어를 다시 그리지 못하도록 요청 순번을 관리한다. + const flowDirectionRequestSeqRef = useRef(0); + + // 지도 이동이 연속으로 발생하면 이전 유하방향 요청을 중단해 늦은 응답과 불필요한 네트워크 대기를 줄인다. + const flowDirectionAbortControllerRef = useRef(null); + + // 같은 장애 상황에서 alert가 반복 노출되지 않도록 실패 안내 여부를 기억한다. + const flowDirectionFailureAlertedRef = useRef(false); + + // 유하방향은 일반 Gis core 레이어 등록 흐름을 타지 않으므로 전용 layer/source/cache를 직접 정리한다. + const removeFlowDirectionLayer = useCallback(() => { + const core = GisApp.Module.core as any; + core?.deleteLayer?.(FLOW_DIRECTION_LAYER_TYPE); + flowDirectionLayerRef.current = null; + flowDirectionSourceRef.current = null; + flowDirectionFeatureKeySetRef.current.clear(); + flowDirectionFeatureKeyQueueRef.current = []; + flowDirectionVisibleUsageCodesRef.current.clear(); + }, []); + + // 유하방향 cache/source만 비워 관종 변경처럼 조건이 달라진 경우 이전 조건의 피처가 남지 않게 한다. + const clearFlowDirectionFeatureCache = useCallback(() => { + flowDirectionSourceRef.current?.clear(); + flowDirectionFeatureKeySetRef.current.clear(); + flowDirectionFeatureKeyQueueRef.current = []; + flowDirectionVisibleUsageCodesRef.current.clear(); + }, []); + + // 체크 해제된 관종의 유하방향 feature/cache만 제거하고, 새로 체크된 관종 코드만 다음 요청 대상으로 돌려준다. + const retainFlowDirectionUsageCodes = useCallback((usageCodes: string[]) => { + const nextUsageCodes = new Set(usageCodes); + const addedUsageCodes = usageCodes.filter( + (usageCode) => !flowDirectionVisibleUsageCodesRef.current.has(usageCode), + ); + const source = flowDirectionSourceRef.current; + if (source) { + source.getFeatures().forEach((feature: any) => { + const usageCode = String( + feature.get("usgCd") ?? feature.get("usg_cd") ?? "", + ).trim(); + if (nextUsageCodes.has(usageCode)) return; + + const featureKey = feature.get("flowDirectionCacheKey"); + if (featureKey) { + flowDirectionFeatureKeySetRef.current.delete(String(featureKey)); + } + source.removeFeature(feature); + }); + flowDirectionFeatureKeyQueueRef.current = + flowDirectionFeatureKeyQueueRef.current.filter((featureKey) => + flowDirectionFeatureKeySetRef.current.has(featureKey), + ); + } + flowDirectionVisibleUsageCodesRef.current = new Set( + Array.from(flowDirectionVisibleUsageCodesRef.current).filter( + (usageCode) => nextUsageCodes.has(usageCode), + ), + ); + return addedUsageCodes; + }, []); + + // 유하방향 레이어는 하나만 유지하고 source에 피처를 누적한다. + const ensureFlowDirectionLayer = useCallback((layerInfo: any) => { + const map = gisMaps.current; + if (!map) return null; + + if (flowDirectionLayerRef.current && flowDirectionSourceRef.current) { + flowDirectionLayerRef.current.setMinZoom?.( + getFlowDirectionMinZoom(layerInfo), + ); + flowDirectionLayerRef.current.setZIndex?.( + getFlowDirectionZIndex(layerInfo), + ); + return flowDirectionSourceRef.current; + } + + const source = new VectorSource(); + const layer = new VectorLayer({ + source, + minZoom: getFlowDirectionMinZoom(layerInfo), + zIndex: getFlowDirectionZIndex(layerInfo), + updateWhileAnimating: false, + updateWhileInteracting: false, + }); + + layer.set("id", FLOW_DIRECTION_LAYER_TYPE); + layer.set("lyrNm", FLOW_DIRECTION_LAYER_NAME); + layer.set("lyrTyNm", FLOW_DIRECTION_LAYER_TYPE); + layer.set("typeName", FLOW_DIRECTION_LAYER_TYPE); + layer.set("clickPsblYn", "N"); + layer.set("chcYn", "N"); + + const core = GisApp.Module.core as any; + core?.deleteLayer?.(FLOW_DIRECTION_LAYER_TYPE); + map.addLayer(layer); + flowDirectionLayerRef.current = layer; + flowDirectionSourceRef.current = source; + return source; + }, []); + + // BE에서 받은 보정 GeoJSON을 OpenLayers feature로 변환해 기존 유하방향 source에 중복 없이 병합한다. + const renderFlowDirectionLayer = useCallback( + ( + featureCollection: any, + layerInfo: any, + usageStyleMap: Record, + ) => { + const source = ensureFlowDirectionLayer(layerInfo); + if (!source) return; + + const features = new GeoJSON().readFeatures(featureCollection, { + dataProjection: FLOW_DIRECTION_SRS, + featureProjection: GisApp.TO_PROJ, + }); + + const nextFeatures = features.filter((feature) => { + const featureKey = getFlowDirectionFeatureCacheKey(feature); + if (flowDirectionFeatureKeySetRef.current.has(featureKey)) return false; + + feature.set("flowDirectionCacheKey", featureKey); + feature.setId(featureKey); + feature.setStyle(createFlowDirectionStyle(feature, usageStyleMap)); + flowDirectionFeatureKeySetRef.current.add(featureKey); + flowDirectionFeatureKeyQueueRef.current.push(featureKey); + return true; + }); + + if (nextFeatures.length > 0) { + source.addFeatures(nextFeatures); + } + }, + [ensureFlowDirectionLayer], + ); + + // 현재 지도 BBOX와 체크된 관종별 필터로 유하방향 GeoJSON을 각각 조회하고, 최신 요청만 지도에 반영한다. + const loadFlowDirectionLayer = useCallback( + async (layerInfo: any, requestOnlyUsageCodes?: string[]) => { + const map = gisMaps.current; + if (!map) return; + + const requestSeq = flowDirectionRequestSeqRef.current + 1; + flowDirectionRequestSeqRef.current = requestSeq; + flowDirectionAbortControllerRef.current?.abort(); + const abortController = new AbortController(); + flowDirectionAbortControllerRef.current = abortController; + + const view = map.getView(); + const zoom = view.getZoom() ?? 0; + const minZoom = getFlowDirectionMinZoom(layerInfo); + + if (zoom < minZoom) { + removeFlowDirectionLayer(); + return; + } + + const size = map.getSize(); + if (!size) return; + + const usageStyleMap = extractCheckedPipeUsageStyles( + checkedNodesDataRef.current, + zoom, + ); + const usageCodes = Object.keys(usageStyleMap); + if (usageCodes.length === 0) { + removeFlowDirectionLayer(); + return; + } + retainFlowDirectionUsageCodes(usageCodes); + + const usageCodeSet = new Set(usageCodes); + const requestUsageCodes = requestOnlyUsageCodes + ? requestOnlyUsageCodes.filter((usageCode) => + usageCodeSet.has(usageCode), + ) + : usageCodes; + const isPartialUsageRequest = Array.isArray(requestOnlyUsageCodes); + if (requestUsageCodes.length === 0) { + if (flowDirectionAbortControllerRef.current === abortController) { + flowDirectionAbortControllerRef.current = null; + } + return; + } + + const screenExtent = view.calculateExtent(size); + const requestExtent = expandFlowDirectionExtent(screenExtent); + const screenBbox = formatFlowDirectionBbox(screenExtent); + const requestBbox = formatFlowDirectionBbox(requestExtent); + const baseParams = new URLSearchParams({ + srs: FLOW_DIRECTION_SRS, + directionMode: flowDirectionMode, + searchStrategy: "BBOX", + bbox: requestBbox, + }); + + try { + const settledResponses = await Promise.allSettled( + requestUsageCodes.map(async (usageCode) => { + const params = new URLSearchParams(baseParams); + params.set("usgCd", usageCode); + const payload = await getRawJson( + `/sewerage-service/api/v1/map/flowDirectionGeometry?${params.toString()}`, + { signal: abortController.signal }, + ); + return { + usageCode, + featureCollection: getFeatureCollection(payload), + }; + }), + ); + if (requestSeq !== flowDirectionRequestSeqRef.current) return; + if (abortController.signal.aborted) return; + + const responses = settledResponses + .filter( + ( + result, + ): result is PromiseFulfilledResult<{ + usageCode: string; + featureCollection: any; + }> => result.status === "fulfilled", + ) + .map((result) => result.value); + const failedResponses = settledResponses.filter( + (result) => result.status === "rejected", + ); + + if (responses.length === 0) { + if (!isPartialUsageRequest) { + removeFlowDirectionLayer(); + } + throw ( + failedResponses[0]?.reason ?? + new Error("유하방향 관종별 조회 결과가 없습니다.") + ); + } + + if (failedResponses.length > 0) { + console.error( + "유하방향 일부 관종 조회 실패:", + failedResponses.map((result) => result.reason), + ); + if (!flowDirectionFailureAlertedRef.current) { + flowDirectionFailureAlertedRef.current = true; + } + } + + responses.forEach(({ usageCode, featureCollection }) => { + renderFlowDirectionLayer(featureCollection, layerInfo, usageStyleMap); + flowDirectionVisibleUsageCodesRef.current.add(usageCode); + }); + const returnedFeaturesByUsage = responses.reduce< + Record + >((acc, { usageCode, featureCollection }) => { + acc[usageCode] = featureCollection.features?.length ?? 0; + return acc; + }, {}); + const networkCorrectedFeatures = responses.reduce( + (count, { featureCollection }) => { + const correctedCount = + featureCollection.features?.filter( + (feature: any) => + String( + feature?.properties?.networkCorrectionYn ?? + feature?.properties?.network_correction_yn ?? + "", + ).toUpperCase() === "Y", + ).length ?? 0; + return count + correctedCount; + }, + 0, + ); + (window as any).__SN_FLOW_DIRECTION_DEBUG__ = { + screenBbox, + requestBbox, + requestSeq, + zoom, + directionMode: flowDirectionMode, + searchStrategy: "BBOX", + usageCodes, + requestUsageCodes, + requestCount: responses.length, + returnedFeaturesByUsage, + returnedFeatures: Object.values(returnedFeaturesByUsage).reduce( + (sum, count) => sum + count, + 0, + ), + networkCorrectedFeatures, + cachedFeatures: + flowDirectionSourceRef.current?.getFeatures?.().length ?? 0, + }; + if (failedResponses.length === 0) { + flowDirectionFailureAlertedRef.current = false; + } + } catch (error) { + if (requestSeq !== flowDirectionRequestSeqRef.current) return; + if ( + typeof error === "object" && + error !== null && + "name" in error && + error.name === "AbortError" + ) + return; + + console.error("유하방향 레이어 조회 실패:", error); + if (!isPartialUsageRequest) { + removeFlowDirectionLayer(); + } + if (!flowDirectionFailureAlertedRef.current) { + flowDirectionFailureAlertedRef.current = true; + } + } finally { + if (flowDirectionAbortControllerRef.current === abortController) { + flowDirectionAbortControllerRef.current = null; + } + } + }, + [ + flowDirectionMode, + removeFlowDirectionLayer, + renderFlowDirectionLayer, + retainFlowDirectionUsageCodes, + ], + ); + const updateSelection = useCallback( + (checkedNodesData: any[]) => { + // 유하방향은 일반 setLayerOnMap 대상이 아니므로 체크 노드 여부를 별도로 추적한다. + const flowDirectionNode = checkedNodesData.find((node) => + isFlowDirectionLayerInfo(node?.data ?? node), + ); + const nextFlowDirectionLayerInfo = + flowDirectionNode?.data ?? flowDirectionNode ?? null; + flowDirectionCheckedRef.current = Boolean(nextFlowDirectionLayerInfo); + flowDirectionLayerInfoRef.current = nextFlowDirectionLayerInfo; + setIsFlowDirectionChecked(Boolean(nextFlowDirectionLayerInfo)); + setFlowDirectionLayerInfo(nextFlowDirectionLayerInfo); + if (nextFlowDirectionLayerInfo) { + const map = gisMaps.current; + const zoom = map?.getView?.()?.getZoom?.() ?? 0; + const nextUsageStyleMap = extractCheckedPipeUsageStyles( + checkedNodesData, + zoom, + ); + const requestUsageCodes = retainFlowDirectionUsageCodes( + Object.keys(nextUsageStyleMap), + ); + flowDirectionSelectionRequestUsageCodesRef.current = requestUsageCodes; + if (requestUsageCodes.length === 0) { + flowDirectionRequestSeqRef.current += 1; + flowDirectionAbortControllerRef.current?.abort(); + flowDirectionAbortControllerRef.current = null; + } + setFlowDirectionSelectionVersion((version) => version + 1); + } + + if (!nextFlowDirectionLayerInfo) { + // 해제 직후 pending API 응답이 늦게 도착해도 renderFlowDirectionLayer가 실행되지 않도록 순번을 무효화한다. + flowDirectionSelectionRequestUsageCodesRef.current = null; + flowDirectionRequestSeqRef.current += 1; + flowDirectionAbortControllerRef.current?.abort(); + flowDirectionAbortControllerRef.current = null; + flowDirectionFailureAlertedRef.current = false; + removeFlowDirectionLayer(); + } + }, + [gisMaps, removeFlowDirectionLayer, retainFlowDirectionUsageCodes], + ); + + useEffect(() => { + if (!flowDirectionCheckedRef.current) return; + clearFlowDirectionFeatureCache(); + flowDirectionSelectionRequestUsageCodesRef.current = null; + setFlowDirectionSelectionVersion((version) => version + 1); + }, [clearFlowDirectionFeatureCache, flowDirectionMode]); + + useEffect(() => { + if (!mapReady || !flowDirectionLayerInfo) return; + + // 트리 체크 상태나 관종 레이어 선택이 바뀌면 유하방향 API vector layer를 추가/갱신/제거한다. + if (isFlowDirectionChecked) { + const requestUsageCodes = + flowDirectionSelectionRequestUsageCodesRef.current; + flowDirectionSelectionRequestUsageCodesRef.current = null; + if (requestUsageCodes && requestUsageCodes.length === 0) return; + loadFlowDirectionLayer( + flowDirectionLayerInfo, + requestUsageCodes ?? undefined, + ); + } else { + removeFlowDirectionLayer(); + } + }, [ + flowDirectionLayerInfo, + flowDirectionSelectionVersion, + isFlowDirectionChecked, + loadFlowDirectionLayer, + mapReady, + removeFlowDirectionLayer, + ]); + + useEffect(() => { + if (!mapReady || !gisMaps.current) return; + + // 유하방향이 켜진 상태에서 지도 이동/확대축소가 끝나면 현재 BBOX 기준으로 다시 조회한다. + return subscribeFlowDirectionMoveEnd( + gisMaps.current, + () => ({ + checked: flowDirectionCheckedRef.current, + layerInfo: flowDirectionLayerInfoRef.current, + }), + loadFlowDirectionLayer, + unByKey, + ); + }, [loadFlowDirectionLayer, mapReady]); + + // 화면 이탈 후 지연 응답이 제거된 지도에 레이어를 되살리지 않도록 무효화한다. + useEffect( + () => () => { + flowDirectionRequestSeqRef.current += 1; + flowDirectionAbortControllerRef.current?.abort(); + flowDirectionAbortControllerRef.current = null; + const ownedLayer = flowDirectionLayerRef.current; + if (ownedLayer) gisMaps.current?.removeLayer?.(ownedLayer); + flowDirectionLayerRef.current = null; + flowDirectionSourceRef.current = null; + flowDirectionFeatureKeySetRef.current.clear(); + flowDirectionFeatureKeyQueueRef.current = []; + flowDirectionVisibleUsageCodesRef.current.clear(); + }, + [gisMaps], + ); + + return { isFlowDirectionChecked, flowDirectionLayerInfo, updateSelection }; +} diff --git a/apps/sn/src/features/map-layers/model/flow-direction-runtime.ts b/apps/sn/src/features/map-layers/model/flow-direction-runtime.ts new file mode 100644 index 000000000..671b396e9 --- /dev/null +++ b/apps/sn/src/features/map-layers/model/flow-direction-runtime.ts @@ -0,0 +1,3 @@ +/** 유하방향 원천 레이어와 API 좌표계 계약. 기존 값을 유지한다. */ +export const FLOW_DIRECTION_PIPE_LAYER_TYPE = "tb_ms_swage_ppl"; +export const FLOW_DIRECTION_SRS = "EPSG:5179"; diff --git a/apps/sn/app/store/useReportTabStore.ts b/apps/sn/src/features/map-search/model/report-store.ts similarity index 93% rename from apps/sn/app/store/useReportTabStore.ts rename to apps/sn/src/features/map-search/model/report-store.ts index 584108407..f592a66aa 100644 --- a/apps/sn/app/store/useReportTabStore.ts +++ b/apps/sn/src/features/map-search/model/report-store.ts @@ -13,9 +13,9 @@ * - DC 트리 데이터 조회: GET /dc-service//api/v1/dc/clsf * - DC 파일 목록 조회: GET /dc-service//api/v1/dc/file */ -import { type Folder, type ReportDataFile } from '../(map-layout)/map/searchModal/useReportTab'; +import { type Folder, type ReportDataFile } from "@/features/map-search/model/report-types"; import { create } from 'zustand'; -import { getDcData } from '@/components/lib/api'; +import { getDcData } from "@/shared/api/external-service-api"; interface useReportTabStoreType { isOpen: boolean; diff --git a/apps/sn/src/features/map-search/model/report-types.ts b/apps/sn/src/features/map-search/model/report-types.ts new file mode 100644 index 000000000..36f277e41 --- /dev/null +++ b/apps/sn/src/features/map-search/model/report-types.ts @@ -0,0 +1,25 @@ +/** 지도 보고서 조회에서 공유하는 도메인 타입. 화면 hook을 역참조하지 않는다. */ +export type Folder = { + clsfSystmSn: string; + upClsfSystmSn: string; + clsfSystmNm: string; + children: Folder[]; +}; + +export type FlattenedFolder = { + [key: string]: string | number | undefined; +}; + +export type SelectDataType = { + text: string; + val: string; + sido?: string; // 시도 코드 (선택 사항) +}; + +export type ReportDataFile = { + no: number; + fileTitle: string; + regDt: string; + fileSn: string; + filePath: string; +}; diff --git a/apps/sn/app/store/useSearchModalStore.ts b/apps/sn/src/features/map-search/model/search-store.ts similarity index 100% rename from apps/sn/app/store/useSearchModalStore.ts rename to apps/sn/src/features/map-search/model/search-store.ts diff --git a/apps/sn/src/features/obsltPpl/api/obslt-ppl-api.ts b/apps/sn/src/features/obslt-ppl/api/obslt-ppl-api.ts similarity index 96% rename from apps/sn/src/features/obsltPpl/api/obslt-ppl-api.ts rename to apps/sn/src/features/obslt-ppl/api/obslt-ppl-api.ts index 976665e7f..41c689505 100644 --- a/apps/sn/src/features/obsltPpl/api/obslt-ppl-api.ts +++ b/apps/sn/src/features/obslt-ppl/api/obslt-ppl-api.ts @@ -5,12 +5,7 @@ import type { ObsltPplPage, } from "../model/obslt-ppl-types"; -import { - deleteData, - getData, - postRawJson, - putData, -} from "@/components/lib/api"; +import { deleteData, getData, postRawJson, putData } from "@/shared/api/sewerage-api"; import { fetchAllPages } from "@/shared/lib/fetch-all-pages"; const API_PATH = "v1/obsltPplBiz"; diff --git a/apps/sn/src/features/obsltPpl/lib/obslt-ppl-excel.ts b/apps/sn/src/features/obslt-ppl/lib/obslt-ppl-excel.ts similarity index 100% rename from apps/sn/src/features/obsltPpl/lib/obslt-ppl-excel.ts rename to apps/sn/src/features/obslt-ppl/lib/obslt-ppl-excel.ts diff --git a/apps/sn/src/features/obsltPpl/lib/obslt-ppl-region.ts b/apps/sn/src/features/obslt-ppl/lib/obslt-ppl-region.ts similarity index 100% rename from apps/sn/src/features/obsltPpl/lib/obslt-ppl-region.ts rename to apps/sn/src/features/obslt-ppl/lib/obslt-ppl-region.ts diff --git a/apps/sn/src/features/obsltPpl/model/obslt-ppl-types.ts b/apps/sn/src/features/obslt-ppl/model/obslt-ppl-types.ts similarity index 100% rename from apps/sn/src/features/obsltPpl/model/obslt-ppl-types.ts rename to apps/sn/src/features/obslt-ppl/model/obslt-ppl-types.ts diff --git a/apps/sn/src/features/obsltPpl/ui/ObsltPplFormPage.tsx b/apps/sn/src/features/obslt-ppl/ui/obslt-ppl-form-page.tsx similarity index 98% rename from apps/sn/src/features/obsltPpl/ui/ObsltPplFormPage.tsx rename to apps/sn/src/features/obslt-ppl/ui/obslt-ppl-form-page.tsx index 5ba43063e..62410b8de 100644 --- a/apps/sn/src/features/obsltPpl/ui/ObsltPplFormPage.tsx +++ b/apps/sn/src/features/obslt-ppl/ui/obslt-ppl-form-page.tsx @@ -41,10 +41,10 @@ import type { ObsltPplBusinessSaveRequest, } from "../model/obslt-ppl-types"; -import LoadingOverlay from "@/components/ui/LoadingBar"; +import LoadingOverlay from "@/components/ui/loading-bar"; import { useAlertStore } from "@/shared/lib"; -import { useOperBusinessCommonCodes } from "@/shared/lib/use-oper-business-common-codes"; -import useStore from "@/store/useStore"; +import { useOperBusinessCommonCodes } from "@/entities/business/lib/use-oper-business-common-codes"; +import useStore from "@/entities/reference-data/model/reference-data-store"; type FormKey = keyof ObsltPplBusinessSaveRequest; type FormState = Record; diff --git a/apps/sn/src/features/obsltPpl/ui/ObsltPplListPage.tsx b/apps/sn/src/features/obslt-ppl/ui/obslt-ppl-list-page.tsx similarity index 98% rename from apps/sn/src/features/obsltPpl/ui/ObsltPplListPage.tsx rename to apps/sn/src/features/obslt-ppl/ui/obslt-ppl-list-page.tsx index 0955abf1e..d9693eee4 100644 --- a/apps/sn/src/features/obsltPpl/ui/ObsltPplListPage.tsx +++ b/apps/sn/src/features/obslt-ppl/ui/obslt-ppl-list-page.tsx @@ -45,11 +45,11 @@ import { } from "../lib/obslt-ppl-region"; import type { ObsltPplBusiness, ObsltPplPage } from "../model/obslt-ppl-types"; -import LoadingOverlay from "@/components/ui/LoadingBar"; -import { useOperBusinessCommonCodes } from "@/shared/lib/use-oper-business-common-codes"; -import useStore from "@/store/useStore"; -import { openWindowPopup } from "../../windowPopup/lib/openWindowPopup"; -import { WINDOW_POPUP_MESSAGE_TYPE } from "../../windowPopup/model/types"; +import LoadingOverlay from "@/components/ui/loading-bar"; +import { useOperBusinessCommonCodes } from "@/entities/business/lib/use-oper-business-common-codes"; +import useStore from "@/entities/reference-data/model/reference-data-store"; +import { openWindowPopup } from "../../window-popup/lib/open-window-popup"; +import { WINDOW_POPUP_MESSAGE_TYPE } from "../../window-popup/model/types"; const DEFAULT_LIST_SIZE = 10; const ALL_REGION_OPTION = { text: "전체", val: "0" }; diff --git a/apps/sn/src/features/windowPopup/index.ts b/apps/sn/src/features/window-popup/index.ts similarity index 77% rename from apps/sn/src/features/windowPopup/index.ts rename to apps/sn/src/features/window-popup/index.ts index 8424dcc1d..2641a6fe4 100644 --- a/apps/sn/src/features/windowPopup/index.ts +++ b/apps/sn/src/features/window-popup/index.ts @@ -1,9 +1,9 @@ -export { openWindowPopup } from "./lib/openWindowPopup"; +export { openWindowPopup } from "./lib/open-window-popup"; export { isWindowPopupKey, windowPopupDefinitions, type WindowPopupKey, -} from "./model/popupDefinitions"; +} from "./model/popup-definitions"; export { windowPopupRegistry } from "./registry"; export type { OpenWindowPopupParams, diff --git a/apps/sn/src/features/windowPopup/lib/openWindowPopup.ts b/apps/sn/src/features/window-popup/lib/open-window-popup.ts similarity index 98% rename from apps/sn/src/features/windowPopup/lib/openWindowPopup.ts rename to apps/sn/src/features/window-popup/lib/open-window-popup.ts index 30f07d153..2a120ab69 100644 --- a/apps/sn/src/features/windowPopup/lib/openWindowPopup.ts +++ b/apps/sn/src/features/window-popup/lib/open-window-popup.ts @@ -4,11 +4,11 @@ import { isWindowPopupKey, windowPopupDefinitions, type WindowPopupKey, -} from "../model/popupDefinitions"; +} from "../model/popup-definitions"; import { resolveWindowPopupTheme, WINDOW_POPUP_THEME_PARAM, -} from "../model/popupTheme"; +} from "../model/popup-theme"; import type { OpenWindowPopupOptions, OpenWindowPopupParams, diff --git a/apps/sn/src/features/windowPopup/lib/popupMessage.ts b/apps/sn/src/features/window-popup/lib/popup-message.ts similarity index 100% rename from apps/sn/src/features/windowPopup/lib/popupMessage.ts rename to apps/sn/src/features/window-popup/lib/popup-message.ts diff --git a/apps/sn/src/features/windowPopup/model/popupDefinitions.ts b/apps/sn/src/features/window-popup/model/popup-definitions.ts similarity index 100% rename from apps/sn/src/features/windowPopup/model/popupDefinitions.ts rename to apps/sn/src/features/window-popup/model/popup-definitions.ts diff --git a/apps/sn/src/features/windowPopup/model/popupTheme.ts b/apps/sn/src/features/window-popup/model/popup-theme.ts similarity index 100% rename from apps/sn/src/features/windowPopup/model/popupTheme.ts rename to apps/sn/src/features/window-popup/model/popup-theme.ts diff --git a/apps/sn/src/features/windowPopup/model/types.ts b/apps/sn/src/features/window-popup/model/types.ts similarity index 100% rename from apps/sn/src/features/windowPopup/model/types.ts rename to apps/sn/src/features/window-popup/model/types.ts diff --git a/apps/sn/src/features/windowPopup/registry.tsx b/apps/sn/src/features/window-popup/registry.tsx similarity index 76% rename from apps/sn/src/features/windowPopup/registry.tsx rename to apps/sn/src/features/window-popup/registry.tsx index 733048e31..dd1ff37e7 100644 --- a/apps/sn/src/features/windowPopup/registry.tsx +++ b/apps/sn/src/features/window-popup/registry.tsx @@ -1,18 +1,18 @@ "use client"; -import LoadingOverlay from "@/components/ui/LoadingBar"; +import LoadingOverlay from "@/components/ui/loading-bar"; import dynamic from "next/dynamic"; import type { ComponentType } from "react"; import { windowPopupDefinitions, type WindowPopupKey, -} from "./model/popupDefinitions"; +} from "./model/popup-definitions"; import type { WindowPopupContentProps, WindowPopupRegistryItem, } from "./model/types"; -import FeaturePlaceholderPopupContent from "./ui/FeaturePlaceholderPopupContent"; +import FeaturePlaceholderPopupContent from "./ui/feature-placeholder-popup-content"; type WindowPopupModule = { default: ComponentType; @@ -31,100 +31,100 @@ const loadPopupContent = (loader: () => Promise) => }); const CfmtnBgtPopupContent = loadPopupContent( - () => import("../cfmtnBgt/ui/CfmtnBgtPopupContent"), + () => import("../cfmtn-bgt/ui/cfmtn-bgt-popup-content"), ); const AgedPipePopupContent = loadPopupContent( - () => import("./ui/AgedPipePopupContent"), + () => import("./ui/aged-pipe-popup-content"), ); const AgedPipeStatusByCtpvPopupContent = loadPopupContent( - () => import("./ui/AgedPipeStatusByCtpvPopupContent"), + () => import("./ui/aged-pipe-status-by-ctpv-popup-content"), ); const DredgingRegisterPopupContent = loadPopupContent( - () => import("./ui/DredgingRegisterPopupContent"), + () => import("./ui/dredging-register-popup-content"), ); const DredgingEditPopupContent = loadPopupContent( - () => import("./ui/DredgingEditPopupContent"), + () => import("./ui/dredging-edit-popup-content"), ); const ExmnBusinessRegisterPopupContent = loadPopupContent( - () => import("./ui/ExmnBusinessRegisterPopupContent"), + () => import("./ui/exmn-business-register-popup-content"), ); const ExmnPipeReportPopupContent = loadPopupContent( - () => import("./ui/ExmnPipeReportPopupContent"), + () => import("./ui/exmn-pipe-report-popup-content"), ); const ExmnResultRegisterPopupContent = loadPopupContent( - () => import("./ui/ExmnResultRegisterPopupContent"), + () => import("./ui/exmn-result-register-popup-content"), ); const ExmnSurveyDetailPopupContent = loadPopupContent( - () => import("./ui/ExmnSurveyDetailPopupContent"), + () => import("./ui/exmn-survey-detail-popup-content"), ); const FloodPreTablePopupContent = loadPopupContent( - () => import("./ui/FloodPreTablePopupContent"), + () => import("./ui/flood-pre-table-popup-content"), ); const FloodPreUploadPopupContent = loadPopupContent( - () => import("./ui/FloodPreUploadPopupContent"), + () => import("./ui/flood-pre-upload-popup-content"), ); const InfilChartPopupContent = loadPopupContent( - () => import("./ui/InfilChartPopupContent"), + () => import("./ui/infil-chart-popup-content"), ); const InftwtResultPopupContent = loadPopupContent( - () => import("./ui/InftwtResultPopupContent"), + () => import("./ui/inftwt-result-popup-content"), ); const OldPpUploadPopupContent = loadPopupContent( - () => import("./ui/OldPpUploadPopupContent"), + () => import("./ui/old-pp-upload-popup-content"), ); const OldPpTablePopupContent = loadPopupContent( - () => import("./ui/OldPpTablePopupContent"), + () => import("./ui/old-pp-table-popup-content"), ); const RdiiChartPopupContent = loadPopupContent( - () => import("./ui/RdiiChartPopupContent"), + () => import("./ui/rdii-chart-popup-content"), ); const RdiiResultPopupContent = loadPopupContent( - () => import("./ui/RdiiResultPopupContent"), + () => import("./ui/rdii-result-popup-content"), ); const ReportFilesPopupContent = loadPopupContent( - () => import("./ui/ReportFilesPopupContent"), + () => import("./ui/report-files-popup-content"), ); const ReportTabPopupContent = loadPopupContent( - () => import("./ui/ReportTabPopupContent"), + () => import("./ui/report-tab-popup-content"), ); const SearchModalPopupContent = loadPopupContent( - () => import("./ui/SearchModalPopupContent"), + () => import("./ui/search-modal-popup-content"), ); const SinkholePopupContent = loadPopupContent( - () => import("./ui/SinkholePopupContent"), + () => import("./ui/sinkhole-popup-content"), ); const SbmrgDngrMapPopupContent = loadPopupContent( - () => import("./ui/SbmrgDngrMapPopupContent"), + () => import("./ui/sbmrg-dngr-map-popup-content"), ); const FloodDngrMapPopupContent = loadPopupContent( - () => import("./ui/FloodDngrMapPopupContent"), + () => import("./ui/flood-dngr-map-popup-content"), ); const SscDetailPopupContent = loadPopupContent( - () => import("./ui/SscDetailPopupContent"), + () => import("./ui/ssc-detail-popup-content"), ); const FldngHstryPopupContent = loadPopupContent( - () => import("./ui/FldngHstryPopupContent"), + () => import("./ui/fldng-hstry-popup-content"), ); const GisEditSessionPopupContent = loadPopupContent( - () => import("./ui/GisEditSessionPopupContent"), + () => import("./ui/gis-edit-session-popup-content"), ); const GisManagementPopupContent = loadPopupContent( - () => import("./ui/GisManagementPopupContent"), + () => import("./ui/gis-management-popup-content"), ); const PipeAnalysisPopupContent = loadPopupContent( - () => import("./ui/PipeAnalysisPopupContent"), + () => import("./ui/pipe-analysis-popup-content"), ); const PipeAnalysisDetailPopupContent = loadPopupContent( - () => import("./ui/PipeAnalysisDetailPopupContent"), + () => import("./ui/pipe-analysis-detail-popup-content"), ); const RepairHistoryPopupContent = loadPopupContent( - () => import("./ui/RepairHistoryPopupContent"), + () => import("./ui/repair-history-popup-content"), ); const WaterRiskPopupContent = loadPopupContent( - () => import("./ui/WaterRiskPopupContent"), + () => import("./ui/water-risk-popup-content"), ); const FldngPredcPrstPopupContent = loadPopupContent( - () => import("../fldngPredcPrst/ui/FldngPredcPrstPopupContent"), + () => import("../fldng-predc-prst/ui/fldng-predc-prst-popup-content"), ); const GisEditPopupContent = (_props: WindowPopupContentProps) => ( diff --git a/apps/sn/src/features/windowPopup/ui/AgedPipePopupContent.tsx b/apps/sn/src/features/window-popup/ui/aged-pipe-popup-content.tsx similarity index 87% rename from apps/sn/src/features/windowPopup/ui/AgedPipePopupContent.tsx rename to apps/sn/src/features/window-popup/ui/aged-pipe-popup-content.tsx index 9ff897803..a581e067b 100644 --- a/apps/sn/src/features/windowPopup/ui/AgedPipePopupContent.tsx +++ b/apps/sn/src/features/window-popup/ui/aged-pipe-popup-content.tsx @@ -1,5 +1,5 @@ import { Suspense } from "react"; -import AgedPipePopupPage from "@/app/(map-layout)/map/components/agedPipe/AgedPipePopupPage"; +import AgedPipePopupPage from "@/app/(map-layout)/map/components/aged-pipe/aged-pipe-popup-page"; export default function AgedPipePopupContent() { return ( diff --git a/apps/sn/src/features/windowPopup/ui/AgedPipeStatusByCtpvPopupContent.tsx b/apps/sn/src/features/window-popup/ui/aged-pipe-status-by-ctpv-popup-content.tsx similarity index 77% rename from apps/sn/src/features/windowPopup/ui/AgedPipeStatusByCtpvPopupContent.tsx rename to apps/sn/src/features/window-popup/ui/aged-pipe-status-by-ctpv-popup-content.tsx index fe9338af8..970e7e31a 100644 --- a/apps/sn/src/features/windowPopup/ui/AgedPipeStatusByCtpvPopupContent.tsx +++ b/apps/sn/src/features/window-popup/ui/aged-pipe-status-by-ctpv-popup-content.tsx @@ -1,5 +1,5 @@ import { Suspense } from "react"; -import AgedPipeStatusByCtpvPopupPage from "@/app/(map-layout)/map/components/agedPipeStatusByCtpv/AgedPipeStatusByCtpvPopupPage"; +import AgedPipeStatusByCtpvPopupPage from "@/app/(map-layout)/map/components/aged-pipe-status-by-ctpv/aged-pipe-status-by-ctpv-popup-page"; export default function AgedPipeStatusByCtpvPopupContent() { return ( diff --git a/apps/sn/src/features/windowPopup/ui/DredgingEditPopupContent.tsx b/apps/sn/src/features/window-popup/ui/dredging-edit-popup-content.tsx similarity index 76% rename from apps/sn/src/features/windowPopup/ui/DredgingEditPopupContent.tsx rename to apps/sn/src/features/window-popup/ui/dredging-edit-popup-content.tsx index cf863a025..d53281fe1 100644 --- a/apps/sn/src/features/windowPopup/ui/DredgingEditPopupContent.tsx +++ b/apps/sn/src/features/window-popup/ui/dredging-edit-popup-content.tsx @@ -1,11 +1,11 @@ "use client"; -import DredgingDetailPopupPage from "@/app/(with-base-layout)/oper/dredging/components/DredgingDetailPopupPage"; +import DredgingDetailPopupPage from "@/features/dredging/ui/dredging-detail-popup-page"; -import { postPopupMessage } from "../lib/popupMessage"; +import { postPopupMessage } from "../lib/popup-message"; import { WINDOW_POPUP_MESSAGE_TYPE } from "../model/types"; import type { WindowPopupContentProps } from "../model/types"; -import { buildDredgingPopupRecord } from "./dredgingPopupRecord"; +import { buildDredgingPopupRecord } from "./dredging-popup-record"; /** 상세 화면과 같은 구조에서 선택 관로의 사진·위치·준설일자를 수정한다. */ export default function DredgingEditPopupContent({ diff --git a/apps/sn/src/features/windowPopup/ui/dredgingPopupRecord.ts b/apps/sn/src/features/window-popup/ui/dredging-popup-record.ts similarity index 100% rename from apps/sn/src/features/windowPopup/ui/dredgingPopupRecord.ts rename to apps/sn/src/features/window-popup/ui/dredging-popup-record.ts diff --git a/apps/sn/src/features/windowPopup/ui/DredgingRegisterPopupContent.tsx b/apps/sn/src/features/window-popup/ui/dredging-register-popup-content.tsx similarity index 79% rename from apps/sn/src/features/windowPopup/ui/DredgingRegisterPopupContent.tsx rename to apps/sn/src/features/window-popup/ui/dredging-register-popup-content.tsx index 47fb95d24..a3331ddb8 100644 --- a/apps/sn/src/features/windowPopup/ui/DredgingRegisterPopupContent.tsx +++ b/apps/sn/src/features/window-popup/ui/dredging-register-popup-content.tsx @@ -1,6 +1,6 @@ "use client"; -import DredgingRegisterPopupPage from "@/app/(with-base-layout)/oper/dredging/components/DredgingRegisterPopupPage"; +import DredgingRegisterPopupPage from "@/features/dredging/ui/dredging-register-popup-page"; import { useEffect, useState } from "react"; /** 준설결과 등록 화면을 공통 WindowPopup 레이아웃에 연결한다. */ diff --git a/apps/sn/src/features/windowPopup/ui/ExmnBusinessRegisterPopupContent.tsx b/apps/sn/src/features/window-popup/ui/exmn-business-register-popup-content.tsx similarity index 95% rename from apps/sn/src/features/windowPopup/ui/ExmnBusinessRegisterPopupContent.tsx rename to apps/sn/src/features/window-popup/ui/exmn-business-register-popup-content.tsx index 9e5e1e739..8552ed220 100644 --- a/apps/sn/src/features/windowPopup/ui/ExmnBusinessRegisterPopupContent.tsx +++ b/apps/sn/src/features/window-popup/ui/exmn-business-register-popup-content.tsx @@ -1,6 +1,6 @@ "use client"; -import { ExmnBusinessRegisterPopupPage } from "@/features/exmn/ui/ExmnBusinessRegisterPopupPage"; +import { ExmnBusinessRegisterPopupPage } from "@/features/exmn/ui/exmn-business-register-popup-page"; import { useEffect, useState } from "react"; import type { WindowPopupContentProps } from "../model/types"; diff --git a/apps/sn/src/features/windowPopup/ui/ExmnPipeReportPopupContent.tsx b/apps/sn/src/features/window-popup/ui/exmn-pipe-report-popup-content.tsx similarity index 100% rename from apps/sn/src/features/windowPopup/ui/ExmnPipeReportPopupContent.tsx rename to apps/sn/src/features/window-popup/ui/exmn-pipe-report-popup-content.tsx diff --git a/apps/sn/src/features/windowPopup/ui/ExmnResultRegisterPopupContent.tsx b/apps/sn/src/features/window-popup/ui/exmn-result-register-popup-content.tsx similarity index 98% rename from apps/sn/src/features/windowPopup/ui/ExmnResultRegisterPopupContent.tsx rename to apps/sn/src/features/window-popup/ui/exmn-result-register-popup-content.tsx index 621812695..ff7827bc1 100644 --- a/apps/sn/src/features/windowPopup/ui/ExmnResultRegisterPopupContent.tsx +++ b/apps/sn/src/features/window-popup/ui/exmn-result-register-popup-content.tsx @@ -1,6 +1,6 @@ "use client"; -import { ExmnResultRegisterPopupPage } from "@/features/exmn/ui/ExmnResultRegisterPopupPage"; +import { ExmnResultRegisterPopupPage } from "@/features/exmn/ui/exmn-result-register-popup-page"; import type { ExmnBusiness } from "@/features/exmn/model/exmn-business-types"; import { useEffect, useState } from "react"; import type { WindowPopupContentProps } from "../model/types"; diff --git a/apps/sn/src/features/windowPopup/ui/ExmnSurveyDetailPopupContent.tsx b/apps/sn/src/features/window-popup/ui/exmn-survey-detail-popup-content.tsx similarity index 96% rename from apps/sn/src/features/windowPopup/ui/ExmnSurveyDetailPopupContent.tsx rename to apps/sn/src/features/window-popup/ui/exmn-survey-detail-popup-content.tsx index a684fd121..03aeac021 100644 --- a/apps/sn/src/features/windowPopup/ui/ExmnSurveyDetailPopupContent.tsx +++ b/apps/sn/src/features/window-popup/ui/exmn-survey-detail-popup-content.tsx @@ -5,7 +5,7 @@ import type { ExmnJobType, } from "@/features/exmn/model/exmn-business-types"; import type { ExmnDetailDefectType } from "@/features/exmn/model/exmn-detail-table"; -import { ExmnSurveyDetailPopupPage } from "@/features/exmn/ui/ExmnSurveyDetailPopupPage"; +import { ExmnSurveyDetailPopupPage } from "@/features/exmn/ui/exmn-survey-detail-popup-page"; import { useEffect, useState } from "react"; import type { WindowPopupContentProps } from "../model/types"; diff --git a/apps/sn/src/features/windowPopup/ui/FeaturePlaceholderPopupContent.tsx b/apps/sn/src/features/window-popup/ui/feature-placeholder-popup-content.tsx similarity index 100% rename from apps/sn/src/features/windowPopup/ui/FeaturePlaceholderPopupContent.tsx rename to apps/sn/src/features/window-popup/ui/feature-placeholder-popup-content.tsx diff --git a/apps/sn/src/features/windowPopup/ui/FldngHstryPopupContent.tsx b/apps/sn/src/features/window-popup/ui/fldng-hstry-popup-content.tsx similarity index 79% rename from apps/sn/src/features/windowPopup/ui/FldngHstryPopupContent.tsx rename to apps/sn/src/features/window-popup/ui/fldng-hstry-popup-content.tsx index fcc8c0a90..c1b68d440 100644 --- a/apps/sn/src/features/windowPopup/ui/FldngHstryPopupContent.tsx +++ b/apps/sn/src/features/window-popup/ui/fldng-hstry-popup-content.tsx @@ -1,4 +1,4 @@ -import FldngHstryPopupPage from "@/app/(map-layout)/map/components/fldngHstry/FldngHstryPopupPage"; +import FldngHstryPopupPage from "@/app/(map-layout)/map/components/fldng-hstry/fldng-hstry-popup-page"; export default function FldngHstryPopupContent() { return ; diff --git a/apps/sn/src/features/windowPopup/ui/FloodDngrMapPopupContent.tsx b/apps/sn/src/features/window-popup/ui/flood-dngr-map-popup-content.tsx similarity index 87% rename from apps/sn/src/features/windowPopup/ui/FloodDngrMapPopupContent.tsx rename to apps/sn/src/features/window-popup/ui/flood-dngr-map-popup-content.tsx index b3231a12a..ee81a45da 100644 --- a/apps/sn/src/features/windowPopup/ui/FloodDngrMapPopupContent.tsx +++ b/apps/sn/src/features/window-popup/ui/flood-dngr-map-popup-content.tsx @@ -1,4 +1,4 @@ -import FloodDngrMapPopupPage from "@/app/(map-layout)/map/components/floodDngrMap/FloodDngrMapPopupPage"; +import FloodDngrMapPopupPage from "@/app/(map-layout)/map/components/flood-dngr-map/flood-dngr-map-popup-page"; import type { WindowPopupContentProps } from "../model/types"; /** 도시침수지도 목록 WindowPopup을 실제 목록 화면에 연결한다. */ diff --git a/apps/sn/src/features/windowPopup/ui/FloodPreTablePopupContent.tsx b/apps/sn/src/features/window-popup/ui/flood-pre-table-popup-content.tsx similarity index 82% rename from apps/sn/src/features/windowPopup/ui/FloodPreTablePopupContent.tsx rename to apps/sn/src/features/window-popup/ui/flood-pre-table-popup-content.tsx index 620ac38d7..ff09ad0f3 100644 --- a/apps/sn/src/features/windowPopup/ui/FloodPreTablePopupContent.tsx +++ b/apps/sn/src/features/window-popup/ui/flood-pre-table-popup-content.tsx @@ -3,15 +3,15 @@ import dynamic from "next/dynamic"; import { StandardPopupLayout } from "@common/business_components"; -import { windowPopupDefinitions } from "../model/popupDefinitions"; +import { windowPopupDefinitions } from "../model/popup-definitions"; import type { WindowPopupContentProps } from "../model/types"; -import PopupMissingParams from "./PopupMissingParams"; -import { getPopupParamValue } from "./popupContentUtils"; +import PopupMissingParams from "./popup-missing-params"; +import { getPopupParamValue } from "./popup-content-utils"; // 엑셀 파서는 브라우저 전용 의존성을 포함한다. 다른 WindowPopup까지 SSR 단계에서 // 함께 실패하지 않도록 해당 상세 팝업을 실제로 열 때만 클라이언트에서 불러온다. const TableModal = dynamic( - () => import("@/app/(with-base-layout)/oper/floodPre/tableModal"), + () => import("@/app/(with-base-layout)/oper/floodPre/table-modal"), { ssr: false }, ); diff --git a/apps/sn/src/features/windowPopup/ui/FloodPreUploadPopupContent.tsx b/apps/sn/src/features/window-popup/ui/flood-pre-upload-popup-content.tsx similarity index 85% rename from apps/sn/src/features/windowPopup/ui/FloodPreUploadPopupContent.tsx rename to apps/sn/src/features/window-popup/ui/flood-pre-upload-popup-content.tsx index f06521c16..13591286e 100644 --- a/apps/sn/src/features/windowPopup/ui/FloodPreUploadPopupContent.tsx +++ b/apps/sn/src/features/window-popup/ui/flood-pre-upload-popup-content.tsx @@ -1,13 +1,13 @@ "use client"; -import { postData } from "@/components/lib/api"; -import OperFileUploadForm from "@/components/ui/operFileUploadForm"; +import { postData } from "@/shared/api/sewerage-api"; +import OperFileUploadForm from "@/components/ui/oper-file-upload-form"; import { tokenManager } from "@/entities/auth/lib/token-manager"; import { AUTH_USER_ID } from "@/shared/config"; import { useAlertStore } from "@/shared/lib"; import { StandardPopupLayout } from "@common/business_components"; -import { postPopupMessageAndClose } from "../lib/popupMessage"; -import { windowPopupDefinitions } from "../model/popupDefinitions"; +import { postPopupMessageAndClose } from "../lib/popup-message"; +import { windowPopupDefinitions } from "../model/popup-definitions"; import { WINDOW_POPUP_MESSAGE_TYPE } from "../model/types"; export default function FloodPreUploadPopupContent() { diff --git a/apps/sn/src/features/windowPopup/ui/GisEditSessionPopupContent.tsx b/apps/sn/src/features/window-popup/ui/gis-edit-session-popup-content.tsx similarity index 95% rename from apps/sn/src/features/windowPopup/ui/GisEditSessionPopupContent.tsx rename to apps/sn/src/features/window-popup/ui/gis-edit-session-popup-content.tsx index 99bfb0309..895b80d43 100644 --- a/apps/sn/src/features/windowPopup/ui/GisEditSessionPopupContent.tsx +++ b/apps/sn/src/features/window-popup/ui/gis-edit-session-popup-content.tsx @@ -4,13 +4,13 @@ import { useEffect, useState } from "react"; import { StandardPopupLayout } from "@common/business_components"; import { Button } from "@common/components"; -import GisEditSessionPanel from "@/app/(map-layout)/map/components/gisEdit/GisEditSessionPanel"; -import type { GisEditSessionSnapshot } from "@/app/(map-layout)/map/components/gisEdit/gisEditTypes"; +import GisEditSessionPanel from "@/app/(map-layout)/map/components/gis-edit/gis-edit-session-panel"; +import type { GisEditSessionSnapshot } from "@/app/(map-layout)/map/components/gis-edit/gis-edit-types"; import { WINDOW_POPUP_MESSAGE_TYPE, type WindowPopupContentProps, } from "../model/types"; -import { windowPopupDefinitions } from "../model/popupDefinitions"; +import { windowPopupDefinitions } from "../model/popup-definitions"; const EMPTY_SNAPSHOT: GisEditSessionSnapshot = { isEditMode: false, diff --git a/apps/sn/src/features/windowPopup/ui/GisManagementPopupContent.tsx b/apps/sn/src/features/window-popup/ui/gis-management-popup-content.tsx similarity index 83% rename from apps/sn/src/features/windowPopup/ui/GisManagementPopupContent.tsx rename to apps/sn/src/features/window-popup/ui/gis-management-popup-content.tsx index 9e2dce7d7..89f2ce9e3 100644 --- a/apps/sn/src/features/windowPopup/ui/GisManagementPopupContent.tsx +++ b/apps/sn/src/features/window-popup/ui/gis-management-popup-content.tsx @@ -1,5 +1,5 @@ import { Suspense } from "react"; -import GisManagementPopupPage from "@/app/(map-layout)/map/components/gisManagement/GisManagementPopupPage"; +import GisManagementPopupPage from "@/app/(map-layout)/map/components/gis-management/gis-management-popup-page"; export default function GisManagementPopupContent() { return ( diff --git a/apps/sn/src/features/windowPopup/ui/InfilChartPopupContent.tsx b/apps/sn/src/features/window-popup/ui/infil-chart-popup-content.tsx similarity index 76% rename from apps/sn/src/features/windowPopup/ui/InfilChartPopupContent.tsx rename to apps/sn/src/features/window-popup/ui/infil-chart-popup-content.tsx index daed9f2d1..a276c5e89 100644 --- a/apps/sn/src/features/windowPopup/ui/InfilChartPopupContent.tsx +++ b/apps/sn/src/features/window-popup/ui/infil-chart-popup-content.tsx @@ -1,8 +1,8 @@ -import InfilChart from "@/app/(map-layout)/map/components/popupComponent/chart/infilChart"; -import { windowPopupDefinitions } from "../model/popupDefinitions"; +import InfilChart from "@/features/analysis-charts/ui/infil-chart"; +import { windowPopupDefinitions } from "../model/popup-definitions"; import type { WindowPopupContentProps } from "../model/types"; -import PopupMissingParams from "./PopupMissingParams"; -import { getPopupParamValue } from "./popupContentUtils"; +import PopupMissingParams from "./popup-missing-params"; +import { getPopupParamValue } from "./popup-content-utils"; export default function InfilChartPopupContent({ searchParams, diff --git a/apps/sn/src/features/windowPopup/ui/InftwtResultPopupContent.tsx b/apps/sn/src/features/window-popup/ui/inftwt-result-popup-content.tsx similarity index 90% rename from apps/sn/src/features/windowPopup/ui/InftwtResultPopupContent.tsx rename to apps/sn/src/features/window-popup/ui/inftwt-result-popup-content.tsx index ce74291ea..631794ac0 100644 --- a/apps/sn/src/features/windowPopup/ui/InftwtResultPopupContent.tsx +++ b/apps/sn/src/features/window-popup/ui/inftwt-result-popup-content.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; -import InftwtResultPopupPage from "@/app/(map-layout)/map/components/inftwtResult/InftwtResultPopupPage"; +import InftwtResultPopupPage from "@/app/(map-layout)/map/components/inftwt-result/inftwt-result-popup-page"; export default function InftwtResultPopupContent() { const [isMounted, setIsMounted] = useState(false); diff --git a/apps/sn/src/features/windowPopup/ui/MapPopupDataTable.tsx b/apps/sn/src/features/window-popup/ui/map-popup-data-table.tsx similarity index 100% rename from apps/sn/src/features/windowPopup/ui/MapPopupDataTable.tsx rename to apps/sn/src/features/window-popup/ui/map-popup-data-table.tsx diff --git a/apps/sn/src/features/windowPopup/ui/OldPpTablePopupContent.tsx b/apps/sn/src/features/window-popup/ui/old-pp-table-popup-content.tsx similarity index 82% rename from apps/sn/src/features/windowPopup/ui/OldPpTablePopupContent.tsx rename to apps/sn/src/features/window-popup/ui/old-pp-table-popup-content.tsx index 62a1e6dc5..f41078302 100644 --- a/apps/sn/src/features/windowPopup/ui/OldPpTablePopupContent.tsx +++ b/apps/sn/src/features/window-popup/ui/old-pp-table-popup-content.tsx @@ -3,15 +3,15 @@ import dynamic from "next/dynamic"; import { StandardPopupLayout } from "@common/business_components"; -import { windowPopupDefinitions } from "../model/popupDefinitions"; +import { windowPopupDefinitions } from "../model/popup-definitions"; import type { WindowPopupContentProps } from "../model/types"; -import PopupMissingParams from "./PopupMissingParams"; -import { getPopupParamValue } from "./popupContentUtils"; +import PopupMissingParams from "./popup-missing-params"; +import { getPopupParamValue } from "./popup-content-utils"; // 엑셀 파서는 브라우저 전용 의존성을 포함한다. 다른 WindowPopup까지 SSR 단계에서 // 함께 실패하지 않도록 해당 상세 팝업을 실제로 열 때만 클라이언트에서 불러온다. const TableModal = dynamic( - () => import("@/app/(with-base-layout)/oper/oldPp/tableModal"), + () => import("@/app/(with-base-layout)/oper/oldPp/table-modal"), { ssr: false }, ); diff --git a/apps/sn/src/features/windowPopup/ui/OldPpUploadPopupContent.tsx b/apps/sn/src/features/window-popup/ui/old-pp-upload-popup-content.tsx similarity index 85% rename from apps/sn/src/features/windowPopup/ui/OldPpUploadPopupContent.tsx rename to apps/sn/src/features/window-popup/ui/old-pp-upload-popup-content.tsx index e12bcbece..abdae5e0f 100644 --- a/apps/sn/src/features/windowPopup/ui/OldPpUploadPopupContent.tsx +++ b/apps/sn/src/features/window-popup/ui/old-pp-upload-popup-content.tsx @@ -1,13 +1,13 @@ "use client"; -import { postData } from "@/components/lib/api"; -import OperFileUploadForm from "@/components/ui/operFileUploadForm"; +import { postData } from "@/shared/api/sewerage-api"; +import OperFileUploadForm from "@/components/ui/oper-file-upload-form"; import { tokenManager } from "@/entities/auth/lib/token-manager"; import { AUTH_USER_ID } from "@/shared/config"; import { useAlertStore } from "@/shared/lib"; import { StandardPopupLayout } from "@common/business_components"; -import { postPopupMessageAndClose } from "../lib/popupMessage"; -import { windowPopupDefinitions } from "../model/popupDefinitions"; +import { postPopupMessageAndClose } from "../lib/popup-message"; +import { windowPopupDefinitions } from "../model/popup-definitions"; import { WINDOW_POPUP_MESSAGE_TYPE } from "../model/types"; export default function OldPpUploadPopupContent() { diff --git a/apps/sn/src/features/windowPopup/ui/PipeAnalysisDetailPopupContent.tsx b/apps/sn/src/features/window-popup/ui/pipe-analysis-detail-popup-content.tsx similarity index 81% rename from apps/sn/src/features/windowPopup/ui/PipeAnalysisDetailPopupContent.tsx rename to apps/sn/src/features/window-popup/ui/pipe-analysis-detail-popup-content.tsx index c7d6f1f2a..e0093a5e8 100644 --- a/apps/sn/src/features/windowPopup/ui/PipeAnalysisDetailPopupContent.tsx +++ b/apps/sn/src/features/window-popup/ui/pipe-analysis-detail-popup-content.tsx @@ -1,5 +1,5 @@ import { Suspense } from "react"; -import PipeAnalysisDetailPopupPage from "@/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisDetailPopupPage"; +import PipeAnalysisDetailPopupPage from "@/app/(map-layout)/map/components/pipe-analysis/pipe-analysis-detail-popup-page"; export default function PipeAnalysisDetailPopupContent() { return ( diff --git a/apps/sn/src/features/windowPopup/ui/PipeAnalysisPopupContent.tsx b/apps/sn/src/features/window-popup/ui/pipe-analysis-popup-content.tsx similarity index 84% rename from apps/sn/src/features/windowPopup/ui/PipeAnalysisPopupContent.tsx rename to apps/sn/src/features/window-popup/ui/pipe-analysis-popup-content.tsx index 215b559a9..e5d1504fd 100644 --- a/apps/sn/src/features/windowPopup/ui/PipeAnalysisPopupContent.tsx +++ b/apps/sn/src/features/window-popup/ui/pipe-analysis-popup-content.tsx @@ -1,5 +1,5 @@ import { Suspense } from "react"; -import PipeAnalysisPopupPage from "@/app/(map-layout)/map/components/pipeAnalysis/PipeAnalysisPopupPage"; +import PipeAnalysisPopupPage from "@/app/(map-layout)/map/components/pipe-analysis/pipe-analysis-popup-page"; export default function PipeAnalysisPopupContent() { return ( diff --git a/apps/sn/src/features/windowPopup/ui/popupContentUtils.ts b/apps/sn/src/features/window-popup/ui/popup-content-utils.ts similarity index 100% rename from apps/sn/src/features/windowPopup/ui/popupContentUtils.ts rename to apps/sn/src/features/window-popup/ui/popup-content-utils.ts diff --git a/apps/sn/src/features/windowPopup/ui/PopupMissingParams.tsx b/apps/sn/src/features/window-popup/ui/popup-missing-params.tsx similarity index 100% rename from apps/sn/src/features/windowPopup/ui/PopupMissingParams.tsx rename to apps/sn/src/features/window-popup/ui/popup-missing-params.tsx diff --git a/apps/sn/src/features/windowPopup/ui/PopupSearchPanel.tsx b/apps/sn/src/features/window-popup/ui/popup-search-panel.tsx similarity index 100% rename from apps/sn/src/features/windowPopup/ui/PopupSearchPanel.tsx rename to apps/sn/src/features/window-popup/ui/popup-search-panel.tsx diff --git a/apps/sn/src/features/windowPopup/ui/PopupThemeSync.tsx b/apps/sn/src/features/window-popup/ui/popup-theme-sync.tsx similarity index 85% rename from apps/sn/src/features/windowPopup/ui/PopupThemeSync.tsx rename to apps/sn/src/features/window-popup/ui/popup-theme-sync.tsx index efa9f2ed4..e6421e6a5 100644 --- a/apps/sn/src/features/windowPopup/ui/PopupThemeSync.tsx +++ b/apps/sn/src/features/window-popup/ui/popup-theme-sync.tsx @@ -2,7 +2,7 @@ import { useEffect } from "react"; -import type { WindowPopupTheme } from "../model/popupTheme"; +import type { WindowPopupTheme } from "../model/popup-theme"; /** 서버 렌더를 바꾸지 않고 팝업 마운트 후 메인 창의 현재 테마를 적용한다. */ export default function PopupThemeSync({ theme }: { theme: WindowPopupTheme }) { diff --git a/apps/sn/src/features/windowPopup/ui/RdiiChartPopupContent.tsx b/apps/sn/src/features/window-popup/ui/rdii-chart-popup-content.tsx similarity index 77% rename from apps/sn/src/features/windowPopup/ui/RdiiChartPopupContent.tsx rename to apps/sn/src/features/window-popup/ui/rdii-chart-popup-content.tsx index 148813108..a3499b274 100644 --- a/apps/sn/src/features/windowPopup/ui/RdiiChartPopupContent.tsx +++ b/apps/sn/src/features/window-popup/ui/rdii-chart-popup-content.tsx @@ -1,8 +1,8 @@ -import RdiiChart from "@/app/(map-layout)/map/components/popupComponent/chart/rdiiChart"; -import { windowPopupDefinitions } from "../model/popupDefinitions"; +import RdiiChart from "@/features/analysis-charts/ui/rdii-chart"; +import { windowPopupDefinitions } from "../model/popup-definitions"; import type { WindowPopupContentProps } from "../model/types"; -import PopupMissingParams from "./PopupMissingParams"; -import { getPopupParamValue } from "./popupContentUtils"; +import PopupMissingParams from "./popup-missing-params"; +import { getPopupParamValue } from "./popup-content-utils"; export default function RdiiChartPopupContent({ searchParams, diff --git a/apps/sn/src/features/windowPopup/ui/RdiiResultPopupContent.tsx b/apps/sn/src/features/window-popup/ui/rdii-result-popup-content.tsx similarity index 91% rename from apps/sn/src/features/windowPopup/ui/RdiiResultPopupContent.tsx rename to apps/sn/src/features/window-popup/ui/rdii-result-popup-content.tsx index d72d727fa..39a73d275 100644 --- a/apps/sn/src/features/windowPopup/ui/RdiiResultPopupContent.tsx +++ b/apps/sn/src/features/window-popup/ui/rdii-result-popup-content.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; -import RdiiResultPopupPage from "@/app/(map-layout)/map/components/rdiiResult/RdiiResultPopupPage"; +import RdiiResultPopupPage from "@/app/(map-layout)/map/components/rdii-result/rdii-result-popup-page"; export default function RdiiResultPopupContent() { const [isMounted, setIsMounted] = useState(false); diff --git a/apps/sn/src/features/windowPopup/ui/RepairHistoryPopupContent.tsx b/apps/sn/src/features/window-popup/ui/repair-history-popup-content.tsx similarity index 84% rename from apps/sn/src/features/windowPopup/ui/RepairHistoryPopupContent.tsx rename to apps/sn/src/features/window-popup/ui/repair-history-popup-content.tsx index 4b0b1c3a0..de41696c9 100644 --- a/apps/sn/src/features/windowPopup/ui/RepairHistoryPopupContent.tsx +++ b/apps/sn/src/features/window-popup/ui/repair-history-popup-content.tsx @@ -1,4 +1,4 @@ -import RepairHistoryPopupPage from "@/app/(map-layout)/map/components/repairHistory/RepairHistoryPopupPage"; +import RepairHistoryPopupPage from "@/app/(map-layout)/map/components/repair-history/repair-history-popup-page"; import type { WindowPopupContentProps } from "../model/types"; export default function RepairHistoryPopupContent(props: WindowPopupContentProps) { diff --git a/apps/sn/src/features/windowPopup/ui/ReportFilesPopupContent.tsx b/apps/sn/src/features/window-popup/ui/report-files-popup-content.tsx similarity index 90% rename from apps/sn/src/features/windowPopup/ui/ReportFilesPopupContent.tsx rename to apps/sn/src/features/window-popup/ui/report-files-popup-content.tsx index 8a029bfd6..703322aeb 100644 --- a/apps/sn/src/features/windowPopup/ui/ReportFilesPopupContent.tsx +++ b/apps/sn/src/features/window-popup/ui/report-files-popup-content.tsx @@ -1,15 +1,15 @@ "use client"; import { useEffect, useState } from "react"; -import LoadingOverlay from "@/components/ui/LoadingBar"; -import { getDcData } from "@/components/lib/api"; +import LoadingOverlay from "@/components/ui/loading-bar"; +import { getDcData } from "@/shared/api/external-service-api"; import { useAlertStore } from "@/shared/lib"; import { StandardPopupLayout } from "@common/business_components"; -import { windowPopupDefinitions } from "../model/popupDefinitions"; +import { windowPopupDefinitions } from "../model/popup-definitions"; import type { WindowPopupContentProps } from "../model/types"; -import PopupMissingParams from "./PopupMissingParams"; -import { getPopupParamValue } from "./popupContentUtils"; -import MapPopupDataTable from "./MapPopupDataTable"; +import PopupMissingParams from "./popup-missing-params"; +import { getPopupParamValue } from "./popup-content-utils"; +import MapPopupDataTable from "./map-popup-data-table"; type ReportDataFile = { no?: number; diff --git a/apps/sn/src/features/windowPopup/ui/ReportTabPopupContent.tsx b/apps/sn/src/features/window-popup/ui/report-tab-popup-content.tsx similarity index 82% rename from apps/sn/src/features/windowPopup/ui/ReportTabPopupContent.tsx rename to apps/sn/src/features/window-popup/ui/report-tab-popup-content.tsx index c988e9038..e9845a77e 100644 --- a/apps/sn/src/features/windowPopup/ui/ReportTabPopupContent.tsx +++ b/apps/sn/src/features/window-popup/ui/report-tab-popup-content.tsx @@ -1,11 +1,11 @@ "use client"; -import ReportTab from "@/app/(map-layout)/map/searchModal/reportTab"; +import ReportTab from "@/app/(map-layout)/map/search-modal/report-tab"; import { StandardPopupLayout } from "@common/business_components"; import { useEffect, useState } from "react"; -import { windowPopupDefinitions } from "../model/popupDefinitions"; +import { windowPopupDefinitions } from "../model/popup-definitions"; import type { WindowPopupContentProps } from "../model/types"; -import { getPopupParamValue } from "./popupContentUtils"; +import { getPopupParamValue } from "./popup-content-utils"; export default function ReportTabPopupContent({ searchParams, diff --git a/apps/sn/src/features/windowPopup/ui/SbmrgDngrMapPopupContent.tsx b/apps/sn/src/features/window-popup/ui/sbmrg-dngr-map-popup-content.tsx similarity index 87% rename from apps/sn/src/features/windowPopup/ui/SbmrgDngrMapPopupContent.tsx rename to apps/sn/src/features/window-popup/ui/sbmrg-dngr-map-popup-content.tsx index 116b5cd9b..badc257bb 100644 --- a/apps/sn/src/features/windowPopup/ui/SbmrgDngrMapPopupContent.tsx +++ b/apps/sn/src/features/window-popup/ui/sbmrg-dngr-map-popup-content.tsx @@ -1,4 +1,4 @@ -import SbmrgDngrMapPopupPage from "@/app/(map-layout)/map/components/sbmrgDngrMap/SbmrgDngrMapPopupPage"; +import SbmrgDngrMapPopupPage from "@/app/(map-layout)/map/components/sbmrg-dngr-map/sbmrg-dngr-map-popup-page"; import type { WindowPopupContentProps } from "../model/types"; /** 침수위험지도 목록 WindowPopup을 실제 목록 화면에 연결한다. */ diff --git a/apps/sn/src/features/windowPopup/ui/SearchModalPopupContent.tsx b/apps/sn/src/features/window-popup/ui/search-modal-popup-content.tsx similarity index 87% rename from apps/sn/src/features/windowPopup/ui/SearchModalPopupContent.tsx rename to apps/sn/src/features/window-popup/ui/search-modal-popup-content.tsx index e85b8fb1d..d4925875c 100644 --- a/apps/sn/src/features/windowPopup/ui/SearchModalPopupContent.tsx +++ b/apps/sn/src/features/window-popup/ui/search-modal-popup-content.tsx @@ -1,7 +1,7 @@ "use client"; -import SearchModal from "@/app/(map-layout)/map/searchModal/searchModal"; -import useStore from "@/store/useStore"; +import SearchModal from "@/app/(map-layout)/map/search-modal/search-modal"; +import useStore from "@/entities/reference-data/model/reference-data-store"; import { useEffect, useState } from "react"; export default function SearchModalPopupContent() { diff --git a/apps/sn/src/features/windowPopup/ui/SinkholePopupContent.tsx b/apps/sn/src/features/window-popup/ui/sinkhole-popup-content.tsx similarity index 88% rename from apps/sn/src/features/windowPopup/ui/SinkholePopupContent.tsx rename to apps/sn/src/features/window-popup/ui/sinkhole-popup-content.tsx index 387fc511e..1b9a32b13 100644 --- a/apps/sn/src/features/windowPopup/ui/SinkholePopupContent.tsx +++ b/apps/sn/src/features/window-popup/ui/sinkhole-popup-content.tsx @@ -1,5 +1,5 @@ import { Suspense } from "react"; -import SinkholePopupPage from "@/app/(map-layout)/map/components/sinkhole/SinkholePopupPage"; +import SinkholePopupPage from "@/app/(map-layout)/map/components/sinkhole/sinkhole-popup-page"; export default function SinkholePopupContent() { return ( diff --git a/apps/sn/src/features/windowPopup/ui/SscDetailPopupContent.tsx b/apps/sn/src/features/window-popup/ui/ssc-detail-popup-content.tsx similarity index 75% rename from apps/sn/src/features/windowPopup/ui/SscDetailPopupContent.tsx rename to apps/sn/src/features/window-popup/ui/ssc-detail-popup-content.tsx index 9e0ff6464..c707d23ca 100644 --- a/apps/sn/src/features/windowPopup/ui/SscDetailPopupContent.tsx +++ b/apps/sn/src/features/window-popup/ui/ssc-detail-popup-content.tsx @@ -1,8 +1,8 @@ -import Ssc from "@/app/(map-layout)/map/components/popupComponent/Ssc"; -import { windowPopupDefinitions } from "../model/popupDefinitions"; +import Ssc from "@/features/facility-detail/ui/ssc"; +import { windowPopupDefinitions } from "../model/popup-definitions"; import type { WindowPopupContentProps } from "../model/types"; -import PopupMissingParams from "./PopupMissingParams"; -import { getPopupParamValue } from "./popupContentUtils"; +import PopupMissingParams from "./popup-missing-params"; +import { getPopupParamValue } from "./popup-content-utils"; export default function SscDetailPopupContent({ searchParams, diff --git a/apps/sn/src/features/windowPopup/ui/WaterRiskPopupContent.tsx b/apps/sn/src/features/window-popup/ui/water-risk-popup-content.tsx similarity index 90% rename from apps/sn/src/features/windowPopup/ui/WaterRiskPopupContent.tsx rename to apps/sn/src/features/window-popup/ui/water-risk-popup-content.tsx index eb8cf33ee..57ee1b49c 100644 --- a/apps/sn/src/features/windowPopup/ui/WaterRiskPopupContent.tsx +++ b/apps/sn/src/features/window-popup/ui/water-risk-popup-content.tsx @@ -1,6 +1,6 @@ import { Suspense } from "react"; -import WaterRiskPopupPage from "@/app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage"; +import WaterRiskPopupPage from "@/app/(map-layout)/map/components/water-risk/water-risk-popup-page"; import type { WindowPopupContentProps } from "../model/types"; export default function WaterRiskPopupContent(props: WindowPopupContentProps) { diff --git a/apps/sn/src/features/windowPopup/ui/WindowPopupRenderer.tsx b/apps/sn/src/features/window-popup/ui/window-popup-renderer.tsx similarity index 86% rename from apps/sn/src/features/windowPopup/ui/WindowPopupRenderer.tsx rename to apps/sn/src/features/window-popup/ui/window-popup-renderer.tsx index 9eccc7610..14f98f00f 100644 --- a/apps/sn/src/features/windowPopup/ui/WindowPopupRenderer.tsx +++ b/apps/sn/src/features/window-popup/ui/window-popup-renderer.tsx @@ -1,11 +1,11 @@ "use client"; -import type { WindowPopupKey } from "../model/popupDefinitions"; +import type { WindowPopupKey } from "../model/popup-definitions"; import type { WindowPopupRegistryItem } from "../model/types"; import { windowPopupRegistry } from "../registry"; -import { getPopupParamValue } from "./popupContentUtils"; -import PopupMissingParams from "./PopupMissingParams"; +import { getPopupParamValue } from "./popup-content-utils"; +import PopupMissingParams from "./popup-missing-params"; type WindowPopupRendererProps = { popupKey: WindowPopupKey; diff --git a/apps/sn/src/shared/api/client.ts b/apps/sn/src/shared/api/client.ts index cf37e6671..0cafe8ba1 100644 --- a/apps/sn/src/shared/api/client.ts +++ b/apps/sn/src/shared/api/client.ts @@ -1,7 +1,7 @@ import { Client } from "@common/network"; import { BASE_URL } from "../config/api"; import { SITE_ID } from "../config/env"; -import { setCookie } from "../lib"; +import { setCookie } from "../lib/cookie"; setCookie("site-id-key", SITE_ID); diff --git a/apps/sn/src/shared/api/external-service-api.ts b/apps/sn/src/shared/api/external-service-api.ts new file mode 100644 index 000000000..215e7df36 --- /dev/null +++ b/apps/sn/src/shared/api/external-service-api.ts @@ -0,0 +1,59 @@ +import { + withSiteIdHeaders, + notifyUnauthorizedResponse, +} from "./service-request-context"; + +/** CMS/DC/GeoServer의 기존 원문 응답 계약. 업무 코드/지역 변환은 도메인이 담당한다. */ +// DC API 호출 +export const getDcData = async ( + url: string, + resType: string = "json", +): Promise => { + const res = await fetch("/dc-service" + url, { + method: "GET", + headers: withSiteIdHeaders(), + }); + if (res.status === 401) { + notifyUnauthorizedResponse(); + } + if (res.ok) { + let data; + if (resType === "json") data = await res.json(); + else if (resType === "blob") data = await res.blob(); + + return data; + } + throw new Error("Failed to fetch data"); +}; + +// CMS API 호출 +export const getCmsData = async (url: string, data?: object): Promise => { + const res = await fetch("/cms-service" + url, { + method: "GET", + headers: withSiteIdHeaders(), + }); + if (res.status === 401) { + notifyUnauthorizedResponse(); + } + if (res.ok) { + const data = await res.json(); + return data; + } + throw new Error("Failed to fetch data"); +}; + +// GeoServer API 호출 +export const getGeoData = async (url: string, data?: object): Promise => { + const res = await fetch("/geoserver" + url, { + method: "GET", + headers: withSiteIdHeaders(), + }); + if (res.status === 401) { + notifyUnauthorizedResponse(); + } + if (res.ok) { + const data = await res.json(); + return data; + } + throw new Error("Failed to fetch data"); +}; diff --git a/apps/sn/src/shared/api/file-api.ts b/apps/sn/src/shared/api/file-api.ts new file mode 100644 index 000000000..6581fd713 --- /dev/null +++ b/apps/sn/src/shared/api/file-api.ts @@ -0,0 +1,130 @@ +import axios, { AxiosError } from "axios"; +import { + resolveApiUrl, + DEFAULT_HEADERS, + withSiteIdHeaders, + notifyUnauthorizedResponse, +} from "./service-request-context"; + +/** 파일 응답/멀티파트 요청 adapter. 기존 헤더·파일명·timeout·오류 계약을 보존한다. */ +const parseDownloadFileName = (contentDisposition: string | null) => { + if (!contentDisposition) return undefined; + + const utf8Match = contentDisposition.match( + /filename\*\s*=\s*UTF-8''([^;]+)/i, + ); + if (utf8Match?.[1]) { + return decodeURIComponent(utf8Match[1].trim()); + } + + const plainMatch = contentDisposition.match(/filename\s*=\s*"?([^\";]+)"?/i); + return plainMatch?.[1]?.trim(); +}; + +export const postBlobData = async ( + url: string, + data: object, +): Promise<{ blob: Blob; fileName?: string }> => { + const res = await fetch(resolveApiUrl(url), { + method: "POST", + headers: withSiteIdHeaders({ + "Content-Type": "application/json", + }), + body: JSON.stringify(data), + }); + if (res.status === 401) { + notifyUnauthorizedResponse(); + } + + if (!res.ok) { + const errorText = await res.text().catch(() => ""); + throw new Error( + `Failed to fetch blob: ${res.status} ${res.statusText}${errorText ? ` - ${errorText.slice(0, 200)}` : ""} (${url})`, + ); + } + + return { + blob: await res.blob(), + fileName: parseDownloadFileName(res.headers.get("content-disposition")), + }; +}; + +export const postMutiPart = async ( + url: string, + data: FormData, +): Promise => { + try { + const res = await axios.post(resolveApiUrl(url), data, { + headers: DEFAULT_HEADERS, + timeout: 600000, // 10분 + }); + console.log("postMultiPart response:", res); + return res.data.data; + } catch (error) { + if (error instanceof AxiosError) { + const responseBody = error.response?.data; + const serverMessage = + typeof responseBody?.message === "string" && responseBody.message.trim() + ? responseBody.message.trim() + : typeof responseBody?.error?.message === "string" && + responseBody.error.message.trim() + ? responseBody.error.message.trim() + : typeof responseBody === "string" && responseBody.trim() + ? responseBody.trim() + : null; + + const nextError = new Error( + serverMessage ?? + `Multipart request failed: ${error.response?.status ?? "UNKNOWN"} ${error.response?.statusText ?? ""}`.trim(), + ) as Error & { status?: number; body?: unknown }; + nextError.status = error.response?.status; + nextError.body = responseBody; + throw nextError; + } + throw error; + } +}; + +export const getBlobData = async (url: string): Promise => { + const res = await fetch(resolveApiUrl(url), { + method: "GET", + headers: withSiteIdHeaders(), + }); + if (res.status === 401) { + notifyUnauthorizedResponse(); + } + + if (!res.ok) { + 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(); +}; + +export const getBlobDataWithHeaders = async ( + url: string, +): Promise<{ blob: Blob; fileName?: string; headers: Headers }> => { + const res = await fetch(resolveApiUrl(url), { + method: "GET", + headers: withSiteIdHeaders(), + }); + if (res.status === 401) { + notifyUnauthorizedResponse(); + } + + if (!res.ok) { + const errorText = await res.text().catch(() => ""); + throw new Error( + `Failed to fetch blob: ${res.status} ${res.statusText}${errorText ? ` - ${errorText.slice(0, 200)}` : ""} (${url})`, + ); + } + + return { + blob: await res.blob(), + fileName: parseDownloadFileName(res.headers.get("content-disposition")), + headers: res.headers, + }; +}; diff --git a/apps/sn/src/shared/api/grid-export.ts b/apps/sn/src/shared/api/grid-export.ts index 67eca5d1a..29a3ef22f 100644 --- a/apps/sn/src/shared/api/grid-export.ts +++ b/apps/sn/src/shared/api/grid-export.ts @@ -1,4 +1,4 @@ -import { deleteData, getData, postRawJson } from "@/components/lib/api"; +import { deleteData, getData, postRawJson } from "@/shared/api/sewerage-api"; /** 대량 Excel은 서버 작업으로 생성한다. 목록의 현재 page/size를 전달하지 않는다. */ export async function requestGridExcel(url: string, body: object = {}, fileName = "grid.xlsx"): Promise { diff --git a/apps/sn/src/shared/api/index.ts b/apps/sn/src/shared/api/index.ts index e7cf5731d..7f6501abd 100644 --- a/apps/sn/src/shared/api/index.ts +++ b/apps/sn/src/shared/api/index.ts @@ -1,3 +1,2 @@ -export { client } from "./client"; /** SN 내부 HTTP 기반의 공개 진입점. 인증·화면 정책은 호출자가 조합한다. */ export { ApiRequestError, createHttpTransport } from "./http-transport"; diff --git a/apps/sn/src/shared/api/search-page.ts b/apps/sn/src/shared/api/search-page.ts new file mode 100644 index 000000000..82d4a88c4 --- /dev/null +++ b/apps/sn/src/shared/api/search-page.ts @@ -0,0 +1,21 @@ +import { getData } from "./sewerage-api"; + +/** 기존 검색 query 생성과 페이지 래퍼 변환. 조건 값과 total 의미를 바꾸지 않는다. */ +export const searchData = async >( + url: string, + paramObj: T, +) => { + const params = new URLSearchParams( + Object.entries(paramObj) + .filter(([_, value]) => value !== undefined && value !== "") // 값이 있는 항목만 포함 + .reduce( + (acc, [key, value]) => { + acc[key] = String(value); + return acc; + }, + {} as Record, + ), + ); + const data = await getData(`${url}?${params.toString()}`); + return data && { data: data.content, total: data.totalElements }; +}; diff --git a/apps/sn/src/shared/api/service-request-context.ts b/apps/sn/src/shared/api/service-request-context.ts new file mode 100644 index 000000000..9ffa9f22c --- /dev/null +++ b/apps/sn/src/shared/api/service-request-context.ts @@ -0,0 +1,24 @@ +import { SITE_ID } from "../config/env"; + +/** SN 서비스의 공통 요청 정보. 인증 도메인은 여기서 import하지 않는다. */ +export const BASE_URL = "/sewerage-service/api"; + +export const DEFAULT_HEADERS = { + "X-Site-Id": SITE_ID, +}; + +export const withSiteIdHeaders = (headers: HeadersInit = {}) => ({ + ...headers, + ...DEFAULT_HEADERS, +}); + +export const resolveApiUrl = (url: string) => + url.startsWith("/") ? `${url}` : `${BASE_URL}/${url}`; + +/** 401 통지 계약. 실제 토큰 만료/로그인 이동 정책은 앱 조립에서 구독한다. */ +export const HTTP_UNAUTHORIZED_EVENT = "sn:http-unauthorized"; +export function notifyUnauthorizedResponse() { + if (typeof window !== "undefined") { + window.dispatchEvent(new Event(HTTP_UNAUTHORIZED_EVENT)); + } +} diff --git a/apps/sn/src/shared/api/sewerage-api.ts b/apps/sn/src/shared/api/sewerage-api.ts new file mode 100644 index 000000000..37965cb1f --- /dev/null +++ b/apps/sn/src/shared/api/sewerage-api.ts @@ -0,0 +1,21 @@ +import { createHttpTransport } from "./http-transport"; +import { + BASE_URL, + DEFAULT_HEADERS, + notifyUnauthorizedResponse, +} from "./service-request-context"; + +/** 기존 SN 래퍼/raw 응답 계약을 유지하는 HTTP adapter. 업무 endpoint는 호출자가 소유한다. */ +const httpTransport = createHttpTransport({ + baseUrl: BASE_URL, + headers: DEFAULT_HEADERS, + onUnauthorized: notifyUnauthorizedResponse, +}); +export const { + getData, + postData, + putData, + getRawJson, + postRawJson, + deleteData, +} = httpTransport; diff --git a/apps/sn/src/shared/lib/index.ts b/apps/sn/src/shared/lib/index.ts index 1d0c4a3a8..b8b4c52c0 100644 --- a/apps/sn/src/shared/lib/index.ts +++ b/apps/sn/src/shared/lib/index.ts @@ -4,10 +4,3 @@ export { useMenuStore } from "./use-menu-store"; export { useUserStore } from "./use-user-store"; export { usePrompt } from "./use-prompt"; export { getCookie, setCookie, removeCookie } from "./cookie"; -export { - buildStatisticsComponentImportPath, - menuMatchesStatisticsTabId, - parseStatisticsMenuUrlPath, - resolveStatisticsCategory, - resolveStatisticsTabId, -} from "./statistics-menu-url"; diff --git a/apps/sn/src/shared/providers/store-provider.tsx b/apps/sn/src/shared/providers/store-provider.tsx index db01d81b4..a0ec17b61 100644 --- a/apps/sn/src/shared/providers/store-provider.tsx +++ b/apps/sn/src/shared/providers/store-provider.tsx @@ -6,7 +6,6 @@ import { createAttachmentStore, createMenuStore, createUserStore, - createDataStore, } from "../store"; type StoreContextType = { @@ -14,7 +13,6 @@ type StoreContextType = { attachmentStore: ReturnType; menuStore: ReturnType; userStore: ReturnType; - dataStore: ReturnType; }; export const StoreContext = createContext( @@ -29,7 +27,6 @@ export const StoreProvider = ({ children }: { children: React.ReactNode }) => { attachmentStore: createAttachmentStore(), menuStore: createMenuStore(), userStore: createUserStore(), - dataStore: createDataStore(), }; } diff --git a/apps/sn/src/shared/store/data-store.ts b/apps/sn/src/shared/store/data-store.ts deleted file mode 100644 index 4414ac69f..000000000 --- a/apps/sn/src/shared/store/data-store.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { create } from "zustand"; -import { getCmsData } from "@/components/lib/api"; - -export interface Option { id: number, name: string } - -export type BIZ_CD_TYPE = { - lwpoCd: string, - upCd: string, - lwpoCdNm: string, - unit: string, - prntLwpoCd: null -}; - -export type DataState = { - ctpv: Option[]; - sgg: Option[]; - BIZ_SE_CD: BIZ_CD_TYPE[]; - BIZ_TY_CD: BIZ_CD_TYPE[]; -} - -export type FetchData = { - fetchCtpvSggData: () => Promise; - fetchBIZ_SE_CD: () => Promise; - fetchBIZ_TY_CD: () => Promise; -}; - -export type DataStore = DataState & FetchData; - -export const defaultInitState: DataState = { - ctpv: [], - sgg: [], - BIZ_SE_CD: [], - BIZ_TY_CD: [], -}; - -const fetchCtpvSggData = async (set: (state: any) => void) => { - try { - const ctpvData = await getCmsData('/api/v1/code/cache/ADSTDSTRCD'); - const ctpvList: Option[] = ctpvData - .filter((item: any) => typeof item.lwpoCd === 'string' && item.lwpoCd.length === 2) - .map((feature: any) => ({ - id: feature.lwpoCd, - name: feature.lwpoCdNm - })) - .sort((a: Option, b: Option) => a.name.localeCompare(b.name, 'ko')); - - const sggList: Option[] = ctpvData - .filter((item: any) => typeof item.lwpoCd === 'string' && item.lwpoCd.length !== 2) - .map((item: any) => ({ - id: item.lwpoCd, - name: item.lwpoCdNm - })) - .sort((a: Option, b: Option) => a.name.localeCompare(b.name, 'ko')); - - set({ ctpv: ctpvList, sgg: sggList }); - } catch (e: any) { - // alert("시도, 시군구 데이터 로드 실패: " + e.message); - console.error("CTPV 데이터 로드 실패:", e); - } -}; - -const fetchBIZ_SE_CD = async (set: (state: any) => void) => { - try { - const res = await getCmsData(`/api/v1/code/cache/BIZSECD`); - set({ BIZ_SE_CD: res }); - } catch (e: any) { - // alert("BIZ_SE_CD 데이터 로드 실패: " + e.message); - console.error("BIZ_SE_CD 데이터 로드 실패:", e); - } -}; - -const fetchBIZ_TY_CD = async (set: (state: any) => void) => { - try { - const res = await getCmsData(`/api/v1/code/cache/BIZTYCD`); - set({ BIZ_TY_CD: res }); - } catch (e: any) { - // alert("BIZ_TY_CD 데이터 로드 실패: " + e.message); - console.error("BIZ_TY_CD 데이터 로드 실패:", e); - } -}; - -export const createDataStore = (initState: DataState = defaultInitState) => { - return create((set) => ({ - ...initState, - fetchCtpvSggData: () => fetchCtpvSggData(set), - fetchBIZ_SE_CD: () => fetchBIZ_SE_CD(set), - fetchBIZ_TY_CD: () => fetchBIZ_TY_CD(set), - })); -}; \ No newline at end of file diff --git a/apps/sn/src/shared/store/index.ts b/apps/sn/src/shared/store/index.ts index 8928b7269..a41d6f928 100644 --- a/apps/sn/src/shared/store/index.ts +++ b/apps/sn/src/shared/store/index.ts @@ -2,4 +2,3 @@ export { createAlertStore } from "./alert-store"; export { createAttachmentStore } from "./attachment-store"; export { createMenuStore, type BbsType } from "./menu-store"; export { createUserStore, type IUserInfo } from "./user-store"; -export { createDataStore, type DataStore } from "./data-store"; \ No newline at end of file diff --git a/packages/common/components/src/ui/data-table/index.tsx b/packages/common/components/src/ui/data-table/index.tsx index 6872eae20..54e399474 100644 --- a/packages/common/components/src/ui/data-table/index.tsx +++ b/packages/common/components/src/ui/data-table/index.tsx @@ -11,7 +11,14 @@ import { } from "@radix-ui/react-icons"; import { cn } from "../../lib"; import { Button } from "../button"; -import { Table, TableCell, TableHead, TableHeader, TableRow } from "../table"; +import { + Table, + TableCaption, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "../table"; import type { HTMLAttributes } from "react"; declare module "@tanstack/react-table" { // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -28,6 +35,7 @@ type DataTableProps = { className?: HTMLAttributes["className"]; viewPagination?: boolean; onRowClick?: (row: T) => void; + caption?: string; }; function DataTable({ @@ -36,10 +44,12 @@ function DataTable({ className, viewPagination, onRowClick, + caption, }: DataTableProps) { return ( <> + {caption && {caption}} {table.getRowModel().rows.map((row) => { diff --git a/packages/common/components/src/ui/header/index.tsx b/packages/common/components/src/ui/header/index.tsx index 4c8c117bf..2d3209aa2 100644 --- a/packages/common/components/src/ui/header/index.tsx +++ b/packages/common/components/src/ui/header/index.tsx @@ -2,6 +2,7 @@ import "./header.css"; import { useEffect, useCallback, useState, useRef } from "react"; import { useQuery } from "@tanstack/react-query"; +import { Minus, Plus } from "lucide-react"; import { images } from "@common/assets"; import type { AuthService, TokenManager } from "@common/auth"; import { getRemainingMs } from "@common/auth"; @@ -346,9 +347,9 @@ export function Header({ {userSiteList.content.map((site) => ( setIsMobileMenuOpen(false)} > @@ -368,9 +369,9 @@ export function Header({ {userSiteList.content.map((site) => ( {site.siteNm} @@ -383,6 +384,7 @@ export function Header({ {/* 데스크톱에서 오른쪽 요소들 */}
+ {mounted ? ( isLoggedIn && ( @@ -411,6 +413,48 @@ export function Header({ ); } +function ScreenZoomControls() { + const [scale, setScale] = useState(1); + + const updateScale = (nextScale: number) => { + const boundedScale = Math.min(2, Math.max(0.5, nextScale)); + document.body.style.zoom = String(boundedScale); + setScale(boundedScale); + }; + + return ( +
+ + + {Math.round(scale * 100)}% + + +
+ ); +} + function UserMenuContent({ remainingMs, onLogout, diff --git a/packages/common/components/src/ui/table/index.tsx b/packages/common/components/src/ui/table/index.tsx index 83be366fd..45bc9949b 100644 --- a/packages/common/components/src/ui/table/index.tsx +++ b/packages/common/components/src/ui/table/index.tsx @@ -79,12 +79,31 @@ const TableRow = React.forwardRef< )); TableRow.displayName = "TableRow"; -const TableHead = React.forwardRef< - HTMLTableCellElement, - React.ThHTMLAttributes ->(({ className, ...props }, ref) => ( -
+ ); + }, +); TableHead.displayName = "TableHead"; const TableCell = React.forwardRef< diff --git a/packages/common/components/src/ui/table/table.css b/packages/common/components/src/ui/table/table.css index 2a0897d39..e54b73869 100644 --- a/packages/common/components/src/ui/table/table.css +++ b/packages/common/components/src/ui/table/table.css @@ -7,9 +7,8 @@ .table-ui th { @apply px-4 py-[1.2rem] text-[1.3rem] font-medium leading-[1.2]; } - .table-ui th[data-required]::before { - content: "*"; - color: red; + .table-ui th[data-required] .table-required-marker { + @apply ml-1 text-red-600; } .table-ui td { @apply px-2 py-[1.2rem] text-[1.3rem] font-normal leading-[1.2];
-)); +type TableHeadProps = React.ThHTMLAttributes & { + "data-required"?: boolean | string; +}; + +const TableHead = React.forwardRef( + ({ children, className, ...props }, ref) => { + const isRequired = props["data-required"] !== undefined; + + return ( + + {children} + {isRequired && ( + + (*) + + )} +